Structured Outputs and Fallback Chains: How to Stop LLMs From Breaking Your Parser
Part 3 of the AI Engineer Series. Structured outputs are how you stop the model from breaking your downstream code. JSON mode, tool calling, constrained decoding, and the fallback chain pattern that holds when models misbehave.
Why structured outputs matter more than you think
The moment an LLM output feeds into another piece of code, you have a parsing problem. The model returns markdown when you asked for JSON. It hallucinates a field you never asked for. It truncates mid-object. It wraps the JSON in a friendly explanation. Every one of these failures becomes a 500 error in your application.
This is Part 3 of the AI Engineer Series. We have a harness from Part 1 and good retrieval from Part 2. Now we need outputs that are shaped the way our code expects, every single time. The problem is harder than it looks, and most teams underestimate it until it costs them a Saturday.
Three approaches, three different tradeoffs
You have three real options in 2026, and they are not interchangeable.
JSON mode tells the provider to constrain output to syntactically valid JSON. OpenAI and most providers offer it. It guarantees parseable JSON. It does not guarantee that the JSON matches your schema. The model can return {"answer": 42} when you wanted {"summary": "...", "confidence": 0.8}.
Tool calling (also called function calling) ties the model output to a typed schema. You define the shape, the model fills it. Anthropic and OpenAI both support this. The schema is enforced server-side, so the provider often produces valid output without retries. This is the strongest default for production work.
Constrained decoding masks tokens at sampling time so the model literally cannot emit invalid output. Libraries like Outlines and XGrammar implement this. It is the only approach that guarantees 100% schema validity, but you have to be self-hosting your model to use it.
The library that has settled the structured-output question for most Python teams is instructor. It works across 15+ providers with the same API, uses Pydantic models as the schema, and handles retries automatically when validation fails.
import instructor
from anthropic import Anthropic
from pydantic import BaseModel, Field
from typing import Literal
class SupportTicket(BaseModel):
category: Literal["billing", "technical", "account", "other"]
urgency: Literal["low", "medium", "high"]
summary: str = Field(min_length=10, max_length=200)
suggested_action: str
client = instructor.from_anthropic(Anthropic())
ticket = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
response_model=SupportTicket,
messages=[{
"role": "user",
"content": "My card was charged twice for the $49 plan. Need refund."
}],
)
print(ticket.category) # "billing"
print(ticket.urgency) # "high"The failure modes you will actually see
Structured output failures are predictable. Plan for these six:
- Truncation: the model hit
max_tokensmid-object. Always setmax_tokensgenerously and check finish reasons explicitly. - Hallucinated fields: the model returns keys you did not define. Strict Pydantic validation rejects these by default. Do not relax it.
- Type drift: your schema says
int, the model returns"42"as a string. Pydantic will coerce by default. Decide whether you want coercion or strict types per field. - Enum invention: you specified
Literal["billing", "technical", "account", "other"], the model returns"refund". Validation catches it, but you need the retry to actually fix it. - Null evasion: the model returns
nullor{}when it does not know, rather than the structured "I do not know" you defined. Add an explicit "uncertain" enum value or anOptionalreason field. - Wrapper noise: the model returns valid JSON inside markdown backticks or a friendly preamble. Most libraries strip this automatically, but raw API calls do not.
The fallback chain pattern
The right model for structured outputs is not "the strongest one." It is "the cheapest one that gets it right, with an escalation path when it does not." This is the canonical use case for graceful degradation:
from pydantic import ValidationError
def extract_with_fallback(text: str, schema):
"""Try cheap model first, escalate on failure."""
# Tier 1: cheap and fast
try:
return client.messages.create(
model="claude-haiku-4-5",
max_tokens=500,
response_model=schema,
max_retries=2, # instructor retries internally
messages=[{"role": "user", "content": text}],
)
except (ValidationError, instructor.InstructorRetryException):
pass
# Tier 2: stronger model, same schema
try:
return client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
response_model=schema,
max_retries=2,
messages=[{"role": "user", "content": text}],
)
except (ValidationError, instructor.InstructorRetryException):
pass
# Tier 3: degrade schema and try again
return client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
response_model=schema.relaxed(), # optional fields, looser types
messages=[{"role": "user", "content": text}],
)Most production calls return on Tier 1. The few that fail escalate quietly. The Tier 3 fallback exists for the long tail. The whole chain is observed at the call site so you can see how often each tier fires.
Validators that improve quality
Pydantic validators run before instructor accepts an output. If a validator raises ValueError, instructor catches it, feeds the error back to the model, and retries with that feedback in the prompt. This means you can encode domain constraints directly:
from pydantic import BaseModel, field_validator
from datetime import datetime, timezone
class Invoice(BaseModel):
total_usd: float
invoice_date: datetime
line_items: list[str]
@field_validator("total_usd")
@classmethod
def positive_total(cls, v):
if v <= 0:
raise ValueError("total_usd must be positive")
return round(v, 2)
@field_validator("invoice_date")
@classmethod
def not_future(cls, v):
if v > datetime.now(timezone.utc):
raise ValueError("invoice_date cannot be in the future")
return v
@field_validator("line_items")
@classmethod
def has_items(cls, v):
if not v:
raise ValueError("invoice must have at least one line item")
return vNow when the model returns a future date or a negative total, the validator rejects it, and the next attempt has the error message in context. The model corrects itself most of the time.
The single rule that catches the most bugs
Never silently default missing fields. If the model did not return a required field, that is a signal worth logging. Many teams write code like:
# BAD
confidence = result.get("confidence", 0.5)This is how quality regressions hide for months. The field is missing because the model decided not to return it. You just replaced its decision with a constant. Catch the missing field, log it, and treat it as a data point. Pydantic with required fields does this by default. Resist the urge to make everything Optional.
When to use what
A practical decision tree:
- Working with Claude, GPT, or Gemini, want the cleanest path: tool calling via
instructor - Need rough JSON shape but not strict schema: JSON mode, no library
- Self-hosting an open model and need 100% schema validity: constrained decoding with Outlines or XGrammar
- Working with smaller open models where structured output is unreliable: constrained decoding wins, no contest
For agent workflows that go beyond single-shot extraction, look at PydanticAI. It is built on the same Pydantic foundation, adds typed tools and dataset replays, and is the natural next step when your structured-output code starts growing into an agent loop. We will come back to agents in Part 12.
What to build this week
Extend your harness with structured outputs and a fallback chain:
- Pick a real extraction task (invoice parsing, support ticket classification, resume extraction)
- Define a Pydantic model with at least one validator
- Wire instructor into your harness
- Build a 3-tier fallback: Haiku, Sonnet, Sonnet with relaxed schema
- Deliberately break it: pass garbage input, pass an input that triggers an enum violation, pass an input where the answer truly is "I do not know"
- Observe how each tier fires
You will likely find that Tier 1 handles 95% of well-formed inputs and Tier 2 handles most of the rest. That ratio is what makes structured outputs viable at scale.
What is next
Part 4: evals. Without measurement, every optimization decision becomes a guess. We will look at deterministic assertions, LLM-as-judge done right (with bias mitigation), pairwise versus pointwise scoring, and the eval set discipline that separates teams who ship from teams who hope.
Previous in the series
References
- instructor documentation by Jason Liu. The de facto standard for structured outputs in Python, with 3M+ monthly downloads.
- Anthropic Tool Use documentation. The native approach to structured outputs on the Claude API.
- OpenAI Structured Outputs guide. The Responses API approach for OpenAI models.
- Outlines documentation. Constrained decoding for self-hosted models with strict grammar guarantees.
- XGrammar. Fast constrained decoding implementation, integrated into vLLM and SGLang.
- Pydantic field validators. The pattern that lets you encode domain constraints directly into your schema.