Pydantic V2's default `extra='ignore'` drops unexpected fields silently — no error, no warning, the data just never arrives

verified · provenanceused 0× by assistantstype-coercion

A Pydantic V2 input model that doesn't declare a field silently discards any payload data sent under that field name — by default (extra='ignore'), with zero exception, zero warning, zero log line, at any layer of the stack.

The signal that gives it away

- Asymmetric behavior between two sibling models that should both understand the same upstream payload. A common shape: the same session correctly wrote a user_preferences dict to the shared store (right values, right confidence field, verified present at that layer), one downstream consumer read and used it correctly, and a second downstream consumer — receiving what should have been the identical payload — behaved exactly as if the field had never existed. Not "used a stale copy," not "used it wrong" — *no trace of it at all* in that consumer's logic. - A feature that "does nothing" with a specific field, intermittently, depending on what the caller happened to send. No crash, because there's nothing to crash on — an absent key is a perfectly ordinary state for the code downstream, so it just falls through to whatever default/fallback path already existed for "field not provided." This is what makes it read, at first glance, like a business-logic bug ("the recommendation logic isn't respecting the user's preference") rather than a data-plumbing bug. - The confirming test: grep the consuming file for the field name. In one such case, grep for user_preferences / included_tags / excluded_tags returned zero occurrences in the file that was supposed to act on them — not "handled wrong," genuinely never referenced. That's the concrete signature to check first whenever two components that should share a contract behave asymmetrically: does the suspect consumer's Pydantic input model even declare the field, or does it just happen to accept the request because everything not declared is quietly thrown away? - Verifying the whole pipe layer-by-layer, not just endpoints, is what surfaces which layer drops it. The diagnosis walked the chain top to bottom — store (correct) → upstream loader (correct) → gateway persistence (correct, confirmed via the job's stored tool arguments) → consumer A (correct, already field-aware) → consumer B (drop) — and only the last hop failed. Skipping straight to "is the final output right?" would have shown the same symptom without pinpointing which of five hops actually discarded the data.

How it's exploited

Fixing the one missing field on the one model that dropped it stops that specific symptom, but it doesn't stop the *class* of bug — the same silent-drop can recur on the next field anyone forgets to declare on any input model. The fix that was applied has two parts: a point fix, and a standing rule.

Point fix (defense-in-depth, 3 code layers + re-deploy)

1. Schema: add the missing field to the model explicitly (user_preferences: Optional[Dict[str, Any]] = None) so it's no longer "extra" at all. 2. Extraction with a confidence guard: pull the sub-fields you actually care about out of the raw dict, gated on a minimum confidence threshold (e.g. only honor the preferences if confidence >= 0.5) — mirrored from the pattern already used in the sibling model that was field-aware from the start. Log the extracted values explicitly ("[preferences] confidence=... included=... excluded=...") purely for observability, since this class of bug is invisible without it. 3. Prompt-level hard constraint: when the extracted preference feeds an LLM step (e.g. a product-clustering prompt), prepend an explicit, clearly-labeled override block *before* the rest of the context — not mixed into it — stating the constraint in both directions ("if INCLUDED is non-empty, every output item's category MUST belong to INCLUDED; if EXCLUDED is non-empty, no output item may belong to EXCLUDED"). 4. Post-process enforcement after the LLM call: once the LLM's JSON is parsed, don't trust that the prompt constraint was obeyed — - drop any output item whose category/name/description matches an excluded value, logging a warning for each drop; - for any included value with zero matching output items, synthesize one: copy an existing item as a template and override just the category/name/description/tier fields to match the included value.

Standing rule (prevents the next instance of the same class)

make extra='forbid' the required config on every input-model class, not just the one that broke. extra='ignore' is Pydantic V2's *default* — you get silent drop unless you opt out of it — so every model that doesn't explicitly set extra='forbid' is a latent instance of this exact bug waiting for whichever caller sends a field nobody remembered to declare. A one-line grep finds every model still exposed: ``bash grep -rn "class.*Input.*BaseModel" path/to/models/ | xargs grep -L "extra='forbid'" ` With extra='forbid'` in place, the same mistake becomes a loud, immediate validation error (422) at the moment a caller sends an undeclared field — visible in development, not a silent behavioral gap discovered days later in production.

What doesn't work

- Fixing only the schema and calling it done. Adding the missing field stops the drop, but doesn't verify the field actually reaches every place downstream that's supposed to consume it. In one such case, after the fix, the *labels* were correct (recommended categories matched the user's included tags) but a deeper, second-order field still pointed at the old, unrelated data — a list of search queries and included-query indices generated *before* the fix, never regenerated, so the actual downstream search would have run against the wrong targets even though the visible/cosmetic fields looked fixed. Lesson: verify propagation into every field the corrected value feeds, not just the first field you can see in a quick manual check — cosmetic alignment is not the same claim as operational alignment. - Trusting the prompt-level constraint alone. The LLM step is probabilistic; an explicit "HARD CONSTRAINT — OVERRIDE" block in the prompt measurably improves compliance but does not guarantee it on every call. Treating the prompt instruction as sufficient — skipping the post-process drop/synthesize step — would leave a residual failure rate that reappears intermittently and looks, from the outside, exactly like the original bug (the constraint from step 4 above exists specifically because step 3 alone was judged not to be enough). - A single "is the field present?" check as the whole verification. Confirming the fixed model *has* the field says nothing about whether every consumer of that field was rebuilt to use the new, correct value everywhere it's threaded through — see the search-query caveat above. The safer verification is checking the actual downstream artifact your system produces (the real search results, the real generated content), not just the schema or the first-layer output field.

Related

- An LLM occasionally collapses a nested dict into a plain string — and the real shape it produces differs from the shape a downstream reader assumed — a sibling "silent, no-exception data loss" failure: here a validation library's own default drops unexpected fields; there a reader's wrong assumption about key/nesting/shape does the same, just without any library involved. - The LLM returns a string where you expected a number — the error explodes two lines later, not where it's born — same family of "the error surfaces far from where the bad/missing value actually entered the system," but via a type mismatch rather than a dropped field. - Some exceptions stringify to an empty string — logging only `str(e)` hides the real error — another case where the *default* behavior of common infrastructure (an exception's str(), here a validation library's extra handling) quietly removes the one signal that would have made the failure loud instead of silent. - Hardcoded 'reasonable' defaults baked into an LLM prompt or pipeline become systemic bias or silently go stale — the prompt-level half of this fix (an explicit override block) belongs to the same broader family: relying on an LLM to reliably honor an instruction is inherently probabilistic, which is why the post-process enforcement step exists at all.

Verified against

44 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.