n4nAI

Claude structured outputs: a hands-on tutorial

Step-by-step Claude structured outputs tutorial: force reliable JSON from Claude via tool use, with Pydantic validation and runnable Python examples.

n4n Team2 min read483 words

Audio narration

Coming soon — every post will get a voice note here.

This Claude structured outputs tutorial shows how to get strictly typed JSON out of Claude without begging the model to format correctly. We’ll use Anthropic’s tool-use primitive to force a schema, then validate the result with Pydantic so your agent can trust the payload.

Prerequisites

  • Python 3.10 or newer
  • anthropic and pydantic installed (pip install anthropic pydantic)
  • An Anthropic API key exported as ANTHROPIC_API_KEY
  • Basic familiarity with JSON Schema

If you route through a gateway, the same schemas apply; we’ll note that at the end.

Step 1: Define the target schema

Claude doesn’t have a native “JSON mode” toggle like OpenAI’s response_format. The reliable path is to define a tool whose input_schema is your desired shape, then force the model to call it. Start by describing the data you want.

from pydantic import BaseModel, Field
from typing import List

class LineItem(BaseModel):
    sku: str
    quantity: int = Field(ge=1)
    unit_price_cents: int = Field(ge=0)

class Invoice(BaseModel):
    invoice_id: str
    vendor: str
    total_cents: int
    items: List[LineItem]

We’ll convert this to JSON Schema for Anthropic. Pydantic v2 exposes model_json_schema().

schema = Invoice.model_json_schema()
print(schema)

Expected output (truncated for brevity):

{
  "properties": {
    "invoice_id": {"title": "Invoice Id", "type": "string"},
    "vendor": {"title": "Vendor", "type": "string"},
    "total_cents": {"title": "Total Cents", "type": "integer"},
    "items": {
      "items": {"$ref": "#/$defs/LineItem"},
      "title": "Items",
      "type": "array"
    }
  },
  "$defs": {
    "LineItem": {
      "properties": {
        "sku": {"title": "Sku", "type": "string"},
        "quantity": {"minimum": 1, "title": "Quantity", "type": "integer"},
        "unit_price_cents": {"minimum": 0, "title": "Unit Price Cents", "type": "integer"}
      },
      "required": ["sku", "quantity", "unit_price_cents"],
      "title": "LineItem",
      "type": "object"
    }
  },
  "required": ["invoice_id", "vendor", "total_cents", "items"],
  "title": "Invoice",
  "type": "object"
}

Step 2: Register the schema as a Claude tool

Anthropic expects a tools list where each entry has name, description, and input_schema. The input_schema is a standard JSON Schema object.

tool = {
    "name": "extract_invoice",
    "description": "Extract structured invoice data from free text",
    "input_schema": schema,
}

tools = [tool]

Step 3: Force the call

Set tool_choice to pin the model to your tool. Without this, Claude may return plain text. With it, the response will contain a tool_use block whose input is the parsed object.

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "tool", "name": "extract_invoice"},
    messages=[
        {
            "role": "user",
            "content": "Invoice #INV-42 from Acme Corp: 2x SKU-100 at $5.00, 1x SKU-200 at $12.00. Total $22.00.",
        }
    ],
)

print(response.stop_reason)

Expected output:

tool_use

Step 4: Parse and validate

The assistant message contains a tool_use block. Extract its input and load it back into Pydantic.

tool_block = next(b for b in response.content if b.type == "tool_use")
raw = tool_block.input

invoice = Invoice(**raw)
print(invoice.model_dump_json(indent=2))

Expected output:

{
  "invoice_id": "INV-42",
  "vendor": "Acme Corp",
  "total_cents": 2200,
  "items": [
    {"sku": "SKU-100", "quantity": 2, "unit_price_cents": 500},
    {"sku": "SKU-200", "quantity": 1, "unit_price_cents": 1200}
  ]
}

If Claude hallucinates a missing field or wrong type, Pydantic raises ValidationError. That’s your signal to retry or fall back.

Step 5: Handle real-world messiness

Production text isn’t clean. You’ll get partial invoices, conflicting totals, or currency symbols. Two patterns keep this robust:

  1. Retry on validation failure with the error message fed back as a user turn.
  2. Relax constraints in the schema (e.g., total_cents as int | None) and compute derived fields yourself.

Example retry loop:

from pydantic import ValidationError

def extract(text: str) -> Invoice:
    msg = [{"role": "user", "content": text}]
    for _ in range(3):
        resp = client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=1024,
            tools=tools,
            tool_choice={"type": "tool", "name": "extract_invoice"},
            messages=msg,
        )
        block = next(b for b in resp.content if b.type == "tool_use")
        try:
            return Invoice(**block.input)
        except ValidationError as e:
            msg.append({"role": "assistant", "content": resp.content})
            msg.append({
                "role": "user",
                "content": f"Validation failed: {e}\nFix the extraction to match the schema.",
            })
    raise RuntimeError("extraction failed after retries")

Advanced: Nested arrays and enums

Tool-use schemas accept the full JSON Schema draft-07 subset Anthropic supports. Add enums for constrained strings, or deeper nesting for line-item metadata.

from pydantic import BaseModel, Field
from typing import Literal

class LineItem(BaseModel):
    sku: str
    quantity: int = Field(ge=1)
    unit_price_cents: int = Field(ge=0)
    tax_status: Literal["taxable", "exempt"] = "taxable"

# regenerate schema, same tool registration

Claude respects enum/Literal closely when the tool is forced. In testing, mismatch rates drop to near zero compared to free-form “output JSON” prompts.

Routing through an OpenAI-compatible gateway

If you don’t want to wire Anthropic directly, an OpenAI-compatible endpoint such as n4n.ai accepts the same shape as a function and forwards provider cache-control hints, so you keep prompt caching while using one client. The tool_choice equivalent is function_call: {"name": "extract_invoice"} on the chat completions route. The schema stays identical; only the transport changes.

Takeaways

  • Claude structured outputs tutorial outcome: tool use is the only deterministic way to get JSON from Claude today.
  • Define schema once with Pydantic, reuse for both Anthropic input_schema and validation.
  • Force tool_choice to avoid text drift.
  • Validate immediately; retry with the error as context.
  • For multi-model fleets, the schema ports cleanly to OpenAI-compatible gateways.

That’s the whole pipeline. Ship it behind a queue and you have a typed extraction worker.

Tagsclaudestructured-outputtool-usetutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All structured outputs & json mode for agents posts →