Getting Structured Output

Constrain a reply to a JSON schema, parse it defensively, and recover when what arrives is only almost JSON.

Syntaxclient.messages.create(..., output_config={"format": {"type": "json_schema", "schema": {...}}})

Asking for JSON in the prompt gets you JSON most of the time, and that is the problem: the failures are rare enough to reach production and frequent enough to page someone. Two mechanisms fix it, and you should use both.

Constrain the format

Pass a JSON Schema in output_config={"format": {"type": "json_schema", "schema": ...}} and the reply's first text block is valid JSON matching that schema. The schema needs "required" and "additionalProperties": False to be worth anything, otherwise the model may answer with an empty object. The SDK also offers a typed shorthand, client.messages.parse(..., output_format=YourModel) with a Pydantic model, which validates the reply and hands back an instance on response.parsed_output; that is the same request with the schema generated from your class, and it needs the third-party pydantic package.

Parse as if it were untrusted

Older models, fallbacks and providers without schema support all produce near-misses: a fenced code block, a sentence of preamble, or the right keys with a string where you wanted a number. So the parsing step does four things in order: strip fences, slice from the first { to the last }, json.loads inside a try, then check every field's type before returning.

What trips people up

Validation after parsing is where bugs hide. isinstance(True, int) is True in Python, so a boolean passes an integer check. Enumerated fields deserve a membership test against a set of allowed values, because a model will cheerfully invent a fifth priority level for a four-level enum.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Turning free-text support email into rows for a database insert
  • Extracting line items from a scanned invoice into a typed object
  • Classifying content where downstream code branches on the label
  • Hardening an endpoint that currently crashes when the reply arrives fenced

More examples

The schema the model must fill

The constrained version of the same task. enum pins the allowed priorities, required stops fields going missing, and additionalProperties False stops extra ones appearing. Use this whenever the reply feeds code rather than a human, and keep the defensive parser anyway for the day you fall back to a model without schema support.

Example · python
import json

TICKET_SCHEMA = {
    "type": "object",
    "properties": {
        "title": {"type": "string"},
        "priority": {"type": "integer", "enum": [1, 2, 3]},
        "tags": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["title", "priority", "tags"],
    "additionalProperties": False,
}

reply = client.messages.create(
    model="claude-opus-5-5", max_tokens=1024,
    messages=[{"role": "user", "content": f"Triage this ticket:\n{body}"}],
    output_config={"format": {"type": "json_schema", "schema": TICKET_SCHEMA}},
)
# With a format set, the first text block is valid JSON for this schema.
data = json.loads(next(b.text for b in reply.content if b.type == "text"))

Typed shorthand with the SDK

Shorthand for the previous example: the SDK derives the JSON Schema from the model class, sends it, and validates the reply before handing you an object. You trade a dependency for losing the hand-written schema and the hand-written type checks. The longhand is still what goes over the wire.

Example · python
from pydantic import BaseModel   # third-party: pip install pydantic

class Ticket(BaseModel):
    title: str
    priority: int
    tags: list[str]

reply = client.messages.parse(
    model="claude-opus-5-5", max_tokens=1024,
    messages=[{"role": "user", "content": body}],
    output_format=Ticket,
)
ticket = reply.parsed_output      # a validated Ticket instance
print(ticket.title, ticket.priority)

One retry with the error fed back

A bounded repair loop for providers or models without schema enforcement. Two points matter: the cap, so a stubborn model cannot bill you forever, and quoting the validation error, so the second attempt has something to act on. Do not use this to paper over a schema you could have enforced in the request.

Example · python
def parse_with_repair(messages, attempts=2):
    for attempt in range(attempts):
        reply = call_model(messages)
        try:
            return parse_reply(reply)
        except ValueError as exc:
            if attempt == attempts - 1:
                raise
            # Show the model its own output and the exact complaint. Telling it
            # "that was invalid" without the reason usually repeats the error.
            messages = messages + [
                {"role": "assistant", "content": reply},
                {"role": "user", "content": f"That failed validation: {exc}. "
                                            "Reply with JSON only."},
            ]

Discussion

  • Be the first to comment on this lesson.