lumo

Module 03 · Lesson 5 of 12Free

Structured outputs: getting reliable JSON

Level
Intermediate
Topic
Prompting and structured outputs
Reading time
16 min

When you finish this page you can

  1. Explain why asking a model to reply in JSON fails now and then, and what each failure does to the code that reads it.
  2. Request structured outputs on the Claude API with output_config.format and a JSON schema, and read the result from the text block.
  3. Design a small schema with required fields, enums and nullable fields, so the model never has to invent a value.
  4. Say what structured outputs guarantee (the shape of the reply) and what your code must still check (the values).
  1. Calling a model API: request, response and cost15 min

Video lesson · coming soon

Amara’s ticket waits five hours

Deskora is a help desk app. A shop’s support team uses it to answer customer emails from one shared inbox. Each customer problem becomes a ticket: one customer’s whole conversation with the support team, message by message.

Deskora receives 120,000 new tickets a day. Tomás, an engineer at Deskora, is building a ticket router. When a ticket arrives, a model reads the first message and returns three fields: a category, an urgency and the order number. Deskora’s code then puts the ticket in the right queue, with the order details attached.

Tomás’s system prompt ends with “Reply in JSON with the keys category, urgency and order_number.” On Monday morning, Amara emails a kitchenware shop: her order KS-20417 hasn’t arrived, and it’s a gift for Saturday. One run returned this:

text
Here is the ticket data:
{"category": "delivery", "urgency": "high", "order_number": "KS-20417"}

The fields are right, but the first line isn’t JSON. Python’s json.loads raises an error, and the ticket falls into the Unsorted queue. Nobody looks there until the afternoon. Priya, a support agent at the shop, finally sees it five hours later.

This lesson answers one question: how does Tomás get JSON his code can read on every ticket, and what can that JSON still get wrong?

The 120,000 tickets a day is a fact used across this course. The other numbers below, such as failure rates, token counts and the share of long tickets, are estimates for this lesson’s scenario.

Diagram not drawn yet: deskora-ticket-router.svg

Left to right. 1: a new customer email arrives at the Deskora app server. 2: the app server sends the first message to the Claude Messages API. 3: a dashed reply returns a text block. 4: the router code runs json.loads on it. On success, the ticket goes to one of the shop’s queues: Delivery, Returns, Billing, Product or Account, with its urgency and order number. On failure, a red arrow sends it to the Unsorted queue, labelled “waits for a person”.

The router is code, not a person. Any reply it can’t parse sends the ticket to the Unsorted queue, where it waits.
Check yourselfAnswer in your head first, then reveal
About 1 reply in 200 breaks the router. Tomás changes the prompt to “Reply ONLY with JSON. No other text.” Will that bring it to 200 out of 200?Show answerHide answer
Fewer replies will break, but not none. The prompt makes extra text less likely; it never makes it impossible, because the model still picks every token itself. Not “a firm enough instruction always works”: at 120,000 tickets a day, even 1 in 10,000 is 12 lost tickets.

Asking politely still breaks the router

A model writes its reply one token at a time. At each step it scores every possible next token and picks one; Next-token prediction shows how. Your prompt changes those scores. It can make “Here is the ticket data:” very unlikely, but nothing in the process forbids it.

So a prompt-only router fails in three ways:

What the reply does Example What the router’s code does
Adds text around the JSON Here is the ticket data: before it, or a Markdown code fence around it json.loads raises JSONDecodeError
Leaves out a field No order_number key when the customer gave none fields["order_number"] raises KeyError
Uses a value or type the code doesn’t expect "category": "Shipping delay", or "urgency": 3 No queue matches, so the ticket goes to Unsorted

Suppose, as an illustration, that 1 reply in 200 fails like this. That is 600 tickets a day in the Unsorted queue. Each one is a customer like Amara waiting hours for a first reply.

Retrying a failed reply is cheap: 600 extra calls add about $0.10 a day at the cost worked out below. But a retry can fail too, and it adds a second or more of waiting. Older tricks don’t help on current models either. Prefilling the reply with an opening { returns a 400 error on all current Claude models.

The prompt can only make bad tokens unlikely. To fix the router, Tomás needs something that makes them impossible.

Constrained decoding rules out broken JSON

Structured outputs is an API feature that makes the reply match a JSON schema you send with the request. A JSON schema is a JSON document that describes another one: which keys it must have, and what type each value is 4.

The guarantee comes from how the reply is generated. The API compiles your schema into a grammar: rules for which characters may come next at each point in the reply. It then uses constrained decoding: it only lets the model produce tokens the grammar allows 1.

In general, constrained decoding works like this 5:

  1. The model scores every possible next token, as it always does.
  2. Before one is picked, every token that would break the grammar is removed.
  3. The model picks from the tokens that are left.
  4. The grammar moves forward, and the next step starts.

Diagram not drawn yet: constrained-decoding-token-mask.svg

One decoding step. The reply so far has the category set to delivery and has just opened the urgency value. On the left, five candidate next tokens with illustrative scores as bars: high 55%, urgent 25%, ASAP 10%, normal 7%, low 3%. In the middle, the grammar allows only low, normal and high, so urgent and ASAP are greyed out and labelled “not allowed by the schema”. On the right, the model picks from what is left: high.

At each step, tokens that would break the schema are removed before the model picks. Scores are illustrative.

The model can no longer write “Here is”, because the grammar says a reply must start with {. It can’t skip a required key, because the grammar won’t let the object close without it. It can’t write "urgent", because the schema allows only three urgency values. How decoding picks a token from the scores is covered in Under the hood: how decoding picks each token.

This changes where the work happens. Before, the model had to choose to follow the format, and code had to cope when it didn’t. Now the format is enforced while the reply is generated. The model only chooses the values.

Tomás’s router call in Python

On the Claude API, structured outputs is one request field: output_config.format, holding the type json_schema and your schema 1. Two rules apply to every object in the schema. List its keys in required, and set "additionalProperties": false so the model can’t add keys of its own.

Here is the router call. Install the SDK with pip install anthropic and put your key in ANTHROPIC_API_KEY.

python
import json

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

TICKET_SCHEMA = {
    "type": "object",
    "properties": {
        "category": {
            "type": "string",
            "enum": ["delivery", "returns", "billing", "product", "account", "other"],
        },
        "urgency": {"type": "string", "enum": ["low", "normal", "high"]},
        "order_number": {"type": ["string", "null"]},  # null when none is given
    },
    "required": ["category", "urgency", "order_number"],
    "additionalProperties": False,
}

SYSTEM = (
    "You sort new support tickets for an online shop. Choose the category and urgency. "
    "Copy the order number exactly as the customer wrote it, or use null if there is none."
)


def route_fields(first_message: str) -> dict[str, str | None]:
    response = client.messages.create(
        model="claude-haiku-5-5",
        max_tokens=2000,  # thinking counts toward this too
        system=SYSTEM,
        messages=[{"role": "user", "content": first_message}],
        output_config={"format": {"type": "json_schema", "schema": TICKET_SCHEMA}},
    )
    # A refusal or a cut-off reply may not match the schema.
    if response.stop_reason != "end_turn":
        raise RuntimeError(f"No usable fields: stop_reason={response.stop_reason}")
    text = next(block.text for block in response.content if block.type == "text")
    return json.loads(text)


if __name__ == "__main__":
    print(route_fields("Hi, my order KS-20417 still hasn't arrived. It's a gift for Saturday!"))

The JSON arrives as a string inside the text block, so json.loads is still needed. A thinking block may come before it, which is why the code picks the block by type; Calling a model API explains content blocks and stop_reason. Here is one example response, shortened; the values can differ between runs:

json
{
  "content": [
    {"type": "thinking", "thinking": "", "signature": "Eq8B..."},
    {"type": "text", "text": "{\"category\": \"delivery\", \"urgency\": \"high\", \"order_number\": \"KS-20417\"}"}
  ],
  "stop_reason": "end_turn"
}

The API also adds its own instructions describing the format, which count as input tokens 1. The first request with a new schema is slower while the grammar compiles. The compiled grammar is then cached for 24 hours after its last use 1.

What one routing call costs

The task is one routed ticket, which takes one call. This estimate leaves out retries and any second call. As of October 2026, Claude Haiku 5.5 costs $0.10 per million input tokens and $0.50 per million output tokens, for prompts up to 100,000 tokens 3. Thinking tokens are billed as output.

The formula

cost = input tokens × input price + (thinking + JSON tokens) × output price

Prices are per million tokens, so divide by 1,000,000.

Typical ticket

550 × $0.10 ÷ 1,000,000 + (200 + 30) × $0.50 ÷ 1,000,000 = $0.000055 + $0.000115 = $0.00017

A 300-token first message, a 150-token system prompt and an assumed 100 tokens of format instructions. Assumed: 200 thinking tokens and 30 tokens of JSON.

Long ticket

2,250 × $0.10 ÷ 1,000,000 + (600 + 30) × $0.50 ÷ 1,000,000 = $0.000225 + $0.000315 = $0.00054

A 2,000-token first message. Assumed: 600 thinking tokens.

One day

108,000 × $0.00017 + 12,000 × $0.00054 = $18.36 + $6.48 = about $25

Every new ticket is routed. Assumed: one in ten is long.

usage.output_tokens_details.thinking_tokens reports the real thinking on each call. Log it, because thinking is most of this bill.

Try it yourself

This curl call routes a ticket that has no order number. It costs at most about $0.0005, even if the model uses all 1,000 output tokens. If you don’t have an API key, skip it.

bash
curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -d '{"model": "claude-haiku-5-5", "max_tokens": 1000,
  "system": "Sort this support ticket. Use null for an order number the customer did not give.",
  "messages": [{"role": "user", "content": "Hi, the lid of my new pan cracked the first time I used it. Can I get a replacement?"}],
  "output_config": {"format": {"type": "json_schema", "schema": {"type": "object",
    "properties": {"category": {"type": "string", "enum": ["delivery", "returns", "billing", "product", "account", "other"]},
      "urgency": {"type": "string", "enum": ["low", "normal", "high"]},
      "order_number": {"type": ["string", "null"]}},
    "required": ["category", "urgency", "order_number"], "additionalProperties": false}}}}'

One run returned this text block, shortened; yours may differ:

json
{"type": "text", "text": "{\"category\": \"product\", \"urgency\": \"normal\", \"order_number\": null}"}

What to look for:

  • The text field is a string that holds JSON, not a JSON object.
  • order_number is null, not a guess.
  • Change ["string", "null"] to "string" and run it again. Now the model must write some string, and you’ll see what it chooses.

Designing the schema the router needs

The schema is the contract between the model and the router’s code. Each field should match something the code does with it.

Field Schema What the router does with it
category enum of five queues plus other Picks the queue
urgency enum: low, normal, high Sorts the queue, high first
order_number string or null Attaches the order details, if there is a number

An enum is a list of the only values a field may take 4. Without one, “the category” is whatever words the model picks.

How should the schema hold the ticket’s category?

A free string, with the categories listed in the prompt.

The model writes “delivery” on one ticket and “Shipping delay” on the next. The router needs a lookup table, and every word it misses sends a ticket to Unsorted.

An enum of the five real queues only.

Every reply maps to a queue. But a job application or a supplier’s invoice must still land in one of them, so the model is forced to pick a wrong queue, and nothing shows it was unsure.

Why this choice

The enum turns “match the model’s words to a queue” into a lookup that can’t miss, because the grammar allows only these strings. Adding other gives the model an honest answer for tickets that don’t fit. A rising count of other tells Tomás a queue is missing.

When the choice changes. If each shop has its own queues, the schema is built per shop. Each new schema compiles on its first request, so a shop that changes its queues waits a little longer once. With dozens of categories, a two-step design can work better: a broad category first, then a finer one.

The order number raises a harder question. Many customers don’t give one.

How should the schema say that the customer gave no order number?

A required string.

The field is always there, so the model must write some string even when there is none: an empty string, “N/A”, or a made-up KS-10000. The router may attach the wrong order.

An optional string, left out of required.

A missing key means “none”. But code that reads fields[“order_number”] crashes, and every reader must remember to check. The API also moves optional keys after the required ones and limits how many a request may have.

Why this choice

Allowing null makes “not given” a value the schema accepts, so the model never has to invent one 4. A required field with no null option is the most dangerous design, because the grammar forces the model to write something. The router can’t tell a real number from a filler.

When the choice changes. Optional fields make sense for large objects where most fields are usually missing, such as a long form. Even then, the code must handle the missing keys.

Keep the schema small. Tomás was tempted to add the customer’s mood, their language and a one-line summary. Each extra field costs output tokens and some thinking, and gives the model another value to get wrong. It also makes the grammar larger; a schema that is too complex returns a 400 error 1. A field earns its place only if code acts on it.

Schema features, limits and quirks

As of October 2026, the Structured outputs page lists these rules 1:

  • Supported: the basic types, enum, const, anyOf, $ref to local definitions, and string formats such as date and email.
  • Not supported, and rejected with a 400 error: recursive schemas, minimum and maximum, minLength and maxLength. Check those rules in code.
  • Limits per request, across all schemas: 24 optional parameters, and 16 parameters that use anyOf or type arrays such as ["string", "null"].
  • Key order: required keys come first, then optional ones, each in schema order. Mark every key required to keep your order.
  • Enum casing: the capitals in an enum value can occasionally come back different. Compare in lower case, and never use two values that differ only in case.
  • Changing output_config.format breaks the prompt cache for that conversation.
  • The older output_format request parameter is deprecated; use output_config.format.

Valid JSON can still be wrong

Structured outputs guarantee the shape: valid JSON, every required key, the right types and only allowed enum values 1. They don’t guarantee that the values are true.

Two cases break even the shape 1. If the model refuses, stop_reason is refusal, and the reply may not match the schema. If the reply hits max_tokens, it stops in the middle of the object. That is why the router checks stop_reason before json.loads.

Diagram not drawn yet: schema-guarantees-shape-not-values.svg

Two panels. Left, titled “The API guarantees”: valid JSON; every required key; the right types; only allowed enum values. Underneath, a small note: “not when stop_reason is refusal or max_tokens”. Right, titled “Your code still checks”: the category fits the problem; the order number belongs to this customer; the urgency makes sense. An example reply under the right panel: order_number KS-18842 with a red label “an old, returned order”.

Structured outputs fix the shape of the reply. Whether each value is right is still the model’s judgment, and your code’s job to check.

Here is how a valid reply goes wrong. Femi writes: “I returned order KS-18842 last month. Now KS-20417 is late.” One run returned "order_number": "KS-18842". The JSON is valid and the type is right. The router attaches the old order. Priya reads that it was delivered and returned, and tells Femi his refund is on its way. Femi was asking about a different parcel.

Check yourselfAnswer in your head first, then reveal
A customer writes that her parcel never arrived. The router gets a valid reply with category “billing”. Did structured outputs fail?Show answerHide answer
No. The reply has the right shape, and billing is an allowed value. Choosing the category is the model’s judgment, which no schema can enforce. Not “the schema needs to be stricter”: better category descriptions in the prompt, and an eval set, are what fix wrong values.

So the router’s code still checks the values it can check:

  • The order number exists for this shop and this customer’s email. If it doesn’t, treat it as null.
  • other is counted. A rising share means a missing queue or a prompt problem.
  • Unsorted stays as a . Any call that fails, or any value that fails a check, still reaches a person.

The SDK can also build the schema from a Pydantic model and check the reply for you; the next lesson covers it. To measure how often values are wrong across many tickets, see Code-based checks.

Strict tool use: the other route

Tools are functions your code offers the model, such as lookup_order; Function calling teaches them. Each tool definition has a name and an input_schema. Set "strict": true on it, and the API guarantees two things: the tool call’s input matches the schema, and its name is one of your tools 2. The input arrives already parsed, in a tool_use block. Choose strict tool use when the model is choosing an action to take. Choose output_config.format, as the router does, when the reply itself is the data. One request can use both 1.

Where this shows up

The router now always gets JSON it can read, but it trusts every value inside. Validating model output with Pydantic checks those values in code.

Common mistakes

  • Reading content[0].text. The first block may be thinking. Pick the block whose type is text.
  • Parsing before checking stop_reason. A refusal or a cut-off reply may not match the schema. Parse only when stop_reason is end_turn.
  • A required field with no way to say “none”. The model must then invent a value. Allow null.
  • Treating valid JSON as correct JSON. The schema fixes the shape. Check the values your code can check.
  • Adding fields nobody uses. Each one costs tokens and is one more value to trust.

Check your understanding

Check yourselfAnswer in your head first, then reveal
A routing call returns stop_reason max_tokens, and json.loads raises an error. Did structured outputs break its guarantee?Show answerHide answer
No. The guarantee doesn’t cover a reply cut off at max_tokens: the reply stopped in the middle of the object. Thinking counts toward max_tokens, so raise the ceiling, and keep checking stop_reason before parsing.
Check yourselfAnswer in your head first, then reveal
Tomás adds a required field refund_amount of type number. Most customers never mention an amount. What will the router see on those tickets?Show answerHide answer
Some number, because the grammar forces one: often 0, sometimes a guess. Make the type number or null, or drop the field if no code acts on it. Not “the model will leave it empty”: a required number can’t be empty.
Check yourselfAnswer in your head first, then reveal
A shop adds a wholesale queue, and Tomás adds wholesale to the category enum. What changes on the next request?Show answerHide answer
The schema changed, so the API compiles a new grammar, and that first request is slower. After that, the model may choose wholesale, and replies can never contain a category outside the new list.
Check yourselfAnswer in your head first, then reveal
Tomás wants the model to decide whether to look up an order or escalate a ticket, and to call the right function. Should he use output_config.format or strict tool use?Show answerHide answer
Strict tool use. The model is choosing an action, and strict mode guarantees a valid tool name and inputs that match the schema. output_config.format fits when the reply itself is the data, as in the router.

Summary

  1. output_config.format with a JSON schema makes every reply valid JSON with the required keys, right types and allowed enum values; a prompt alone only makes broken replies rarer.
  2. It works by constrained decoding: tokens that would break the schema are removed before the model picks each one.
  3. Read the JSON string from the text block, check that stop_reason is end_turn, then json.loads it. A refusal or a cut-off reply may not match the schema.
  4. Design the schema for the code that reads it: every key required, enums for fixed choices with an other option, null for values that may be missing, and no unused fields.
  5. Structured outputs guarantee the shape, not the truth. A valid reply can still pick the wrong category or the wrong order number, so code checks what it can and keeps a fallback.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.