The ingredient statement on a US food label is one long string with commas in it, and it is not a list. It is a tree with an ordering guarantee that switches off partway through, and splitting it on commas discards both facts.
The order is a claim about weight
Under the US Food and Drug Administration’s food labelling regulations at 21 CFR 101.4, ingredients are listed by common or usual name in descending order of predominance by weight. That single sentence is why the string is worth extracting as a sequence rather than as a set: position one is the largest component by weight, and the relative order of any two ingredients is an assertion the manufacturer made.
For anyone building a product database, that ordering is most of the available signal about composition when no quantities are declared. A sorted-alphabetically list of ingredients, or a de-duplicated set, has thrown away the only quantitative information present. Preserve position as an explicit integer field rather than relying on array order surviving every serialisation between here and the database.
Note the boundary of the claim: it is descending by weight of the ingredient as added, not as present in the finished food, so water that evaporates during baking still ranks by the weight that went in. Your extraction does not have to know that, but anyone consuming your data does, and it belongs in the field documentation rather than being discovered later.
Parentheses are structure, not punctuation
When an ingredient is itself made of ingredients, the regulation permits declaring it by its own name followed by a parenthetical listing of its components in descending order, or alternatively dissolving its components into the main list without naming the parent at all. Both forms appear, and the first form is the one that carries structure:
INGREDIENTS: ENRICHED FLOUR (WHEAT FLOUR, NIACIN, REDUCED IRON,
THIAMINE MONONITRATE, RIBOFLAVIN, FOLIC ACID), WATER, SUGAR,
SOYBEAN OIL, YEAST, CONTAINS 2% OR LESS OF: SALT, MONOGLYCERIDES,
CALCIUM PROPIONATE (PRESERVATIVE), SOY LECITHIN.
Flatten that on commas and you get niacin as the third-largest component of the product. It is not; it is the third-largest component of the flour, present in milligrams. The nesting is the difference between a database that can answer “how much wheat is in this” and one that cannot.
Nesting goes deeper than one level in practice — a compound ingredient can contain a compound ingredient — and parenthetical content is overloaded, which is what makes naive parsing fail. Parentheses on a real label carry at least five different meanings:
- Sub-ingredients of the preceding ingredient. The case above.
- A function declaration — “CALCIUM PROPIONATE (PRESERVATIVE)”, “ASCORBIC ACID (VITAMIN C)”. One token, not a child list.
- A source qualifier — “LECITHIN (SOY)”, “NATURAL FLAVOR (CONTAINS MILK)”. This one is allergen-relevant and must not be discarded as noise.
- A quantity — “(2%)”, “(LESS THAN 2% OF THE FOLLOWING)”.
- A colour or grade note — “CARAMEL COLOR (CLASS IV)”.
Distinguishing them is a classification problem on the parenthetical contents, not a parsing problem, and it is one a language model is genuinely good at once the tree structure has been recovered mechanically. Recover the tree with a bracket-matching pass in code — which cannot get it wrong — and classify the nodes afterwards.
The 2%-or-less marker turns the ordering off
This is the detail most extraction pipelines miss, and it invalidates downstream reasoning rather than merely degrading it. The regulation permits ingredients present at 2 percent or less by weight to be listed without regard to the descending-order rule, provided they follow an appropriate quantifying statement — the familiar “CONTAINS 2% OR LESS OF” or “LESS THAN 2% OF” phrasing.
Everything after that marker is unordered. A pipeline that ranks ingredients by position across the whole statement is making claims the label does not support for the tail of the list, and those are exactly the ingredients — additives, preservatives, flavourings — that people query for.
So the parser needs a third structural concept beyond sequence and nesting: an ordering scope. Each ingredient node carries a flag saying whether it sits in the ordered region or in a below-threshold region, along with the threshold phrase as printed, since the wording varies and occasionally names a different percentage. Downstream code can then refuse to compare positions across the boundary.
The allergen statement is a separate field
The “Contains” statement that follows many ingredient lists is a distinct regulatory element under US food allergen labelling law, not the last item of the ingredient list. Merge it into the ingredient array and you get “contains: wheat, milk, soy” as an ingredient. It belongs in its own field.
It is also not a substitute for reading the ingredient statement: an allergen may be declared either in the Contains statement or in parentheses next to the ingredient itself, so an allergen-aware consumer of your data needs both fields and needs to know that either may carry the declaration. And advisory statements about shared equipment or possible cross-contact are a third, different thing again — voluntary rather than mandated — and conflating them with the Contains statement misrepresents both.
The set of major food allergens subject to mandatory declaration in the United States has changed within recent memory — sesame was added as a major allergen effective 1 January 2023 under the FASTER Act — and other jurisdictions have different lists entirely. Treat the allergen list as configuration to be checked against the FDA’s current food allergen guidance rather than as a constant compiled into a parser.
Parsing it as a grammar
Put together, the target is a recursive structure rather than a flat array of strings:
{
"statement_raw": "ENRICHED FLOUR (WHEAT FLOUR, NIACIN, ...), WATER, ...",
"ingredients": [
{
"position": 1, "name": "Enriched flour", "scope": "ordered",
"children": [
{ "position": 1, "name": "Wheat flour", "scope": "ordered", "children": [] },
{ "position": 2, "name": "Niacin", "scope": "ordered", "children": [] }
]
},
{ "position": 2, "name": "Water", "scope": "ordered", "children": [] },
{ "position": 6, "name": "Salt", "scope": "below_threshold",
"threshold_phrase": "CONTAINS 2% OR LESS OF", "children": [] },
{ "position": 8, "name": "Calcium propionate", "scope": "below_threshold",
"qualifier": { "kind": "function", "value": "preservative" }, "children": [] }
],
"contains_statement": ["Wheat", "Soy"],
"advisory_statement": "May contain milk."
}
The recognition problems that sit underneath this are worth being honest about, because they are where the errors actually come from. The text is set in capitals, often in a condensed face at six point, and frequently printed on a curved or reflective surface — conditions that challenge any recognition pipeline. Long ingredient names hyphenate across lines and the hyphen must be removed on rejoining, while genuinely hyphenated names must keep theirs. A dropped closing parenthesis at a line break turns the rest of the statement into children of one ingredient, which is the failure to test for first: assert that brackets balance in the raw string before you trust any tree built from it, and route an unbalanced statement to review rather than guessing where the bracket closed.
Top comments (0)