← Blog

Claude API Structured Output with Pydantic: Full Guide

2026-11-03 · 5 min read · SubToAPI Team

Claude doesn't have a built-in response_format parameter like some other chat APIs, so getting Pydantic-validated structured output requires combining Claude's tool use feature with Pydantic's schema generation. The short answer: define a Pydantic model, convert it to a JSON schema, pass that schema as a tool definition, force Claude to call that tool, then validate the returned arguments against your model.

This pattern is reliable, works for nested objects, enums, and optional fields, and gives you the same guarantees you'd get from a typed API response — without Anthropic needing to ship a Python-specific parsing layer. Below is the full approach, including validation and retry handling for when the model returns something that doesn't quite match your schema.

Why tool use is the right mechanism

Claude's tool use feature lets you describe a function with a JSON Schema input_schema. When Claude decides to "call" that tool, it returns a tool_use content block whose input field is a JSON object matching your schema. If you force Claude to always use a specific tool (instead of letting it choose), you effectively get structured output every time — the tool is just a vehicle for shaping the response.

Pydantic already knows how to produce JSON Schema from a model via model_json_schema() (Pydantic v2). That schema is compatible with Claude's input_schema format with only minor caveats around $defs and recursive types, which Claude handles fine in practice for most business-data use cases.

Basic example

from pydantic import BaseModel, Field
from anthropic import Anthropic

class Invoice(BaseModel):
    invoice_number: str
    total: float
    currency: str
    line_items: list[str] = Field(default_factory=list)

client = Anthropic()
schema = Invoice.model_json_schema()

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    tools=[{
        "name": "extract_invoice",
        "description": "Extract structured invoice data from the given text.",
        "input_schema": schema,
    }],
    tool_choice={"type": "tool", "name": "extract_invoice"},
    messages=[{
        "role": "user",
        "content": "Invoice #4471, total $820.50, items: consulting, hosting"
    }]
)

tool_use = next(b for b in response.content if b.type == "tool_use")
invoice = Invoice.model_validate(tool_use.input)

print(invoice.invoice_number, invoice.total, invoice.currency)

The tool_choice parameter is what makes this deterministic — without it, Claude might just reply with text instead of calling the tool. Setting type: tool with the tool's name forces that specific call on every request.

Handling validation failures

Even with a strict schema, Claude can occasionally return a value that technically satisfies the JSON Schema but fails a Pydantic validator (a custom @field_validator, a stricter type, or a regex constraint not expressible in plain JSON Schema). Wrap the parsing step and retry with the validation error fed back to the model:

from pydantic import ValidationError

def extract_invoice(text: str, retries: int = 2) -> Invoice:
    messages = [{"role": "user", "content": text}]
    last_error = None

    for _ in range(retries + 1):
        if last_error:
            messages.append({
                "role": "user",
                "content": f"Your previous output failed validation: {last_error}. Try again."
            })

        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=1024,
            tools=[{
                "name": "extract_invoice",
                "description": "Extract structured invoice data.",
                "input_schema": Invoice.model_json_schema(),
            }],
            tool_choice={"type": "tool", "name": "extract_invoice"},
            messages=messages,
        )
        tool_use = next(b for b in response.content if b.type == "tool_use")

        try:
            return Invoice.model_validate(tool_use.input)
        except ValidationError as e:
            last_error = str(e)

    raise RuntimeError(f"Failed after {retries} retries: {last_error}")

This is the same self-correction loop you'd build for any LLM output validation, but feeding the Pydantic error message back as context usually fixes the issue in one extra round trip.

Nested models and enums

Pydantic's schema generation handles nested models and enums automatically, and Claude generally respects them well:

from enum import Enum
from pydantic import BaseModel

class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"

class Ticket(BaseModel):
    title: str
    priority: Priority
    assignee: str | None = None

schema = Ticket.model_json_schema()

One thing to watch: Pydantic puts enum and nested model definitions under $defs with $ref pointers. Claude's tool use generally resolves these correctly, but if you hit edge cases with deeply nested or recursive models, flattening the schema manually (or using model.model_json_schema(ref_template="#/definitions/{model}") adjustments) can help.

Using this pattern over HTTP

If you're calling Claude through an HTTP layer rather than the raw Anthropic SDK — for example when a backend service or a non-Python client needs structured output — the tool use mechanics are identical, since it's all request/response JSON. SubToAPI exposes Claude as a plain HTTPS API with application keys, so the same tools and tool_choice payload shape works against https://api.subtoapi.app/v1/messages:

curl https://api.subtoapi.app/v1/messages \
  -H "Authorization: Bearer $SUBTOAPI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-sonnet-4-5",
    "max_tokens": 1024,
    "tools": [{
      "name": "extract_invoice",
      "description": "Extract structured invoice data",
      "input_schema": { "type": "object", "properties": { "invoice_number": {"type":"string"}, "total": {"type":"number"} }, "required": ["invoice_number","total"] }
    }],
    "tool_choice": { "type": "tool", "name": "extract_invoice" },
    "messages": [{ "role": "user", "content": "Invoice #4471, total $820.50" }]
  }'

You'd parse the tool_use.input from the response the same way, feeding it into Invoice.model_validate(...). This is useful when you want team-wide API keys, usage tracking, and streaming on top of Claude without managing your own Anthropic credentials — see /docs/tools for the full tool use request format and /docs/messages for response shapes. A free trial is available at /signup if you want to test this against a live endpoint, and plan details are on /pricing.

Practical tips

Does Claude have a native structured output mode like OpenAI's response_format?

Not directly. Claude achieves the same result through tool use: define a JSON Schema tool, force its selection with tool_choice, and parse the tool_use.input field, which is functionally equivalent to a structured output mode.

Can I use Pydantic directly with the Claude API?

Not through a built-in SDK method, but you can generate a JSON Schema from any Pydantic model with model_json_schema() and pass it straight into a Claude tool definition, then validate the response with model_validate().

What happens if Claude returns data that doesn't match my Pydantic model?

model_validate() raises a ValidationError describing exactly what's wrong. The common fix is to catch that error, append it to the conversation as context, and retry the request — Claude usually corrects the output on the next attempt.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →