Skip to content
datarekha

Structured outputs with JSON Schema

How to make an LLM emit valid JSON every time using Pydantic, JSON Schema, and constrained generation.

Written and reviewed by Last reviewed

7 min read Intermediate Generative AI Lesson 6 of 73

What you'll learn

  • Why parsing free-text LLM output is a losing battle
  • How a Pydantic schema turns into JSON Schema for the API
  • The shape of the API call on OpenAI and Claude, and the older tool-forcing idiom
  • What constrained decoding does and does not enforce

Before you start

You ask the model “extract the user’s name, age, and city from this text” and get back:

Sure! Here's what I found:

Name: Sara Kim
Age: 29
Located in San Francisco (sometimes works remotely from Tokyo)

Now write the regex. Then handle “twenty-nine” instead of 29. Then handle the cases where the model adds disclaimers, refuses, or uses a different key. This is the problem structured outputs solve.

TryStructured outputs

See how enforcement mode changes parse success — same prompt, three modes

Pick a mode, hit Generate, and watch the parser score each field. Free text often fails and retries; strict mode guarantees the shape on the first call.

Model replies in natural language. Parse by regex or heuristic.

target schemaInvoice
class Invoice(BaseModel):
vendor: str
total: float
due_date: date
line_items: list
model responsewaiting
Press Generate to simulate a model response.

The modern shape

Both major APIs now support constrained decoding: you give them a JSON Schema, and the model is forced (at the token level) to emit only tokens that keep the output valid. Not “asked nicely” — actually constrained. (Mechanically: during generation, any token that would make the partial output invalid according to the schema gets its probability set to zero before sampling.) The mechanics — token masking, grammar compilation, and the near-zero-overhead engines (XGrammar, llguidance) behind it — are covered in depth in constrained decoding.

  • OpenAI: a JSON Schema in the request (response_format on Chat Completions, text.format on the Responses API), or the client.responses.parse(...) helper with a Pydantic model.
  • Anthropic: a JSON Schema in output_config.format, or the client.messages.parse(...) helper with a Pydantic model.
  • Gemini: response_mime_type="application/json" plus response_schema.

Pydantic as the source of truth

In Python, you write the schema once as a Pydantic model and the SDKs convert it. Your schema, your types, and your validation are the same object.

from pydantic import BaseModel, Field
from typing import Literal

class Person(BaseModel):
    name: str = Field(description="Full name of the person")
    age: int = Field(ge=0, le=150)
    city: str
    employment_status: Literal["employed", "unemployed", "student", "retired"]

schema = Person.model_json_schema()           # Pydantic emits a JSON Schema
print("fields:", list(schema["properties"].keys()))
print("age:", schema["properties"]["age"]["minimum"], "to", schema["properties"]["age"]["maximum"])
print("status options:", schema["properties"]["employment_status"]["enum"])
fields: ['name', 'age', 'city', 'employment_status']
age: 0 to 150
status options: ['employed', 'unemployed', 'student', 'retired']

That schema — field names, the integer range on age, the four allowed employment_status values — is exactly what gets sent to the API. The model literally cannot emit {"age": "twenty-nine"}, because at the moment it is filling age the constrained decoder masks every token that is not a digit:

{“age”: 2 ✓9 ✓“twenty” ✗[ ✗digits: valid JSONmasked: prob 0

The shape of the API call

from pydantic import BaseModel, Field
from typing import Literal
from openai import OpenAI

class Person(BaseModel):
    name: str
    age: int = Field(ge=0, le=150)
    city: str
    employment_status: Literal["employed", "unemployed", "student", "retired"]

client = OpenAI()

# The SDK sends Person's JSON Schema to the model, then parses the reply back
# into a validated Person — malformed JSON or an out-of-range age is rejected for you.
response = client.responses.parse(
    model="gpt-4.1",
    input="Extract the person's details from this text: "
          "'Sara Kim is a 29-year-old software engineer living in San Francisco.'",
    text_format=Person,
)

person = response.output_parsed          # a validated Person instance
print(person.name, person.age, person.city, person.employment_status)
# -> Sara Kim 29 San Francisco employed

The flow is: Pydantic model → JSON Schema → API → JSON response → back into Pydantic. One schema, end to end.

What can go wrong

Constrained decoding handles the format. It does not handle:

  • Wrong content. The model can still hallucinate the wrong name or the wrong age. Constrained decoding enforces the JSON structure (types, required keys, enum values) — it does not enforce Pydantic validators like ge=0, le=150. Those run on your side after you call model_validate_json.
  • Missing nuance. If the text says “around 30”, the schema forces an int. The model has to pick.
  • Refusals. The model might emit {"name": "[REFUSED]", ...} if it can’t find an answer. Always handle this case.

Structured outputs with Claude

Claude’s Messages API has native structured outputs. You hand it a JSON Schema in output_config.format, and the reply is guaranteed to be JSON that matches it. The Python SDK’s messages.parse helper takes a Pydantic model directly and gives you back a validated instance, mirroring the OpenAI call above:

# Claude native structured outputs (read-only here; needs ANTHROPIC_API_KEY)
# response = client.messages.parse(
#     model="claude-opus-5-5",
#     max_tokens=1024,
#     messages=[{"role": "user", "content": prompt}],
#     output_format=Person,             # the same Pydantic model as above
# )
# person = response.parsed_output       # a validated Person instance

Without the helper, pass the schema yourself as output_config={"format": {"type": "json_schema", "schema": ...}} and read the JSON from the text block of the reply.

The older idiom: forcing a tool call

Before native structured outputs existed, the standard trick was to declare a single tool whose input_schema is your output schema and force the model to call it with tool_choice. You will still see it in older code and in libraries such as instructor and LangChain’s with_structured_output:

# The tool-forcing idiom (works on older Claude models)
tools = [{
    "name": "extract_person",
    "description": "Extract the person's details from the input text.",
    "input_schema": Person.model_json_schema(),
}]

# response = client.messages.create(
#     model="claude-opus-4-7",
#     max_tokens=1024,
#     tools=tools,
#     tool_choice={"type": "tool", "name": "extract_person"},  # force this tool
#     messages=[{"role": "user", "content": prompt}],
# )
# person = Person.model_validate(response.content[0].input)

When NOT to use structured outputs

  • Long-form generation (essays, summaries, code). Forcing a schema on prose makes the prose worse.
  • When the model genuinely needs to refuse or escalate. A schema that requires every field be filled removes the model’s option to say “I don’t know”.
  • For one-off prototypes. Setting up the schema can be more work than parsing the output for a 5-line script.

For everything else — extraction, classification, routing, function arguments — structured outputs should be your default.

In one breath

  • Parsing free-text LLM output is a losing battle — constrain the format instead.
  • One Pydantic model becomes the JSON Schema you send and the validator you parse back into — no drift.
  • Constrained decoding masks any token that would break the schema, so the JSON is valid by construction.
  • OpenAI uses responses.parse (or a schema in the request); Claude uses output_config.format or messages.parse. Forcing a single tool is the older idiom, and the newest Claude models reject it. Always set strict: true.
  • It enforces structure, not truth or Pydantic validators (ge/le) — those run on your side. Don’t use it for prose.

Quick check

Quick check

0/3
Q1What does 'constrained decoding' mean for structured outputs?
Q2Why use Pydantic instead of writing a JSON Schema by hand?
Q3When should you NOT use structured outputs?

Next

The next lesson covers prompt patterns — the techniques that actually move the needle on quality, and the cargo-cult ones that don’t.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How do temperature, top-k, and top-p sampling control LLM generation?

Temperature reshapes the token probability distribution, while top-k keeps a fixed number of likely tokens and top-p keeps the smallest probability-mass nucleus; temperature changes softness, and k or p removes candidates before sampling.

How do you reliably get structured outputs (JSON, typed objects) from an LLM?

Modern APIs offer constrained decoding — the model's token sampling is restricted to only produce tokens that are valid continuations of a JSON schema. Combined with Pydantic validation in application code, this eliminates the JSON-parsing errors that plagued earlier prompt-only approaches. When constrained decoding is unavailable, few-shot examples plus output parsing with retry is the fallback.

What is constrained decoding and how does it guarantee structured outputs like valid JSON?

Constrained decoding restricts each next-token choice to continuations allowed by a grammar or supported JSON Schema, so a completed response can be guaranteed to be syntactically valid JSON and schema-conformant without changing model weights. It does not guarantee that values are true, sensible, or semantically consistent, and incomplete or refused generations still need explicit handling.

How do you evolve a data schema without breaking downstream ML consumers?

Use a schema registry with backward-compatible evolution rules so changes are managed rather than ad hoc: producers can add optional or nullable fields and consumers ignore unknown fields, which keeps existing pipelines working. Breaking changes such as renaming, removing, or retyping a field require versioning, often a new topic or table, with a migration window and deprecation before the old schema is retired. This lets data evolve continuously while ML features and models stay stable.

Related lessons

Explore further