Tooling & Integration

Constrained Decoding: Making Invalid Output Unreachable

David Guzenburg/ / 8 min read

Ask-and-retry works most of the time, which is a poor guarantee for anything feeding a downstream system. Constraint changes the guarantee rather than improving the odds.

JSON schemagrammarsstructured outputreliability

Retry loops are not a strategy

The standard way to get structured output from a model is to ask for JSON, parse it, and retry when parsing fails. It works most of the time, which is the problem — most of the time is a poor guarantee for anything that feeds a downstream system.

Constrained decoding replaces the guarantee. Rather than checking the output afterwards, the sampler is restricted at each step to tokens that keep the output valid under a schema or grammar. Invalid output is not rejected; it is unreachable.

Ask and retryConstrained decoding
Invalid outputPossible; caught after the factCannot be produced
Cost of a failureA whole extra generationNone
Tail behaviourOccasionally never convergesBounded
Works withAny APIProviders or runtimes that support it
GuaranteesShape only, after parsingShape by construction

What it guarantees, and what it does not

This is the distinction that matters and the one most often blurred. Constrained decoding guarantees form. It says nothing about content.

{
  "file": "src/does_not_exist.py",
  "line": 999999,
  "severity": "critical",
  "message": "Undefined behaviour in the quantum module"
}

Every field is the right type. The file does not exist, the line is past the end, and the module is imaginary. A schema cannot catch any of that. Teams that adopt constrained decoding and stop validating semantics have swapped one class of failure for a quieter one.

Schema-valid is not correct

Keep your semantic checks. Does the file exist? Is the line within it? Is the enum value one your system handles? Constrained decoding removes parse errors, which were the failures you noticed — not the ones that mattered.

Designing a schema the model can satisfy

A schema is not only a validator; it is also an instruction. Its structure shapes what the model produces, and small design choices change output quality noticeably.

{
  "type": "object",
  "properties": {
    "reasoning": {
      "type": "string",
      "description": "Why this is a problem. Write this BEFORE deciding severity."
    },
    "severity": { "enum": ["info", "warning", "error"] },
    "file":     { "type": "string" },
    "line":     { "type": "integer", "minimum": 1 },
    "fix": {
      "type": "object",
      "properties": {
        "description": { "type": "string" },
        "confident":   { "type": "boolean" }
      },
      "required": ["description", "confident"]
    }
  },
  "required": ["reasoning", "severity", "file", "line", "fix"],
  "additionalProperties": false
}

Three deliberate choices. reasoning comes first, so the model generates its justification before committing to a severity rather than after — ordering in a schema is ordering in generation. Every categorical field is an enum, which makes an out-of-range value impossible rather than merely unlikely. And confident gives the model somewhere to express uncertainty, which is much better than it expressing uncertainty by inventing a hedge in the description field.

Two failure modes of over-constraint

Before reaching for a schema everywhere, two things go wrong when the structure is too tight.

Required fields the model cannot fill. A schema requiring line for every finding forces a number even when the issue is file-level. The model produces one, and it is wrong. Make fields optional when the answer might legitimately be "not applicable".

No room to decline. A schema with no way to express "I found nothing" or "I am not sure" guarantees a confident answer, because the structure has no other shape available. Include an explicit findings: [] path and a confidence field, or you have designed a system that cannot report an absence.

Grammars, for output that is not JSON

JSON Schema covers structured data. When the output is a language — a query, a DSL, a config format — a context-free grammar constrains it the same way.

root        ::= "SELECT " columns " FROM " table where? ";"
columns     ::= "*" | ident ("," ws ident)*
table       ::= ident
where       ::= " WHERE " condition (" AND " condition)*
condition   ::= ident ws op ws value
op          ::= "=" | "!=" | "<" | ">" | "<=" | ">="
value       ::= number | "'" [^']* "'"
ident       ::= [a-zA-Z_] [a-zA-Z0-9_]*
number      ::= [0-9]+
ws          ::= " "?

A model decoding under that grammar cannot emit a DROP, cannot emit a subquery, and cannot emit anything that fails to parse. For a tool that turns a natural-language question into a query against a fixed schema, this is a stronger safety property than any amount of instruction — the dangerous statements are not in the language.

Grammar authoring is genuinely fiddly, and this is a good task to hand an agent: give it examples of valid and invalid output and ask for a grammar that accepts the first set and rejects the second. Then test it against both sets, because a grammar that is too permissive fails silently.

Where to apply it

Use caseConstrain?Why
Output feeds a programYesA parse failure is a runtime error
Classification into fixed categoriesYes — enumRemoves invented categories entirely
Generating a query or DSLYes — grammarUnsafe constructs become unreachable
Writing codeRarelyA grammar for a real language is huge, and syntax is not the failure mode
Explaining, summarising, reviewingNoConstraint costs quality with nothing to gain

Row four is worth stating plainly, because it is a tempting idea. Models rarely produce syntactically invalid code, and the compiler catches it when they do. The failures in code generation are semantic, and no grammar addresses those.

Where the constraint lives

Support varies by where you run inference, and the difference decides what is available to you.

RuntimeMechanismNotes
Hosted provider APIStructured-output or JSON modeSchema support common; arbitrary grammars rare
Local inference serverGrammar or schema at sample timeFull control; both usually available
Your own sampling loopLogit maskingTotal control, and you maintain it

The practical consequence: if arbitrary grammars matter to your use case — a DSL, a query language, a config format — that is an argument for local inference on its own, independent of any privacy or cost consideration. It is one of the few genuinely capability-shaped reasons to run your own.

Check what your provider supports before designing around it. "JSON mode" and "constrained to this schema" are different guarantees, and the first is closer to a strong hint than to a constraint.

The cost nobody mentions

Heavy constraint degrades quality. A model forced through a narrow structure has less room to arrive at a good answer, and over-specified schemas produce output that satisfies the shape while saying less.

The practical rule: constrain the parts a program consumes, and leave the parts a human reads free. A schema with a tightly-constrained severity enum and an unconstrained reasoning string gets both properties. One that also enumerates permissible reasoning phrasings gets neither.

Takeaway

Constrained decoding makes invalid output unreachable rather than merely unlikely, which is a different guarantee from retry-and-parse. It secures form and says nothing about content — keep your semantic validation. Order schema fields so reasoning precedes conclusions, enumerate every categorical, and do not constrain the parts a human is going to read.

Keep reading
Codex vs Claude

Long-Horizon Runs: The Loop Cannot Tell Progress From Motion

A multi-hour agent run does not stop when it stops making progress. Why activity metrics rise fastest during a failing search, the degenerate solution to make the tests pass, checkpointing, budgets with defined exits, and compaction as a source of drift.

Tooling & Integration

Orchestrating Parallel Agent Sessions Without Corrupting Your Repo

Why two agents in one checkout reliably interfere, three ways to isolate them, how to split work so it stays independent, and why parallelism often just lengthens the review queue.

Tooling & Integration

LSP and Agents: Two Different Models of Understanding Code

A language server holds a resolved symbol graph; an agent holds text in a window. Why one says 'not found' and the other invents, and how to combine them.

Tooling & Integration

Normalising Agent Commits: The Only Surviving Record of Intent

Why commit quality matters more when the author cannot be asked, a format worth enforcing with a hook, declaring agent involvement in trailers, and keeping CI checks to the two that should fail.

← Event-Driven Agent Jobs: Fire on Change, Produce Information  ·  Local and Hosted Models: Deciding on Data Flow, Not Benchmarks →

All tooling & integration articles  ·  Every article