Why regex-ing the response fails
The first version of every extraction feature asks the model to "return JSON" and then parses whatever comes back. It works in testing, because in testing you send five inputs and they all look alike. It fails in production, and it fails in a specific, tediously predictable set of ways.
- A markdown fence wrapped around the JSON, because the model is trained on chat formatting.
- A helpful preamble:
Here is the extracted data: followed by the object.
- A trailing comma, or a single quote, or an unescaped newline inside a string.
- A field you never defined, invented because the input mentioned it and the model wanted to be useful.
- A field you did define, missing entirely, because the input did not mention it and the model decided omitting was more honest than guessing.
- The value
"N/A" in an integer field. Or "unknown". Or null in a field you did not mark nullable.
- An apology instead of an object, because something about the input triggered a refusal.
Each of these is individually fixable with a regex, and the regex pile is exactly how you end up with a 200-line parser that is itself the least reliable component in the system. The fix is not a better parser. The fix is to stop letting the model emit invalid output in the first place, and then to validate what it does emit anyway.
The four mechanisms, from worst to best
| Mechanism | What it guarantees | What it does not |
| Prompt and parse | Nothing. | Everything. Do not build on this. |
| JSON mode | The output is syntactically valid JSON. | That it matches your schema, has your fields, or uses your types. |
| Tool / function calling | The model emits a named call with an arguments object shaped like your schema. | Deep validation of nested constraints, unless the provider offers a strict mode. |
| Constrained decoding | Token-by-token, the model is only permitted to emit tokens that keep the output schema-valid. Invalid output is unrepresentable. | That the content is correct. A schema-perfect object can still be wrong. |
JSON mode
JSON mode is a flag that says "the response body must parse as JSON." It solves the markdown fence, the preamble, and the trailing comma. It does not solve the schema — you can get back valid JSON with none of your fields, or with your fields carrying types you never asked for. It is a real improvement over parsing prose and it is not a solution.
Tool calling
Tool calling is the mechanism most people should reach for, because it was designed for exactly this. You describe a function with a JSON Schema for its parameters, the model returns a structured call to that function, and the provider handles the shaping. The subtle win is that it aligns with the model's training: models are heavily trained to emit well-formed tool calls, so you get better adherence than from a prompt asking for the same shape.
Many providers now offer a strict mode on top of this — a flag on the tool definition that makes the provider actually enforce the schema rather than treat it as a strong suggestion. Where that exists, use it. It typically requires additionalProperties: false and an explicit required array, which is a reasonable price for the guarantee.
Constrained decoding
Constrained decoding is the strongest mechanism and the one most people do not know they have. At each generation step, the model produces a probability distribution over the entire vocabulary; a grammar engine masks out every token that would make the partial output invalid against your schema, and sampling happens only over what remains. The model is not asked nicely to produce valid JSON — it is structurally incapable of producing anything else.
This is available locally for free. Ollama accepts a JSON Schema in the format field and enforces it during generation; llama.cpp exposes the same capability as GBNF grammars; libraries like Outlines and XGrammar implement it over other runtimes. If you are running a model locally, this is the single highest-leverage feature you are probably not using:
curl -s http://localhost:11434/api/chat -d '{
"model": "qwen3",
"stream": false,
"messages": [{"role": "user", "content": "Extract the load details: ..."}],
"format": {
"type": "object",
"properties": {
"origin_city": {"type": "string"},
"rate_usd": {"type": "number"},
"equipment": {"type": "string", "enum": ["dry_van","reefer","flatbed","unknown"]}
},
"required": ["origin_city", "rate_usd", "equipment"]
}
}'
Constrained decoding guarantees the shape and says nothing about the content. A model that does not know the rate will still emit a number, because the grammar requires one. Schema enforcement converts "unparseable garbage" into "parseable garbage" — which is a large improvement, since parseable garbage can be validated, logged, and caught, while unparseable garbage just throws.
You still need validation with retry
Whichever mechanism you use, a validation layer belongs in front of your business logic. It is the only place that can enforce the constraints schemas cannot express — that a date is in the past, that a total equals the sum of the line items, that an ID exists in your database — and it is the layer that gives the model a second chance with a useful error message instead of dying.
The pattern is short. Validate; on failure, feed the validation error back as a new turn and try once more; cap the attempts and fail loudly.
from pydantic import BaseModel, ValidationError, Field
from typing import Literal
class Load(BaseModel):
origin_city: str = Field(min_length=2)
rate_usd: float = Field(gt=0)
equipment: Literal["dry_van", "reefer", "flatbed", "unknown"]
pickup_date: str # ISO 8601
def extract(text: str, max_attempts: int = 3) -> Load:
messages = [{"role": "user", "content": PROMPT + text}]
for attempt in range(max_attempts):
raw, finish = call_model(messages, schema=Load.model_json_schema())
if finish == "max_tokens": # truncation, not a parse bug
raise OutputTruncated(attempt) # retrying identically will not help
try:
return Load.model_validate_json(raw)
except ValidationError as e:
messages += [
{"role": "assistant", "content": raw},
{"role": "user",
"content": f"That failed validation:\n{e}\nReturn corrected JSON only."},
]
raise ExtractionFailed(f"{max_attempts} attempts exhausted")
Two details in there are load-bearing. First, the validation error text goes back to the model verbatim — a good validator's error message names the field and the constraint, which is exactly the information needed to fix it, and models are strikingly good at acting on it. Second, truncation is detected separately and never retried the same way, for reasons in the next section.
Truncation is not a parse error
When a response hits the output token limit, generation stops mid-token. With constrained decoding you get a syntactically valid prefix of an invalid document; without it you get a string ending in "origin_city": "Sto. Both surface at your parser as a parse failure, and treating them as one is the bug: no amount of retrying an identical request fixes a limit that is too low.
Every major API returns a stop reason or finish reason on the response. Check it before you check the parse. If it indicates the token limit was hit, that is a configuration failure — raise the limit, shrink the requested output, or split the extraction into smaller pieces. Retrying the same call gets the same truncation and burns money doing it.
The related trap is streaming. If you stream a structured response and parse incrementally, every intermediate state is an incomplete document. Partial-JSON parsers exist and are genuinely useful for progressive UI, but nothing downstream of a partial parse may perform a side effect — you are looking at a document that is still being written and whose remaining fields may contradict what you already read.
Idempotency: the retry loop's real cost
Here is the failure that reaches production: extraction succeeds, the write succeeds, the response times out on the way back, the retry logic fires, and the same record is written twice. The model behaved perfectly. The retry loop did the damage.
The rule is simple and easy to violate: the retry loop must never contain a side effect. Structure the code so the model call and validation happen inside the loop and the write happens strictly after it returns.
# wrong — a validation retry can re-run the insert
for attempt in range(3):
data = call_model(...)
db.insert(data) # side effect INSIDE the loop
# right — retries are pure; the write happens once, keyed
data = extract(text) # loop is contained here, no side effects
db.upsert(data, key=idempotency_key(source_document_id))
Then make the write itself idempotent with a key you derive, not one the model invents. A hash of the source document plus the extraction version is ideal: deterministic, collision-resistant, and stable across retries. Never let the model generate the idempotency key — it will generate a different one on the retry, which is precisely the case the key exists to prevent.
Schema design that reduces retries
Most validation failures are avoidable by writing a better schema, which is cheaper than handling them at runtime.
- Give every enum an escape hatch. Add
"unknown" or "other" as a permitted value. Without one, a model facing an input that fits nothing on your list will pick the closest wrong option, and you will never know it guessed.
- Put a reasoning field first, and the answer after. Schemas are filled in order, so a
reasoning string before classification lets the model work through the problem in its own output before committing. It costs tokens and measurably improves accuracy on judgment calls.
- Write descriptions for every field. The schema is part of the prompt.
"rate_usd": total linehaul rate in USD, excluding fuel surcharge prevents a category of error that no amount of type enforcement catches.
- Prefer flat over deeply nested. Every level of nesting is another place for a structure mistake. Two calls with flat schemas beat one call with a four-level tree.
- Mark optional fields optional. If you require a field the source often lacks, you are asking the model to fabricate. It will.
- Set
additionalProperties: false. Both a real constraint and, in strict modes, a prerequisite for enforcement.
What to log
Structured output failures are close to impossible to debug after the fact without the raw string. Log, on every failure: the raw model output before parsing, the stop reason, the validation error, the attempt number, and the schema version. That last one matters more than it sounds — schemas drift, and "this used to work" is usually "the schema changed and nothing recorded which version produced this row."
Track your retry rate as an actual metric. A stable 2% is the system working. A rate climbing over weeks means your inputs are drifting away from what your prompt and schema were built for, and the retry loop is quietly absorbing it until the day it cannot. This is the same discipline as the automated quality gates I use for generated outreach drafts: the model produces, a deterministic checker validates, and nothing ships that fails the check.
A short decision list
| Situation | Use |
| Local model, any structured task | Constrained decoding — Ollama's format field with a JSON Schema. Free, strongest guarantee available. |
| Hosted API, extraction or classification | Tool calling with strict mode if the provider has it, plus a validation layer. |
| Hosted API, no strict mode | Tool calling plus validation with a capped retry loop. |
| Output is long (large arrays, many records) | Split into batched calls. Long structured outputs truncate, and truncation is the failure retries cannot fix. |
| Any write to a database or external service | Idempotency key derived from the source, upsert outside the retry loop, always. |
| Free-text summary with no downstream consumer | Do not force a schema. Structured output is for machine consumers, and JSON overhead on prose buys nothing. |
The through-line is that no single mechanism is sufficient. Constrained decoding gives you shape, validation gives you semantics, idempotency gives you safety under retry, and logging gives you the ability to fix it when the inputs change. Skip any one of them and the gap shows up eventually — usually as a duplicated row someone finds three weeks later. If you are building this into a real product, the same care belongs on the surrounding infrastructure; the security checklist covers the parts that bite next.
Tools referenced in this guide
- Local LLM with Ollama — where constrained decoding via the format field is free and strongest.
- RAG explained — the other half of most LLM features that touch real data.
- Security checklist — the surrounding infrastructure a data-writing LLM feature needs.
- Projects — extraction and automation pipelines built on these patterns.