Kousigan A

Valid JSON, wrong values: validate LLM structured outputs

Get reliable JSON from an LLM: structured outputs or strict tool calling, Pydantic validation, refusals and cut-offs, bounded retries and schema versions.

Last verified against official docs on

Reliable JSON from an LLM (large language model) takes two layers: a JSON Schema the provider enforces, and validation of every response in your code. Refusals, cut-off responses and wrong values still get past the schema, so your code checks for each one. A library such as Pydantic does the shape check, and a bounded retry that sends the error back fixes some failures.

The provider and library behaviour below comes from each one’s official docs, last verified on 1 November 2026. No provider API was called for this post. The Python examples were last verified on 1 November 2026 against Pydantic 2.13.4, with a stub in place of the model call.

This is part 28 of the series. Part 26 built an eval suite for what a model says. This part makes sure your code can read it.

JSON mode, structured outputs and tool calling do different jobs

JSON mode, schema-constrained structured outputs and tool calling are three ways to get machine-readable output from a model, and they promise different things.

JSON mode asks for valid JSON in any shape. OpenAI’s JSON mode docs say it “will not guarantee the output matches any specific schema, only that it is valid and parses without errors.”

Structured outputs hold the response to a JSON Schema you send with the request. Anthropic’s docs describe the mechanism: the API compiles your schema “into a grammar that constrains Claude’s output”. The keys and types come out as declared.

Tool calling, also called function calling, uses a schema for the arguments of a function the model asks your code to run. With strict mode on, those arguments follow the schema too.

Pick by where the output goes. OpenAI’s guide says: “If you are connecting the model to tools, functions, data, etc. in your system, then you should use function calling”. A structured response format suits the model’s reply to the user. Anthropic’s docs split it the same way: JSON outputs control “what Claude says”, and strict tool use controls “how Claude calls your functions”. One request can use both.

What OpenAI, Anthropic and Gemini document

Provider Schema-constrained output Tool calling Limits the docs state
OpenAI (Structured Outputs, function calling) json_schema format with strict: true, in text.format (Responses API) or response_format (Chat Completions). JSON mode is { "type": "json_object" }, and the API errors if “JSON” is nowhere in the context strict: true on a function. Left out, the Responses API tries strict mode and falls back to best effort; Chat Completions stays non-strict. Every field in required (allow null for optional ones), additionalProperties: false, and a root object that is not anyOf. Up to 5000 object properties, 10 levels of nesting and 1000 enum values. allOf, not and if/then/else are not supported (supported schemas)
Anthropic (structured outputs, strict tool use) JSON outputs: output_config.format with type: "json_schema". SDK helpers take a Pydantic model or a Zod schema strict: true on a tool No recursive schemas, no minimum, maximum, multipleOf, minLength or maxLength, and minItems of 0 or 1 only. Per request: 20 strict tools, 24 optional parameters, 16 union-type parameters. Unsupported features return a 400 error (limitations)
Google Gemini (structured outputs) response_format with mime_type: "application/json" and a schema. The Google GenAI SDKs take Pydantic or Zod Function calling is for taking action during a conversation. Structured outputs combined with tools is a preview for Gemini 3 series models minLength, maxLength, minProperties, maxProperties, uniqueItems and contains are “not enforced” and stripped with no error; minItems and maxItems are enforced. Very large or deeply nested schemas may fail with 400 INVALID_ARGUMENT (limitations)

Every row comes from the linked docs, last verified on 1 November 2026; none was tested for this post.

The same schema can get different treatment from each provider. A maxLength on a string gets a 400 error from Anthropic’s raw API, and Gemini drops it without a word. Anthropic’s SDK helpers move such constraints into the field description, and a helper that validates responses checks them there.

Validate every response in code, even with strict mode on

Validation in your own code still matters when the provider enforces the schema. OpenAI’s guide says “Structured Outputs can still contain mistakes.” Gemini’s best practices say: “While output is syntactically correct JSON, always validate values in your application.”

A validation library lets one definition do both jobs. You send its JSON Schema to the provider and check the response against the same model. That also keeps the schema and your types from drifting apart, which OpenAI’s guide warns about. Pydantic is the example here because all three providers’ Python SDKs accept it. In TypeScript, Zod plays the same role, and its .safeParse() returns a result object with either the parsed data or the error.

An example Pydantic model for a made-up feature that reads customer emails and pulls out a refund request:

refund_schema.py (example)
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

SCHEMA_VERSION = "refund_request.v2"


class RefundRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")

    order_id: str = Field(pattern=r"^ORD-\d{6}$")
    amount_minor: int = Field(gt=0)  # cents, pence or paise
    currency: Literal["GBP", "EUR", "INR", "USD"]
    reason: Literal["damaged", "late", "wrong_item", "other"]
    summary: str = Field(max_length=300)

RefundRequest.model_json_schema() gives the JSON Schema to send, and RefundRequest.model_validate_json(text) checks the response. With extra="forbid", an unexpected key fails validation, and the generated schema gets the additionalProperties: false that OpenAI and Anthropic require. Pydantic’s models docs say “Providing extra data is not permitted.” Text that is not valid JSON at all fails with the json_invalid error type.

A failed check raises one ValidationError holding every problem. Pydantic’s error docs list errors() for the details of each one, including its location loc and a message msg. str(e) gives a readable version, and that is what the retry below sends back.

What still breaks after the schema passes

A response can pass through a strict schema setting and still be unusable. Each failure below shows up in a different place, so the code checks for each one separately, before and after parsing.

Failure What the docs say What your code does
Refusal OpenAI adds a refusal field, because “a refusal does not necessarily follow the schema”. Anthropic returns stop_reason: "refusal" with a 200 status, and you’re “billed for the tokens generated”. Check before parsing. Don’t resend the same input; show a message or route it to a person.
Cut off at the token limit OpenAI: an incomplete response when “you reach a max tokens limit”. Anthropic: stop_reason: "max_tokens", and “The output might be incomplete and not match your schema”. Check before parsing. Raise the output limit, or ask for less per call.
Enum value in the wrong case Anthropic: structured outputs “don’t guarantee the capitalization of string enum and const values” Avoid enum values that differ only by case, and compare case-insensitively.
Input that doesn’t fit the schema OpenAI: the model “will always try to adhere to the provided schema, which can result in hallucinations if the input is completely unrelated” Give the schema a way to say nothing was found, such as a nullable field.
Valid JSON, wrong value Gemini asks for “robust error handling for schema-compliant but semantically incorrect outputs” Check the values against your own data.

The rows quote the linked docs, last verified on 1 November 2026. Gemini’s structured output page doesn’t cover refusals or truncation, so check how its API reports a stopped or blocked response before you parse.

Wrong values pass every schema

A schema checks shape and type, and a constrained response can still carry the wrong number. An email that mentions two orders can produce a perfectly valid order_id for the wrong one. Checks against your own records catch some of these.

An example business check for the made-up refund feature:

refund_checks.py (example)
def check_against_order(req: RefundRequest, order) -> list[str]:
    if order is None:
        return [f"{req.order_id} is not a known order"]
    problems = []
    if req.currency != order.currency:
        problems.append("currency differs from the order")
    if req.amount_minor > order.total_minor:
        problems.append("refund is more than the order total")
    return problems

Send these failures to a person. A retry that says “the amount is too high” invites the model to pick a lower number, and that number is a guess. Treat the parsed object as untrusted input, like anything else a model writes. Part 20 covers what that means for the browser and the email. Some wrong values look right to any check you can write, and measuring those is the job of an eval suite.

Retry with the validation error, a fixed number of times

A retry that sends the validation error back tells the model which field failed and why. OpenAI’s JSON mode docs suggest the same pairing when schema enforcement isn’t available: “use a validation library and potentially retries”. With strict mode on, Anthropic’s docs promise “No retries needed for schema violations”. Retries then mostly cover constraints the provider couldn’t enforce, like a maxLength that Gemini strips.

An example loop, where call_model and log_failure stand in for your own provider wrapper and logging:

extract_refund.py (example)
from pydantic import ValidationError

MAX_ATTEMPTS = 3  # the first try plus two retries


def extract_refund(email_text: str) -> RefundRequest | None:
    messages = [{"role": "user", "content": email_text}]
    for attempt in range(1, MAX_ATTEMPTS + 1):
        result = call_model(messages, schema=RefundRequest.model_json_schema())

        if result.stop == "refusal":
            log_failure("refusal", attempt)
            return None  # send to a person; resending won't help
        if result.stop == "max_tokens":
            log_failure("truncated", attempt)
            return None  # raise the limit or shrink the task, then try again

        try:
            return RefundRequest.model_validate_json(result.text)
        except ValidationError as err:
            log_failure("invalid", attempt, err.errors())
            messages += [
                {"role": "assistant", "content": result.text},
                {"role": "user", "content": (
                    "Your JSON failed validation:\n"
                    f"{err}\n"
                    "Return the corrected JSON only."
                )},
            ]
    return None  # out of attempts: send to a person

Your wrapper maps each provider’s stop signal to result.stop. On Anthropic, that’s stop_reason. On OpenAI’s Responses API, a cut-off shows as status: "incomplete" with incomplete_details.reason set to max_output_tokens, per its guide. A refusal there is a content item of type refusal.

The cap lives in code, and the last step is always a fallback a person can see. Each retry sends the whole conversation again and pays for it, as Part 22 covers. Log every failure with its kind and the schema version, so your traces show which field keeps failing (Part 21 covers tracing).

Version the schema like an API contract

A schema change is a contract change for every piece of code that reads the output, including rows you stored last month. Adding an optional field is usually safe. Renaming a field or changing its type breaks older readers. Part 18 covers breaking and non-breaking changes to a contract, and the same rules apply here.

Keep three habits:

  1. Give each schema a name and a version, such as refund_request.v2, and store that version with every saved result and trace.
  2. Keep a parser for each version still in storage, so old rows stay readable.
  3. Run your eval set against the new schema before you switch traffic.

An example of reading stored rows by version:

load_refund.py (example)
PARSERS = {
    "refund_request.v1": RefundRequestV1,
    "refund_request.v2": RefundRequest,
}


def load_refund(row: dict):
    return PARSERS[row["schema_version"]].model_validate(row["data"])

Plan the rollout around the provider’s schema cache too. Anthropic’s docs say the first request with a new schema has “additional latency while the grammar compiles”. The compiled grammar is cached for 24 hours from last use, and changing the schema’s structure invalidates it. OpenAI’s guide also mentions extra latency on the first request with a schema, and one of its notes limits that to fine-tuned models.

To keep the JSON Schema and your code in step, OpenAI’s guide suggests “a CI step that automatically generates the JSON Schema from type definitions (or vice-versa)”. With Pydantic, model_json_schema() is that generator.

Two prompts for structured output in an AI feature

A coding agent can find every place your code reads model output and add the checks. Neither prompt relies on one tool’s features.

Prompt 1: find where model output is parsed

An example prompt for mapping the risk before you change anything.

Prompt 1: find where model output is parsed
Task: review how <the feature or service> reads model responses.
Start from: <path or entry point>

Do not change code and do not call any model API.
For every place the code uses a model's response:
1. Which mode is used: plain text, JSON mode, a JSON Schema
   (structured outputs), or tool calling? Is strict mode on?
2. Is the response validated in code? With what?
3. Are refusals and token-limit cut-offs checked before parsing?
4. Are values checked against the system's own data?
5. Are retries capped? What is sent back on a retry?
6. Is the schema versioned, and is the version stored with results?

Cite the file and line for each answer. Mark anything you could not
confirm as unverified.
Never print, expose or commit secret values.
Do not commit, push, deploy, run destructive commands or make external
changes unless I explicitly authorise it.
If two reasonable attempts fail, stop and report the failure rather
than repeatedly guessing.

End with the three places most likely to break on a bad response.

Prompt 2: add validation and bounded retries to one call

An example prompt for fixing one model call at a time.

Prompt 2: add validation and bounded retries to one call
Task: add schema validation to the model call in <file and function>.
Validation library: <Pydantic, Zod or the one the project uses>

1. Define the output as one schema in that library, and generate the
   JSON Schema sent to the provider from it.
2. Check the provider's documented schema limits, and list any
   constraint it will reject or ignore.
3. Before parsing, handle refusals and token-limit cut-offs.
4. Validate every response. On a validation error, retry at most
   <N> times, sending the error back. Then fall back to <fallback>.
5. Never retry business-rule failures; return them for a person.
6. Add a schema version and log it with every failure.
7. Add tests for: invalid JSON, a missing field, an extra field,
   a refusal, a cut-off, and a value that breaks a business rule.

Never print, expose or commit secret values.
Do not commit, push, deploy, run destructive commands or make external
changes unless I explicitly authorise it.
If two reasonable attempts fail, stop and report the failure rather
than repeatedly guessing.

Read the tests it writes first; a test that mocks the validator proves nothing.

A structured output checklist

An example checklist, in the order of this post:

Structured output checklist
STRUCTURED OUTPUT: REVIEW

□ Structured outputs or strict tool calling, not JSON mode alone
□ One schema in code; the JSON Schema is generated from it
□ Provider's schema limits checked; ignored constraints known
□ Refusal checked before parsing
□ Token-limit cut-off checked before parsing
□ Every response validated in code
□ Values checked against your own data
□ Retries capped; the validation error sent back
□ Business-rule failures go to a person, never a retry
□ Schema versioned; version stored with results and traces
□ Old versions still parse
□ Eval set run before a schema change ships

Quick answers

What is the difference between JSON mode and structured outputs?

JSON (JavaScript Object Notation) mode asks a model for valid JSON in any shape. Structured outputs hold the response to a JSON Schema you supply, so required keys and types come out as declared. OpenAI recommends structured outputs over JSON mode where a model supports them.

Do structured outputs guarantee correct values?

Structured outputs constrain the shape of a response, and the values inside it can still be wrong. OpenAI’s guide says structured outputs “can still contain mistakes”, and Gemini’s docs ask developers to validate values in the application.

Why does an LLM return incomplete JSON?

A large language model (LLM) returns incomplete JSON (JavaScript Object Notation) most often when the response hits the output token limit. Anthropic reports this as stop_reason: "max_tokens", and OpenAI’s Responses API marks the response incomplete. Check the stop reason before parsing.

When should you use tool calling instead of structured outputs?

Tool calling suits a model that needs your code to run a function, such as a database lookup. Structured outputs suit the model’s final answer. OpenAI and Anthropic both document strict modes for tool arguments, and one request can use both features.

How should you retry an invalid LLM response?

A retry for an invalid large language model (LLM) response sends the validation error back with the previous output. It runs a fixed number of times, set in code. After the last attempt, the request falls back to a person or a safe default.

More on AI coding is in the AI category.

Cartoon of Kousigan making a heart with his hands

Kousigan A

Build with AI. Ship with care.

Software engineer building real products with AI assistants. I write down what works: prompts, rules, skills and agents, and how to ship safely.