Neural Tech Daily
ai-tutorials

Structured Output with Claude Sonnet 4.6 + Pydantic v2: A Production-Grade Python Tutorial

Force Claude Sonnet to return validated JSON via tool_use, parse with Pydantic v2, stream with retries — extract structured orders from email prose.

Updated ~12 min read
Share
Anthropic tool-use documentation page showing the tool_choice parameter and the response shape this tutorial walks through end-to-end

Image: Anthropic tool-use overview, used for editorial coverage of the API surface taught in this tutorial.

What you’ll build

By the end of this tutorial you will have a Python script that takes a natural-language customer email and returns a validated Order object: items, quantities, shipping address, requested delivery date, and a free-form note. The script forces Claude Sonnet 4.6 to return JSON that matches a Pydantic v2 schema, validates the response, streams the response so you can render partial fields in a UI, and retries with schema-feedback when validation fails 1 .

The reason to do this with tool use rather than a plain “respond in JSON” prompt is that Anthropic’s tool-use API guarantees the model emits a tool_use block whose input field matches your declared JSON schema. The Anthropic docs surface tool_choice: {"type": "tool", "name": "..."} as the supported way to force structured output 2 . Pair that with Pydantic v2’s model_json_schema() and model_validate() and you have a closed loop: schema → API call → validated Python object.

This guide assumes Python 3.10+, comfort with type hints, and an Anthropic API key. Budget 45 minutes start to finish.

1. Install the SDK and Pydantic

mkdir order-extractor && cd order-extractor
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install "anthropic>=0.40" "pydantic>=2.7"
export ANTHROPIC_API_KEY="sk-ant-..."

The anthropic Python SDK lives at anthropics/anthropic-sdk-python on GitHub and ships both sync and async clients 3 . Pydantic v2 is the version line that introduced the model_json_schema() method and the rewritten validation core; v1 syntax will not work without the pydantic.v1 shim.

2. Define the Pydantic model

Start with the data shape. The model is your contract with Claude: the JSON schema it emits drives the tool definition, and model_validate() drives the post-call validation.

# models.py
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field, field_validator


class OrderItem(BaseModel):
    sku: str = Field(
        description="Product SKU as a short alphanumeric code."
    )
    quantity: int = Field(
        ge=1,
        le=999,
        description="Integer quantity ordered, between 1 and 999.",
    )
    unit_price_inr: float | None = Field(
        default=None,
        description="Unit price in INR if quoted in the email.",
    )


class ShippingAddress(BaseModel):
    line1: str
    line2: str | None = None
    city: str
    state: str
    postcode: str = Field(
        pattern=r"^\d{6}$",
        description="6-digit postcode.",
    )


class Order(BaseModel):
    customer_email: str = Field(description="Sender email address.")
    items: list[OrderItem] = Field(min_length=1)
    shipping: ShippingAddress
    requested_delivery: date | None = None
    priority: Literal["standard", "express", "overnight"] = "standard"
    notes: str | None = Field(
        default=None,
        max_length=500,
    )

    @field_validator("customer_email")
    @classmethod
    def must_contain_at(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("not a valid email address")
        return v

Two things matter here. First, every Field description doubles as documentation Claude reads — Pydantic surfaces these descriptions into the generated JSON schema, and the model uses them to disambiguate fields 4 . Second, constraints like ge, le, min_length, and pattern ride along into the schema, so Claude sees them at call time rather than only at validation time.

Pydantic v2 documentation showing the BaseModel API and model_json_schema method used in this tutorial

Image: Pydantic v2 models documentation, used for editorial coverage of the BaseModel surface.

3. Generate the tool schema from the model

Pydantic v2 emits a JSON schema that is structurally compatible with Anthropic’s input_schema field on a tool definition. The mapping is direct: top-level type, properties, required, and nested definitions all flow through 5 .

# tool_schema.py
from models import Order

def order_tool_definition() -> dict:
    schema = Order.model_json_schema()
    return {
        "name": "record_order",
        "description": (
            "Record a structured order extracted from a customer "
            "email. Call exactly once with all fields populated."
        ),
        "input_schema": schema,
    }

model_json_schema() returns a dict with $defs for nested models, which Anthropic’s API accepts as-is. The description field on the tool is one of the few prompt surfaces the model reads before it picks fields, so make it operational rather than decorative.

4. Make the call with forced tool use

Now wire the call. tool_choice with type: "tool" and the tool name forces Claude to emit a tool_use block whose input matches the schema 6 .

# extractor.py
import json
from anthropic import Anthropic
from pydantic import ValidationError
from models import Order
from tool_schema import order_tool_definition

client = Anthropic()
MODEL = "claude-sonnet-4-6-20260101"  # adjust per the models overview

SYSTEM = (
    "You extract structured orders from natural-language customer "
    "emails. Call the record_order tool exactly once. If a field is "
    "not present in the email, leave optional fields null; never "
    "invent values. The customer_email field must be the sender's "
    "address, not any address mentioned in the body."
)


def extract_order(email_text: str) -> Order:
    response = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        system=SYSTEM,
        tools=[order_tool_definition()],
        tool_choice={"type": "tool", "name": "record_order"},
        messages=[{"role": "user", "content": email_text}],
    )

    tool_block = next(
        (b for b in response.content if b.type == "tool_use"),
        None,
    )
    if tool_block is None:
        raise RuntimeError("model returned no tool_use block")

    return Order.model_validate(tool_block.input)

Two design choices worth surfacing. The system prompt names the contract explicitly: call the tool exactly once, do not invent values. Anthropic’s tool-use guide flags that vague instructions invite the model to leave optional fields blank inconsistently 7 . And Order.model_validate(tool_block.input) is the gate: even with forced tool use, the model can still return a value that violates a pattern or a custom validator, and Pydantic raises ValidationError rather than silently passing junk downstream.

Test it with a sample email:

EMAIL = """
From: priya@example.com

Hi, please send 2x SKU-A100 (₹499 each) and 1x SKU-B220 to
14 MG Road, Bengaluru, Karnataka, 560001. Express shipping,
need it by 25 May 2026. Thanks!
"""

if __name__ == "__main__":
    order = extract_order(EMAIL)
    print(order.model_dump_json(indent=2))

Run it and you should see a fully populated Order JSON object printed to stdout.

5. Retry with schema feedback on validation failure

Forced tool use does not guarantee semantic correctness. The model might emit a 5-digit postcode where the schema demands 6, hallucinate an email address into the body, or skip a min_length=1 items list when the email is ambiguous. The robust pattern is to catch ValidationError, feed Pydantic’s error report back to Claude, and ask for a corrected call 8 .

# extractor_with_retry.py
import json
from anthropic import Anthropic
from pydantic import ValidationError
from models import Order
from tool_schema import order_tool_definition

client = Anthropic()
MODEL = "claude-sonnet-4-6-20260101"
MAX_RETRIES = 2


def extract_order_with_retry(email_text: str) -> Order:
    messages: list[dict] = [{"role": "user", "content": email_text}]
    last_error: str | None = None

    for attempt in range(MAX_RETRIES + 1):
        response = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            system=SYSTEM,
            tools=[order_tool_definition()],
            tool_choice={"type": "tool", "name": "record_order"},
            messages=messages,
        )

        tool_block = next(
            (b for b in response.content if b.type == "tool_use"),
            None,
        )
        if tool_block is None:
            raise RuntimeError("model returned no tool_use block")

        try:
            return Order.model_validate(tool_block.input)
        except ValidationError as e:
            last_error = e.json(indent=2)
            if attempt == MAX_RETRIES:
                break
            # Append assistant turn + user feedback, then retry.
            messages.append(
                {"role": "assistant", "content": response.content}
            )
            messages.append(
                {
                    "role": "user",
                    "content": (
                        f"The previous tool call failed schema "
                        f"validation. Errors:\n{last_error}\n\n"
                        "Call record_order again with the issues "
                        "corrected. Do not re-invent fields that "
                        "were already correct."
                    ),
                }
            )

    raise RuntimeError(
        f"validation failed after {MAX_RETRIES + 1} attempts: "
        f"{last_error}"
    )

The Pydantic ValidationError.json() method emits a structured per-field error list that reads cleanly to the model: each entry names the field path, the error type (string_pattern_mismatch, value_error.missing, etc.), and the input value that failed 9 . Feeding the JSON back is more reliable than paraphrasing the error in prose; the model treats it as structured ground truth and corrects specific fields rather than re-emitting the entire object.

The conversation history pattern matters. Appending the prior assistant turn (with the failed tool_use block) before the user-side feedback gives Claude the context to do a delta-fix instead of a from-scratch re-extraction.

Anthropic implement tool use documentation showing the tool_choice parameter and forced-tool-use pattern

Image: Anthropic — How to implement tool use, used for editorial coverage of the tool_choice forcing pattern.

6. Stream the response with partial-JSON parsing

For interactive UIs you want to surface fields as Claude emits them rather than blocking until the full object lands. The streaming API emits input_json_delta events on the tool_use block; each delta is a fragment of the JSON string for the tool’s input field 10 .

# streaming_extractor.py
from anthropic import Anthropic
from models import Order
from tool_schema import order_tool_definition

client = Anthropic()
MODEL = "claude-sonnet-4-6-20260101"


def extract_order_streaming(email_text: str) -> Order:
    buffer = ""
    with client.messages.stream(
        model=MODEL,
        max_tokens=1024,
        system=SYSTEM,
        tools=[order_tool_definition()],
        tool_choice={"type": "tool", "name": "record_order"},
        messages=[{"role": "user", "content": email_text}],
    ) as stream:
        for event in stream:
            if (
                event.type == "content_block_delta"
                and event.delta.type == "input_json_delta"
            ):
                buffer += event.delta.partial_json
                # Hook for UI: try a tolerant parse here and render
                # whatever top-level fields are already complete.

        final = stream.get_final_message()

    tool_block = next(
        (b for b in final.content if b.type == "tool_use"),
        None,
    )
    if tool_block is None:
        raise RuntimeError("stream ended without a tool_use block")

    return Order.model_validate(tool_block.input)

Two practical notes. The partial_json fragments do not chunk on field boundaries; a single delta may contain "customer_em and the next ail":"priya@. Naive json.loads on the buffer will fail until the full object closes. The library-supported pattern for live-rendering partial fields is a tolerant JSON parser such as partial-json-parser or json-stream that returns a partial object on incomplete input 11 . The final validation still happens against stream.get_final_message(), not against the buffered fragments; the SDK assembles the canonical tool_use block once the stream ends.

Streaming is also where MAX_RETRIES > 0 gets expensive. Each retry restarts the stream from scratch. For interactive flows, prefer streaming the first attempt and falling back to non-streaming retries; the user sees fast feedback on the happy path and pays the latency cost only when the model returns invalid output.

Anthropic streaming messages documentation showing input_json_delta events emitted during tool use

Image: Anthropic — Streaming messages, used for editorial coverage of the input_json_delta event pattern.

7. Common pitfalls

A handful of failure modes show up repeatedly when teams first wire this pattern.

Field descriptions become test cases. The model treats Field(description=...) strings as instructions, not documentation. “Sender email address” and “any email address mentioned in the body” produce different extractions on the same input. Write descriptions the way you would write acceptance criteria.

tool_choice: "auto" is not the same as forced. Without {"type": "tool", "name": "..."} the model may answer in text, emit a different tool, or skip the tool call entirely. Always force the specific tool when you need structured output.

Pydantic strict=True on numerics catches type drift. Claude occasionally emits "2" (string) where an int is declared, especially when the source text reads “two units”. Pydantic v2’s coercion is permissive by default; pass strict=True on the field or call model_validate(..., strict=True) if you want to fail-fast and route to retry.

Date fields need explicit format. date | None accepts ISO-8601 ("2026-05-25") but rejects natural-language strings like "25 May". Either tighten the field description (“ISO-8601 date string, YYYY-MM-DD format”) or post-process with a parser like dateutil before validation.

Conversation history grows fast on retry. Each retry appends an assistant turn plus a user-feedback turn. For three retries on a 1,500-token email you can easily hit 6,000+ tokens of context. Cap MAX_RETRIES at 2 and keep feedback prompts short.

Streaming partial JSON is not stable enough to validate mid-stream. Render partial fields for UX, but do not attempt a model_validate until the stream completes. The SDK’s get_final_message() is the single source of truth.

Pydantic v2 validation errors documentation showing ValidationError JSON output format used in the retry loop

Image: Pydantic v2 validation errors documentation, used for editorial coverage of the ValidationError API.

8. Where to go next

The pattern in this tutorial extends naturally in three directions.

Multi-tool flows where the model picks between several structured outputs. Replace tool_choice: {"type": "tool", ...} with tool_choice: {"type": "any"} and declare a list of Pydantic-derived tools; the model picks one and emits its input. The Anthropic tool-use guide covers the dispatch pattern.

Function execution loops where the model calls a tool, receives a result, then calls a second tool. The extract_order flow above is a one-shot; the docs walk through the tool_result block pattern for multi-turn agentic flows.

Schema evolution where the Pydantic model changes faster than your prompts. Use Order.model_json_schema() as the single source of truth and regenerate the tool definition on every call rather than checking the schema into a separate JSON file; the description fields stay in sync with the validators automatically.

For production hardening, three things to add beyond this tutorial: a request-side timeout via the SDK’s timeout parameter, observability on the retry counter (a histogram of attempts per call surfaces prompt drift early), and an out-of-band reconciler for the cases where retries exhaust and the email lands in a human-review queue.

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.