Getting Reliable Structured Output From LLMs: What Actually Works

“Just ask for JSON” works in a demo and breaks in production the first time a model adds a trailing comment, wraps the output in markdown fences, or produces valid JSON that doesn’t match your schema. Reliable structured output isn’t one trick — it’s a stack of defenses, and which layers you need depends on how much failure you can tolerate.

Layer 1: schema-constrained generation, when your provider supports it

The strongest guarantee comes from the model provider actually constraining generation to match a schema at the token level, not just being asked nicely to produce one:

response = client.messages.create(
    model="claude-sonnet-5",
    tools=[{
        "name": "extract_ticket_info",
        "description": "Extract structured info from a support ticket",
        "input_schema": {
            "type": "object",
            "properties": {
                "priority": {"type": "string", "enum": ["low", "medium", "high"]},
                "category": {"type": "string"},
            },
            "required": ["priority", "category"],
        },
    }],
    tool_choice={"type": "tool", "name": "extract_ticket_info"},  # forces this schema
    messages=[{"role": "user", "content": ticket_text}],
)

Forcing a tool call with a defined schema is the most reliable layer available, because the constraint is enforced during generation, not checked after the fact. Use this whenever the provider supports it — it eliminates most of the failure modes below before they can happen.

Layer 2: parse defensively, don’t trust “it said JSON”

Even with schema-constrained output, code that consumes the result should never assume it parses cleanly — providers change behavior, edge cases exist, and a silent parse failure that crashes downstream code is worse than one that’s caught. Every layer below raises a shared exception type, defined once:

class StructuredOutputError(Exception):
    """Raised by any layer below when structured output can't be trusted as-is."""
import json

def parse_structured_response(raw_text: str) -> dict:
    # strip common wrapping artifacts before attempting to parse
    cleaned = raw_text.strip()
    if cleaned.startswith("```"):
        cleaned = cleaned.split("```")[1]
        if cleaned.startswith("json"):
            cleaned = cleaned[4:]
        cleaned = cleaned.rsplit("```", 1)[0]

    try:
        return json.loads(cleaned.strip())
    except json.JSONDecodeError as e:
        raise StructuredOutputError(f"Could not parse response as JSON: {e}\nRaw: {raw_text[:200]}")

The point of surfacing a specific StructuredOutputError instead of letting a raw JSONDecodeError propagate is that it’s now a distinguishable failure mode your eval harness and your logging can specifically track — “how often does structured output actually fail to parse” is a number worth knowing, not something to silently swallow.

Layer 3: validate against the schema, not just “is it valid JSON”

Valid JSON that doesn’t match your expected shape is a distinct failure from invalid JSON, and it’s the one people check for less:

from jsonschema import validate, ValidationError

SCHEMA = {
    "type": "object",
    "properties": {
        "priority": {"type": "string", "enum": ["low", "medium", "high"]},
        "category": {"type": "string"},
    },
    "required": ["priority", "category"],
}

def validate_structured_output(data: dict) -> dict:
    try:
        validate(instance=data, schema=SCHEMA)
        return data
    except ValidationError as e:
        raise StructuredOutputError(f"Output didn't match schema: {e.message}")

This catches the case schema-constrained generation is supposed to prevent but occasionally doesn’t — an enum value slightly off, a field present but the wrong type. Cheap to add, and it turns a downstream KeyError three functions later into an immediate, specific failure at the source.

Layer 4: retry with the error fed back, not from scratch

When validation fails, the cheapest recovery isn’t re-prompting from zero — it’s telling the model specifically what was wrong with its own output and asking it to fix that:

def get_structured_output_with_retry(prompt, schema, max_retries=2):
    messages = [{"role": "user", "content": prompt}]
    for attempt in range(max_retries + 1):
        raw = llm_call(messages)
        try:
            data = parse_structured_response(raw)
            return validate_structured_output(data)
        except StructuredOutputError as e:
            if attempt == max_retries:
                raise
            messages.append({"role": "assistant", "content": raw})
            messages.append({"role": "user", "content": f"That output was invalid: {e}. Fix it and return only valid JSON matching the schema."})

This converges much faster than a fresh retry because the model sees exactly what it got wrong instead of guessing again from the same ambiguous instructions that produced the failure the first time.

Which layers you actually need

Schema-constrained generation (layer 1) when your provider supports it — always, it’s close to free. Defensive parsing and schema validation (layers 2–3) always, regardless of layer 1, because “the provider guarantees it” is a claim worth verifying in your own logs rather than trusting blindly. The retry loop (layer 4) is the one layer worth skipping for latency-sensitive paths where a fast, clear error is better than a slower guaranteed success — not every structured-output use case can afford the extra round trip, and that’s a legitimate tradeoff, not a shortcut.

← Back to all articles