# Structured output

> Get schema-valid JSON from a model every time with JSON Schema, structured outputs, Pydantic or Zod, plus validation and retries.

AI Engineer roadmap · LLM foundations · 18 min read · https://levelcode.org/roadmaps/ai-engineer/structured-output

Most AI features don't show the model's words to a human. They feed them into code: a ticket router needs a category, an invoice pipeline needs numbers, a UI needs fields to render. Code needs **structured output**, data in an exact shape, every time. This lesson shows why asking politely for JSON isn't enough, how schema-constrained output works, how to get typed objects with Pydantic (Python) and Zod (TypeScript), how to design schemas a model fills well, and how to handle the cases where valid JSON is still wrong.

## Why "reply in JSON" breaks

The obvious first attempt is to ask for JSON in the prompt:

```text
Extract the customer's name, email and plan from this message. Reply in JSON.
```

It works most of the time. In production, "most of the time" means a steady trickle of failures like these:

```text
Sure! Here's the extracted information:

{"name": "Jane Doe", "email": "jane@co.com", "plan": "Enterprise",}

Let me know if you need anything else!
```

That one reply has two separate problems: chatty text around the data, and a trailing comma that makes it invalid JSON. Other common failures are JSON wrapped in a markdown code fence, a missing field, a renamed key (`customer_name`), the wrong type (`"plan": ["Enterprise"]`), an invented extra field, and output truncated at the token limit. Each needs its own defensive parsing code, and you'll never catch them all.

The fix is to stop *asking* for a format and start *enforcing* one.

## Structured outputs: schema-constrained generation

With **structured outputs**, you send a JSON Schema alongside the request, and the API constrains generation so the model can only produce tokens that keep the output valid against that schema. The result is guaranteed to parse, with the required fields present and the right types. Many providers offer this; on Claude it is the `output_config.format` parameter:

```python
import json

import anthropic

client = anthropic.Anthropic()

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "email": {"type": "string"},
        "plan": {"type": "string", "enum": ["Free", "Pro", "Enterprise"]},
        "demo_requested": {"type": "boolean"},
    },
    "required": ["name", "email", "plan", "demo_requested"],
    "additionalProperties": False,
}

response = client.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": "Extract the contact details: Jane Doe (jane@co.com) wants Enterprise and asked for a demo.",
    }],
    output_config={"format": {"type": "json_schema", "schema": schema}},
)

if response.stop_reason != "end_turn":
    raise RuntimeError(f"No usable output: stop_reason={response.stop_reason}")

text = next(block.text for block in response.content if block.type == "text")
data = json.loads(text)
print(data)
```

```text
{'name': 'Jane Doe', 'email': 'jane@co.com', 'plan': 'Enterprise', 'demo_requested': True}
```

The text block contains only the JSON, with no preamble and no fences. A quick JSON Schema primer covers almost everything you'll need:

| Keyword | Meaning |
| --- | --- |
| `"type"` | `object`, `array`, `string`, `integer`, `number`, `boolean` or `null` |
| `"properties"` | The fields of an object and their schemas |
| `"required"` | Fields that must be present |
| `"additionalProperties": false` | No other fields allowed. Structured outputs require this on every object |
| `"enum"` | The value must be one of a fixed list |
| `"items"` | The schema for each element of an array |
| `"anyOf"` | One of several schemas, e.g. a string *or* `null` for an optional value |
| `"format"` | String formats such as `date`, `date-time`, `email`, `uri`, `uuid` |

The supported subset has limits. Recursive schemas aren't allowed, and numeric and length constraints (`minimum`, `maximum`, `minLength` and similar) aren't enforced during generation. The SDK helpers below deal with that for you.

> [!IMPORTANT]
> Check `stop_reason` before you parse. The schema guarantee only covers a response that finished normally. If the model refused (`"refusal"`) or ran out of tokens (`"max_tokens"`), the output may be incomplete or not match the schema at all.

## Typed results with Pydantic

Hand-writing JSON Schema is tedious and drifts from your code. In Python, define the shape once as a **Pydantic** model and let the SDK convert it. `client.messages.parse()` sends the schema and returns a validated instance:

```python
from typing import Literal

import anthropic
from pydantic import BaseModel, Field

client = anthropic.Anthropic()

class Ticket(BaseModel):
    category: Literal["bug", "billing", "feature_request", "other"]
    severity: int = Field(ge=1, le=4, description="1 = cosmetic, 4 = outage for many users")
    summary: str = Field(description="One sentence, in plain English")
    order_id: str | None = Field(description="Order ID if the message mentions one, otherwise null")

response = client.messages.parse(
    model="claude-sonnet-5-5",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": "Order #A-1042 was charged twice and now the checkout page shows a 500 error.",
    }],
    output_format=Ticket,
)

ticket = response.parsed_output  # a Ticket instance
print(ticket)
```

```text
category='billing' severity=3 summary='The customer was double-charged for an order and checkout now returns a server error.' order_id='A-1042'
```

`Literal[...]` becomes an `enum`, `str | None` becomes `anyOf` string-or-null, and `Field(description=...)` puts the description into the schema, where the model reads it. Constraints that structured outputs can't enforce, like `ge=1, le=4`, are moved into the field description as a hint for the model, then **checked client-side** when the result is validated. If validation fails (the model said `severity: 7`, or a refusal produced text that isn't JSON), `parse()` raises `pydantic.ValidationError`.

For production code, you want to see the `stop_reason` *before* validation runs. Build the schema with `anthropic.transform_schema` (the same conversion `parse()` uses), call `create()`, check the stop reason, then validate:

```python
import pydantic

def extract_ticket(message: str) -> Ticket | None:
    response = client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": message}],
        output_config={"format": {"type": "json_schema", "schema": anthropic.transform_schema(Ticket)}},
    )
    if response.stop_reason == "refusal":
        return None  # route to a human instead
    if response.stop_reason == "max_tokens":
        raise RuntimeError("Output truncated; raise max_tokens")
    text = next(block.text for block in response.content if block.type == "text")
    try:
        return Ticket.model_validate_json(text)
    except pydantic.ValidationError as e:
        raise RuntimeError(f"Schema-valid JSON broke a constraint: {e}") from e
```

### The same in TypeScript

In TypeScript, define the shape with **Zod** and convert it with the SDK's `zodOutputFormat` helper. `client.messages.parse()` returns `parsed_output` directly. For the same check-then-validate control, pass the format to `create()` and validate with `safeParse`:

```ts
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";

const client = new Anthropic();

const Ticket = z.object({
  category: z.enum(["bug", "billing", "feature_request", "other"]),
  severity: z.number().int().min(1).max(4).describe("1 = cosmetic, 4 = outage for many users"),
  summary: z.string().describe("One sentence, in plain English"),
  order_id: z.string().nullable().describe("Order ID if the message mentions one, otherwise null"),
});
type Ticket = z.infer<typeof Ticket>;

async function extractTicket(message: string): Promise<Ticket | null> {
  const response = await client.messages.create({
    model: "claude-sonnet-5-5",
    max_tokens: 1024,
    messages: [{ role: "user", content: message }],
    output_config: { format: zodOutputFormat(Ticket) },
  });
  if (response.stop_reason === "refusal") return null;
  if (response.stop_reason === "max_tokens") throw new Error("Output truncated; raise max_tokens");

  const text = response.content.find((b): b is Anthropic.TextBlock => b.type === "text")?.text ?? "";
  const result = Ticket.safeParse(JSON.parse(text));
  if (!result.success) throw new Error(`Constraint violated: ${result.error.message}`);
  return result.data;
}
```

## Designing schemas the model fills well

The schema is part of your prompt. The model reads the field names, descriptions and enums, so design them for comprehension as well as for your code.

**Use enums for anything categorical.** `"category": {"enum": [...]}` makes a typo or an invented category impossible, and it doubles as documentation of the allowed values.

**Make "unknown" representable.** If `due_date` is a required string, the model *must* produce a date, so when the text has none it will invent a plausible one. A nullable field (`str | None`) with the description "null if no due date is stated" gives the model an honest answer. This is the single most important schema-design rule for extraction.

**Describe fields precisely.** `"amount"` is ambiguous. `"amount_cents": integer, "Total in the smallest currency unit, e.g. 1999 for $19.99"` is not. Put units, formats and edge-case rules in descriptions.

**Keep names meaningful and structures shallow.** `customer_email` beats `field3`. Deeply nested objects are harder to fill correctly than a flat record with a list or two.

**Ask for evidence when you need to trust a value.** Adding a field like `"evidence": "the exact sentence from the input that supports the category"` makes the output auditable, and lets your code check that the quote really appears in the input.

> [!NOTE]
> Older guides force JSON by pre-filling the start of the assistant's reply with `{`. Current Claude models reject a prefilled assistant turn with a 400 error. Structured outputs replace the trick and give a real guarantee.

**Still write a prompt.** The schema says *what shape*; the prompt says *what task*. "Extract the ticket details. Severity reflects business impact, not how angry the customer sounds" carries judgement that no schema can express.

## Valid is not the same as correct

Structured outputs guarantee **syntax**: the JSON parses and matches the schema. They don't guarantee **semantics**. A perfectly valid invoice can have line items that don't add up to the total, a date in the wrong year, or a name the model misread. Layer your checks:

1. **Schema validation**, handled by the API plus Pydantic or Zod: types, enums, required fields, numeric ranges.
2. **Business rules** in your own code: totals add up, dates fall in a sensible range, IDs exist in your database.
3. **Grounding checks:** quoted evidence appears in the source text, and extracted emails occur in the input.

When a check fails, you have three options. You can **retry with feedback**: send a follow-up turn that includes the model's previous output and the specific error ("line items sum to 4,200 but total_cents is 4,500; re-check the total"). Models are good at fixing a concrete, named mistake. You can **fall back**, for example to a more capable model. Or you can **escalate** the case to a human queue. Cap retries at one or two. A model that fails the same check three times needs a better prompt, not a fourth attempt.

Two practical notes. The first request with a new schema has extra latency while the API compiles it; it is cached for subsequent requests. And structured outputs are about the model's *final answer*. When the JSON is really the arguments to an action your code will take, **strict tool use** (`strict: true` on a tool definition) gives the same schema guarantee for tool inputs. The next lesson covers it.

## Practice

Build an invoice extractor. Given the raw text of an invoice, return:

- `vendor` (string), `invoice_number` (string), `issue_date` (ISO date string) and `due_date` (ISO date or null);
- `currency` (one of `USD`, `EUR`, `GBP`);
- `line_items`: a list of `{description, quantity, unit_price_cents}`;
- `total_cents` (integer).

Then add a business-rule check that the line items sum to the total, and retry **once** with feedback if they don't. Test it on this text:

```text
INVOICE #2026-118 from Brightline Studio. Issued 3 Oct 2026.
2 x Logo concepts @ $450.00
1 x Brand guidelines PDF @ $1,200.00
Total due: $2,100.00 (USD). Payment terms: on receipt.
```

**Solution**

```python
from datetime import date
from typing import Literal

import anthropic
import pydantic
from anthropic.types import MessageParam
from pydantic import BaseModel, Field

client = anthropic.Anthropic()

class LineItem(BaseModel):
    description: str
    quantity: int
    unit_price_cents: int = Field(description="Price per unit in cents, e.g. 45000 for $450.00")

class Invoice(BaseModel):
    vendor: str
    invoice_number: str = Field(description="As printed, without a leading #")
    issue_date: date
    due_date: date | None = Field(description="Null unless a specific due date is printed")
    currency: Literal["USD", "EUR", "GBP"]
    line_items: list[LineItem]
    total_cents: int = Field(description="The invoice total in cents, as printed")

SYSTEM = "You extract invoice data exactly as printed. Never compute values that aren't printed."
SCHEMA = anthropic.transform_schema(Invoice)

def business_errors(inv: Invoice) -> list[str]:
    line_sum = sum(item.quantity * item.unit_price_cents for item in inv.line_items)
    if line_sum != inv.total_cents:
        return [f"line items sum to {line_sum} cents but total_cents is {inv.total_cents}"]
    return []

def extract_invoice(raw: str) -> Invoice:
    messages: list[MessageParam] = [{"role": "user", "content": f"<invoice>\n{raw}\n</invoice>"}]
    errors: list[str] = []
    for _attempt in range(2):
        response = client.messages.create(
            model="claude-sonnet-5-5",
            max_tokens=2048,
            system=SYSTEM,
            messages=messages,
            output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
        )
        if response.stop_reason != "end_turn":
            raise RuntimeError(f"stop_reason={response.stop_reason}")
        text = next(b.text for b in response.content if b.type == "text")
        try:
            invoice = Invoice.model_validate_json(text)
        except pydantic.ValidationError as e:
            errors = [str(e)]
        else:
            errors = business_errors(invoice)
            if not errors:
                return invoice
        messages += [
            {"role": "assistant", "content": response.content},
            {"role": "user", "content": "Your extraction failed these checks: " + "; ".join(errors)
             + ". Re-read the invoice and return corrected data."},
        ]
    raise RuntimeError(f"Extraction failed after retry: {errors}")

raw = """INVOICE #2026-118 from Brightline Studio. Issued 3 Oct 2026.
2 x Logo concepts @ $450.00
1 x Brand guidelines PDF @ $1,200.00
Total due: $2,100.00 (USD). Payment terms: on receipt."""

print(extract_invoice(raw).model_dump_json(indent=2))
```

```text
{
  "vendor": "Brightline Studio",
  "invoice_number": "2026-118",
  "issue_date": "2026-10-03",
  "due_date": null,
  "currency": "USD",
  "line_items": [
    {
      "description": "Logo concepts",
      "quantity": 2,
      "unit_price_cents": 45000
    },
    {
      "description": "Brand guidelines PDF",
      "quantity": 1,
      "unit_price_cents": 120000
    }
  ],
  "total_cents": 210000
}
```

Key decisions: `due_date` is nullable because "on receipt" is not a date, so the model has no reason to invent one. Money is integer cents with a description showing the conversion. Pydantic's `date` type maps to the schema's `date` format and parses into a real `date` object. The retry appends the model's own previous answer plus the specific failed check, which gives it something concrete to fix.

## Key takeaways

- Prompt-only JSON fails in many small ways. Structured outputs constrain generation to your JSON Schema, so the result always parses and fits the schema.
- On Claude, pass `output_config={"format": {"type": "json_schema", "schema": ...}}`, or use `messages.parse()` with a Pydantic model (Python) or `zodOutputFormat` (TypeScript).
- Always check `stop_reason` first. A refusal or truncation voids the schema guarantee.
- Design schemas for the model: enums for categories, nullable fields for "not stated", precise descriptions with units, and shallow structures.
- Schema-valid isn't correct. Add business-rule and grounding checks, retry once with specific feedback, then escalate.

## Quiz

1. You extract due_date as a required string. For invoices that say "payment on receipt", the model returns plausible but made-up dates. What is the best fix?

   A. Add 'do not hallucinate' to the prompt
   B. Make due_date nullable and describe it as null unless a specific date is printed
   C. Use a larger model
   D. Remove due_date from the schema

   **Answer: B.** A required string forces the model to produce some date, so it invents one. Making the field nullable, with a clear description, lets the model represent the truthful answer: there is no due date.

2. With structured outputs enabled, which of these can still happen?

   A. The JSON contains a trailing comma
   B. A required field is missing from a normal end_turn response
   C. The output has a markdown code fence around it
   D. The JSON is valid, but the line items don't add up to the total

   **Answer: D.** Constrained generation guarantees syntax and schema conformance for a normally completed response, but not semantic correctness. Business rules like totals must be checked in your code.

3. Why should you check stop_reason before parsing a structured response?

   A. If the model refused or hit max_tokens, the output may be incomplete or not match the schema
   B. stop_reason contains the parsed JSON
   C. Parsing is only allowed after the stream closes
   D. The schema is only applied when stop_reason is tool_use

   **Answer: A.** The schema guarantee applies to responses that finish normally. A refusal or a truncated response can contain text that isn't valid against your schema, so handle those cases explicitly before validating.

4. Your Pydantic model has severity: int = Field(ge=1, le=4). Structured outputs can't enforce minimum and maximum during generation. How does the Python SDK handle this?

   A. It rejects the request with a 400 error
   B. It silently ignores the constraint forever
   C. It moves the constraint into the field description for the model, then validates it client-side, raising a ValidationError if it's broken
   D. It converts the field to an enum of 1 to 4

   **Answer: C.** The SDK strips unsupported constraints from the schema it sends, adds them to the description as a hint, and enforces them when validating the result. So you still get a guarantee, but it is checked on your side.
