Skip to content

How to parse "2 cups flour, sifted" into structured data

A 40-line regex ingredient parser, the lines that break it, and how our pipeline handles the rest.

Every recipe app hits the same wall sooner or later. You want to scale a recipe, add up a shopping list or find everything with chickpeas in it. And all you have is a pile of strings.

They look harmless at first. Then you meet the real ones. Here are a few lines that actually came through our pipeline. They're straight out of our test files:

2 cups flour, sifted
1 (14 oz) can tomatoes
2-3 cloves garlic, minced
salt to taste
½ c. + 2 tbsp butter
1/4 cup plus 2 tablespoons light brown sugar, packed
14 oz can artichoke hearts packed in water (drained (8.5 oz dry wt))
1 (15-oz) can white beans ((drained and rinsed // we like cannellini))
500g/1lb 2oz potatoes
2x400g tins chickpeas
1 teaspoon each cloves, cinnamon, and nutmeg
8 lime wedges ((NOT optional))
2 tbsp olive oil, plus more for serving
1 ½ cups flour
ca 0,5 krm salt
1 Dose(n) (425 ml; Abtr. Gew.: 250 g) Tomaten

Yes, someone wrote "(NOT optional)" in double brackets. Yes, that half is HTML-encoded twice. And the last two are Swedish and German, which bring their own fun: decimal commas, a "krm" that is one millilitre, and a plural ending in brackets.

So what are we trying to get out of a line? Roughly five things:

  • Quantity. A number, maybe a range. "2-3" is 2 to 3. "1 1/2", "1½" and "1-1/2" are all 1.5.
  • Unit. One canonical spelling. "c.", "cup" and "cups" should all become cup. Swedish "msk" and German "EL" are both tbsp.
  • Name. What the food is. "garlic", not "cloves garlic".
  • Preparation. What you do to it. Usually after the comma: "minced", "sifted".
  • Notes. Everything else worth keeping. "14 oz", "to taste", "we like cannellini".

Here's the shape we store, simplified from our schema:

type Ingredient = {
  original: string // the line exactly as the site wrote it
  display: string // the line written back from the fields below
  name: string
  quantity?: { value: number; max?: number; unit?: Unit }
  preparation?: string
  note?: string
  size?: string // "large", "small"
  optional: boolean
  ingredientId?: string // "chicken-thigh", the same in every language
}

Two rules matter more than the fields. First, original is never touched. Whatever we get wrong, the source line is still there. Second, a line we can't read is still a valid ingredient. "salt to taste" has no quantity, and that's fine. It's better to have no number than a made-up one.

A regex parser in 40 lines

Let's start the way everyone starts. One regex, a unit table and a split on the first comma. This is plain TypeScript and it runs as is:

const UNITS: Record<string, string> = {
  cup: 'cup', cups: 'cup', c: 'cup',
  tbsp: 'tbsp', tablespoon: 'tbsp', tablespoons: 'tbsp',
  tsp: 'tsp', teaspoon: 'tsp', teaspoons: 'tsp',
  g: 'g', gram: 'g', grams: 'g', kg: 'kg',
  oz: 'oz', ounce: 'oz', ounces: 'oz', lb: 'lb', lbs: 'lb',
  clove: 'clove', cloves: 'clove', can: 'can', cans: 'can',
}

const FRACTIONS: Record<string, number> = {
  '¼': 0.25, '½': 0.5, '¾': 0.75, '⅓': 1 / 3, '⅔': 2 / 3,
}

const LINE =
  /^(?<qty>\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?|[¼½¾⅓⅔])(?:\s*-\s*(?<max>\d+(?:\.\d+)?))?\s*(?<unit>[a-z]+\.?)?\s+(?<rest>.+)$/i

function toNumber(text: string): number {
  if (text in FRACTIONS) return FRACTIONS[text]
  const [whole, frac] = text.includes(' ') ? text.split(/\s+/) : ['0', text]
  const [n, d] = frac.split('/')
  return Number(whole) + (d ? Number(n) / Number(d) : Number(n))
}

type Parsed = {
  quantity?: number
  max?: number
  unit?: string
  name: string
  preparation?: string
}

export function parseLine(line: string): Parsed {
  const match = LINE.exec(line.trim())
  if (!match?.groups) return { name: line.trim() }

  const { qty, max, unit, rest } = match.groups
  const unitKey = unit?.toLowerCase().replace(/\.$/, '')
  const known = unitKey ? UNITS[unitKey] : undefined
  // Not a unit we know? Then it's part of the name: "3 eggs".
  const text = known ? rest : [unit, rest].filter(Boolean).join(' ')
  const [name, ...prep] = text.split(',')

  return {
    quantity: toNumber(qty),
    ...(max ? { max: Number(max) } : {}),
    ...(known ? { unit: known } : {}),
    name: name.trim(),
    ...(prep.length ? { preparation: prep.join(',').trim() } : {}),
  }
}

And it does well on the easy lines:

"2 cups flour, sifted"      → { quantity: 2, unit: "cup", name: "flour", preparation: "sifted" }
"2-3 cloves garlic, minced" → { quantity: 2, max: 3, unit: "clove", name: "garlic", preparation: "minced" }
"1 1/2 cups milk"           → { quantity: 1.5, unit: "cup", name: "milk" }
"½ cup sugar"               → { quantity: 0.5, unit: "cup", name: "sugar" }
"3 eggs"                    → { quantity: 3, name: "eggs" }

That's honestly most lines on most sites. If you control your input, you can stop here. Now let's feed it the rest of the gallery:

"salt to taste"           → { name: "salt to taste" }
"1 (14 oz) can tomatoes"  → { quantity: 1, name: "(14 oz) can tomatoes" }
"½ c. + 2 tbsp butter"    → { quantity: 0.5, unit: "cup", name: "+ 2 tbsp butter" }
"1½ tsp salt"             → { name: "1½ tsp salt" }
"500g/1lb 2oz potatoes"   → { name: "500g/1lb 2oz potatoes" }
"2 large onions, chopped" → { quantity: 2, name: "large onions", preparation: "chopped" }
"1 teaspoon each cloves, cinnamon, and nutmeg"
  → { quantity: 1, unit: "tsp", name: "each cloves", preparation: "cinnamon, and nutmeg" }
"2 tbsp olive oil, plus more for serving"
  → { quantity: 2, unit: "tbsp", name: "olive oil", preparation: "plus more for serving" }

Every one of these is a different kind of failure. "to taste" is a note, not part of the name. The bracket in "1 (14 oz) can" sits between the number and the unit. "½ c. + 2 tbsp" is one amount written in two units. "1½" glues a whole number to a unicode fraction. "500g" glues the unit to the number, then the slash gives the amount again in pounds. "large" is a size. "each" means one line is really three ingredients. And "plus more for serving" isn't a preparation at all.

You can patch each of these. We did. That's the funny part: the fixes are all small, and there are a lot of them.

How the Tiny Plates pipeline does it

Our ingredient parsing has two passes. First a set of plain rules, then a language model. Each one does the part it's good at.

The rules come first and run on every line. They're deterministic, fast, and they never invent anything. In order, roughly:

  1. Clean the text. Decode HTML entities (twice, because sites double-encode), strip tags, turn the fraction slash in "1⁄2" into a normal one.
  2. Lift out markers. "optional", "to taste", "(NOT optional)", plus the Swedish, German, French, Italian and Spanish versions ("efter smak", "nach Geschmack", "q.b."). Words in front of the amount like "ca" or "etwa" get dropped too.
  3. Pull out brackets as notes. Sites write unbalanced and nested ones, so leftover orphans get cleaned up.
  4. Read the amount. Whole numbers, decimals with either separator, "1 1/2", "1-1/2", "1 and 1/2", unicode fractions, and ranges joined by "-", "to", "bis", "till" or "à". Whether "1,000" is a thousand or one depends on the recipe's language.
  5. Read the unit from a table of spellings in six languages, longest first. Some are case sensitive: "T" is a tablespoon and "t" is a teaspoon. Some only count in one language: "el" is a German tablespoon but a Spanish article.
  6. Handle the special shapes. "14 oz can" becomes one can, with "14 oz" as a note. "2x400g tins" is two cans. "500g/1lb 2oz" keeps 500 g and saves "1lb 2oz" as a note. "¼ cup plus 2 tablespoons" is added up to ⅜ cup. "1 teaspoon each cloves, cinnamon, and nutmeg" is split into three lines.
  7. Split name and preparation on the first comma. But not a decimal comma: "0,5 kruka färsk mynta" is half a pot of mint.

Here's what that rule pass makes of the lines that broke our little parser. This is real output:

[
  { "original": "½ c. + 2 tbsp butter",
    "name": "butter", "quantity": { "value": 0.625, "unit": "cup" } },
  { "original": "1 (14 oz) can tomatoes",
    "name": "tomatoes", "note": "14 oz", "quantity": { "value": 1, "unit": "can" } },
  { "original": "500g/1lb 2oz potatoes",
    "name": "potatoes", "note": "1lb 2oz", "quantity": { "value": 500, "unit": "g" } },
  { "original": "2x400g tins chickpeas",
    "name": "chickpeas", "note": "400g", "quantity": { "value": 2, "unit": "can" } },
  { "original": "salt to taste", "name": "salt", "note": "to taste" }
]

Not bad. But look closer and the rules still miss things. "2 tbsp olive oil, plus more for serving" still comes out with "plus more for serving" as the preparation. "juice of one lemon" has no digit, so there's no amount. And the name is still just the words. The rules can tell you a line says "artichoke hearts packed in water". They can't tell you it's the same food as "canned or jarred artichoke hearts" in another recipe.

That's the second pass. A language model reads the whole recipe: its language, title, ingredient lines and steps. It answers in a strict JSON schema. The prompt asks it to be conservative and never invent an amount the text doesn't give. It gives each line a canonical English name and an ingredientId slug like artichoke-heart. It also adds a localName in the recipe's own language, an English preparation, and fills gaps like "juice of one lemon". It knows Swedish "msk" and German "EL" too. It can also judge things the rules can't, like whether a line is only a garnish.

We don't trust the model blindly, though. Two small rules keep it honest:

  • The parser wins on amounts it read itself. Models round, halve, or grab the wrong number on a line often enough. So when the parser and the model read the same unit, we keep the parser's number. When the model returns the same number under a different unit, like "2 msk citronsaft" answered as 2 g, the line's own unit stands. The model only fills in what the parser didn't have.
  • Ids get cleaned up in code. Ask a model for singular slugs and it'll still say sesame-seeds in one recipe and sesame-seed in the next. Or raps-oil where every other Swedish recipe says rapeseed-oil. So every id goes through a small plural and synonym table before it's stored. scallion and green-onion both become spring-onion. parmesan-cheese becomes parmesan.

That canonical id is what makes the whole thing useful. It's the same in English, Swedish and Spanish recipes. So "vitlök" and "garlic" both end up as garlic, and you can search, filter and add up shopping lists across languages. We wrote more about that in ingredient lines that read in the recipe's own language.

Both passes are versioned. When we fix a rule, we re-run it over every stored recipe from the raw data we kept. No re-scraping needed.

What comes out of the API

Here's a real recipe from the API, a kale and white bean artichoke dip. You can fetch it like this:

curl 'https://api.tinyplates.dev/recipes/69e216e1d0295ac8a90112d5' \
  -H 'Authorization: Bearer rd_your_api_key'

And here are three of its lines, trimmed a little:

[
  {
    "original": "1 (14-ounce) can artichoke hearts ((drained and quartered))",
    "display": "1 can artichoke hearts, drained and quartered (14-ounce can)",
    "name": "artichoke heart",
    "ingredientId": "artichoke-heart",
    "preparation": "drained and quartered",
    "note": "14-ounce can",
    "quantity": { "value": 1, "unit": "can" },
    "optional": false
  },
  {
    "original": "4-6 Tbsp nutritional yeast ((plus more to taste))",
    "display": "4-6 tbsp nutritional yeast (plus more to taste)",
    "name": "nutritional yeast",
    "ingredientId": "nutritional-yeast",
    "note": "plus more to taste",
    "quantity": { "value": 4, "max": 6, "unit": "tbsp" },
    "optional": false
  },
  {
    "original": "4 cups packed chopped kale ((organic when possible))",
    "display": "4 cups chopped kale, packed (organic when possible)",
    "name": "kale",
    "ingredientId": "kale",
    "preparation": "packed, chopped",
    "note": "organic when possible",
    "quantity": { "value": 4, "unit": "cup" },
    "optional": false
  }
]

original is what the site wrote. display is the line rebuilt from the fields, so it stays right when you scale the recipe or switch to metric. Everything else is for your code.

It isn't perfect. In the same recipe, "1 cup cooked (and drained) white beans" ends up with the preparation "cooked and drained" and also a leftover note that says "and drained". Small, harmless, and real. Ingredient lines are written by people, for people. Any parser that claims 100% is reading a cleaner corpus than ours.

If you'd rather run your own, there are good options. ingredient-parser is a Python library built on a trained sequence-labelling model, and it's a solid pick for English lines. Spoonacular also has a parse ingredients endpoint if you're already on their API. We lined up the main recipe APIs in the best recipe APIs. And if your lines come from one source you control, the 40-line regex above may honestly be enough.

If you just want recipes that come already parsed, with ids that work across languages, that's what Tiny Plates is for. You can ask /pantry?ingredients=artichoke-heart,kale what to cook with what you have, or merge a week of recipes into one shopping list. The free plan gives you 50 requests a day, no credit card. Grab a key and throw your ugliest line at it.