Model Output Is Untrusted Input
A tool-calling pipeline handed json.loads a model response and got JSONDecodeError: Expecting ',' delimiter. The string looked like perfect JSON: the model had wrapped the object in a fenced code block labeled json and appended "Anything else?" after the closing brace. The parser was correct; the assumption that model text equals JSON was wrong.
What counts as acceptable — and what to do with the rest — gets decided at the boundary where a string becomes a record.
What raw output actually contains
| Defect | How it appears | What json.loads reports |
|---|---|---|
| Fenced block | fence lines around the object | Extra data |
| Trailing prose | object, then a sentence | Extra data |
| Single quotes | {'a': 1} |
Expecting property name enclosed in double quotes |
| Truncated output | {"a": 1, "b": |
Expecting value |
| Line comments | {"a": 1} // note |
Extra data |
Strip what is decoration, reject what is damage
import json, re
def extract_json(raw: str) -> dict:
raw = raw.strip()
fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", raw, re.DOTALL)
if fence:
raw = fence.group(1) # fences are decoration: safe to remove
start, end = raw.find("{"), raw.rfind("}")
if start == -1 or end == -1:
raise ValueError("no JSON object in model output")
return json.loads(raw[start:end + 1]) # prose outside the braces falls away
Input:
Here is the extraction:
```json
{"title": "Idempotency", "score": 0.92}
```
Hope that helps!
Output: {'title': 'Idempotency', 'score': 0.92}
Three decisions inside fifteen lines:
- The fence regex is anchored
^...$, so a code block inside the JSON cannot be mistaken for the wrapper. find/rfindbracket the object instead of trusting the whole string, so trailing chatter is excluded rather than fatal.- Truncation still fails: on an unclosed object,
raw[start:end + 1]raisesJSONDecodeError. Returning{}would push the damage downstream, where the traceback lands on innocent code.
Parsing yields a dict. It says nothing about whether score is present, in range, or remotely the right type — and nothing yet stops a bad value from reaching arithmetic three frames later.
A Schema That Argues Back
Once the string parses, the dict still carries no promises: keys may be missing, numbers may be strings, and nothing complains until round(score * 100) raises TypeError — far from the data that caused it. A model moves that complaint to the boundary where the data entered.
Declare the record once
from pydantic import BaseModel, ValidationError
class Summary(BaseModel):
title: str
score: float
tags: list[str] = [] # a default makes the field optional, not absent-tolerant
good = Summary(title="Idempotency", score=0.92)
print(good.model_dump())
Output: {'title': 'Idempotency', 'score': 0.92, 'tags': []}
try:
Summary(title="Idempotency", score="high")
except ValidationError as exc:
print(exc)
Output:
1 validation error for Summary
score
Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='high']
Read that block like a coordinate system:
for Summary— which model rejected the data; matters the moment models nest.score— the exact field, as a path:items.0.pricefor lists inside models.type=float_parsing— machine-readable, so a test can assert on the kind of failure instead of message text that shifts between releases.
The comparison that makes the case
Bare dict |
Pydantic model | |
|---|---|---|
| Missing required key | KeyError wherever it is first used |
ValidationError at construction |
| Wrong type | surfaces at the arithmetic, frames away | surfaces at the field, immediately |
| Extra keys | silently kept | governed by model_config |
| Introspection | grep the call sites | Summary.model_json_schema() |
model_json_schema() emits JSON Schema — the same artifact you can pass to a model when requesting Schema-Constrained Generation, so the parser's expectations and the prompt's contract come from one declaration.
Two settings deserve a deliberate choice: model_config = ConfigDict(extra="forbid") turns an unexpected key into an error, and strict validation refuses conversions like "42" → 42 that the default lax mode accepts. Strict for types you control, lax where numbers arrive from forms and CSV cells as strings.
The schema stops type errors. It does not know that an email needs @, that priority has three legal values, or that a similarity above 1.0 means the embedding call misfired.
Business Rules Live in the Model
Types stop the obviously broken. They do not know that reporter must contain an @ or that a score outside [0, 1] means an upstream call went wrong. Checking those at each call site guarantees two sites eventually disagree; on the model, every consumer inherits one rule.
Rules declared next to the field
from enum import Enum
from pydantic import BaseModel, EmailStr, field_validator
class Priority(str, Enum):
low = "low"
high = "high"
urgent = "urgent"
class Ticket(BaseModel):
reporter: EmailStr
priority: Priority
score: float
@field_validator("score")
@classmethod
def score_in_range(cls, v: float) -> float:
if not 0.0 <= v <= 1.0:
raise ValueError(f"score must be in [0, 1], got {v}")
return v
Ticket(reporter="ada@example.com", priority="high", score=0.71) # passes
Ticket(reporter="ada@example.com", priority="urgent", score=1.4) # fails below
Output of the second call:
1 validation error for Ticket
score
Value error, score must be in [0, 1], got 1.4 [type=value_error, input_value=1.4]
Three mechanisms, three jobs:
EmailStrandPriorityreuse rules that already exist. AnEnumdoubles as a type check:"URGENT"fails as a non-member, andticket.priority.valueserializes back to the wire string.@field_validator("score")runs after type coercion, sovarrives as afloatand the range check never parses anything. Reach formode="before"only when the raw input must be reshaped first.- Raising
ValueErrorproduces an indexedValidationErrorentry carrying your message. ReturningNonewould silently replace the bad value and turn a detectable defect into an invisible one.
Where a rule must not live
A validator is synchronous and in-process: no API call to confirm the email, no database lookup for the ticket ID, no sleeping. Construction that blocks for thirty seconds turns every parse into an outage risk, and a model built in a request handler puts that latency on the hot path. Cross-system checks belong in a service layer with their own timeout — while validators stay pure, deterministic, and fast, which is what makes enforcing them on every record free.
One class of rejection remains: noisy but harmless inputs — a labeled email with mixed case, an optional field submitted blank. Those should not reach the validators at all.
Sanitize Before You Reject
A field typed float rejects " Score: 0.85 " — and every slightly messy but reasonable value arriving from spreadsheets, form fields, and model text. Rejecting noise that could be normalized cheaply burns engineer time on false alarms while real defects scroll past.
Normalize before validating
from pydantic import BaseModel, field_validator
class Extract(BaseModel):
email: str
score: float | None = None
@field_validator("email", mode="before")
@classmethod
def clean_email(cls, v):
if isinstance(v, str):
v = v.strip().lower().split("<")[-1].rstrip(">")
return v # " <Ada@Example.com> " -> "ada@example.com"
@field_validator("score", mode="before")
@classmethod
def blank_to_none(cls, v):
return None if v == "" else v # absent data is None, not an error
print(Extract(email=" <Ada@Example.com> ", score=""))
Output: Extract(email='ada@example.com', score=None)
mode="before" is the whole difference: these hooks see raw input before coercion, so two short methods erase three classes of noise — case, whitespace, empty strings. score="high" still fails, because normalization never invents a number the input did not contain.
Repair or reject
| Input defect | Example | Treatment |
|---|---|---|
| Whitespace and case | " ADA@x.io " |
normalize, then validate |
| Missing optional field | no score |
default to None, document it |
| Convertible type from a messy source | "0.85" |
allow lax coercion |
| Wrong type, meaning intact | score="high" |
reject with the field path |
| Out-of-domain value | score=1.4 |
reject — a rule, not a formatting issue |
| Missing required field | no email |
reject at construction |
The dividing line: formatting defects are the input's fault and worth repairing; semantic defects carry the data's meaning and must reach the caller as errors. A pipeline that clamps score=1.4 to 1.0 converts a measurable bug into an invisible one.
With the boundary raising the right exceptions, the suite in Testing AI Code can assert on them without the network — and Environments and Dependency Hygiene keeps the pydantic version identical on every machine.
Quiz
1. json.loads reports `Extra data: line 2 column 1` on a model response. What is the most likely cause?
2. By default, when does a @field_validator run relative to type coercion?
3. A ValidationError shows `loc = ('items', 0, 'price')`. What does that give you?
4. Which input should be rejected outright rather than normalized before validation?
Flashcards
Question
What is the boundary rule for model output?
Click to reveal answer
Answer
Parse defensively first — strip fences, bracket the object away from prose — then validate the record. A dict that json.loads returned is not yet a record you can trust.
Question
What does a ValidationError contain?
Click to reveal answer
Answer
The model name, the loc path to the failing field (e.g. ('items', 0, 'price')), the raw input value, and a machine-readable type= — assert on type and loc, not message prose.
Question
Default field_validator mode versus mode="before"
Click to reveal answer
Answer
Default runs after coercion on the converted value — good for range rules. mode="before" sees raw input — good for stripping, case folding, and blank-to-None normalization.
Question
Repair or reject — where is the line?
Click to reveal answer
Answer
Formatting defects (case, whitespace, blank optional fields) get normalized; semantic defects (wrong type, out-of-domain value, missing required field) must reach the caller as errors.
Question
Why never validate across the network inside a validator?
Click to reveal answer
Answer
Validators are synchronous and in-process; a network or DB call would block every construction and put that latency on the hot path. Cross-system checks belong in a service layer with their own timeout.
Question
Why raise ValueError in a validator instead of returning None?
Click to reveal answer
Answer
Raising produces an indexed ValidationError entry carrying your message; returning None silently replaces the bad value and converts a detectable defect into an invisible one.
Revision Notes
Key Takeaways
- 1. Model text is untrusted input: strip decoration, bracket the object, and let truncation fail loudly instead of defaulting to {}
- 2. A Pydantic model moves failure from wherever the value is first used to the moment the record is constructed
- 3. field_validator default mode runs after coercion; mode="before" is for reshaping raw input
- 4. Normalize formatting noise early so semantic defects stay the only thing validation reports
- 5. Validators stay pure and local — anything needing I/O moves to a service layer
Interview Tips
- • Write the defensive parser on a whiteboard — fence regex, find/rfind bracketing — and say explicitly why truncation must still raise
- • Explain ValidationError by its parts: model, loc path, input value, type code — then say which two a test should assert on
- • Be ready for the repair-versus-reject question with a concrete pair: whitespace in an email is normalized, score=1.4 is not
- • Expect the follow-up on where validation should live if it needs the database — the answer separates model construction from service checks
Cheat Sheet
JSON Handling and Pydantic Models — Cheat Sheet
Parse defensively
import json, re
raw = raw.strip()
m = re.match(r"^```(?:json)?\s*(.*?)\s*```$", raw, re.DOTALL) # strip the fence if present
if m:
raw = m.group(1)
obj = json.loads(raw[raw.find("{"): raw.rfind("}") + 1]) # bracket prose away
Declare and enforce
class Ticket(BaseModel):
reporter: EmailStr
priority: Priority # Enum doubles as a type check
score: float
tags: list[str] = []
@field_validator("score") # AFTER coercion: v is already float
@classmethod
def in_range(cls, v):
if not 0 <= v <= 1:
raise ValueError(f"score must be in [0, 1], got {v}")
return v
@field_validator("reporter", mode="before") # BEFORE coercion: reshape raw input
@classmethod
def clean(cls, v):
return v.strip().lower().split("<")[-1].rstrip(">") if isinstance(v, str) else v
Reading a ValidationError: for <Model> → which schema · loc path → which field (items.0.price) · type= → assert on the type, not the message text.
Repair vs reject
| Defect | Treatment |
|---|---|
| whitespace, case, blank optional strings | normalize in mode="before" |
| convertible type from a known-messy source | lax coercion (default) |
wrong type ("high" → float), out-of-range value |
reject |
| missing required field | reject at construction |
| extra keys from the provider | model_config = ConfigDict(extra="forbid") |
Never reach the network or the database inside a validator — it is synchronous and in-process; those checks belong in a service layer with their own timeout.
Failure modes worth recognizing on sight: Extra data → trailing prose or fence · Expecting value → truncation · float_parsing → a number arrived as text · value_error → a business rule rejected a well-typed value.
Continue Learning
Before You Start
- No prerequisites listed.