Build Claude 4.5 JSON Mode: Reliable Structured Output 2026

Claude 4.5 JSON mode structured output patterns using Python 3.12 and the Anthropic SDK. Extract validated data, avoid parse errors, build production pipelines.

Problem: Claude Returns Unstructured or Broken JSON

Claude 4.5 JSON mode structured output is the fastest way to get typed, validated data from Claude — but most developers hit json.JSONDecodeError on the first real request. Claude wraps output in markdown fences, adds prose before the JSON, or returns partial objects under token pressure.

You'll learn:

  • Three patterns for reliable JSON extraction from Claude 4.5 — from quick-fix to production-grade
  • How to use Pydantic v2 to validate and coerce Claude's output at runtime
  • How to force schema-correct output using Claude's tool_use feature as a JSON constraint

Time: 20 min | Difficulty: Intermediate


Why Claude Returns Broken JSON

Claude is a language model, not a JSON serializer. Without explicit constraints, it follows its training instinct: be helpful and readable.

Claude 4.5 JSON mode structured output request-response flow Three extraction layers: prompt constraint → tool_use schema → Pydantic runtime validation

Symptoms:

  • Response starts with Here is the JSON: followed by a fenced code block
  • Valid JSON wrapped in ```json ``` markdown fences — json.loads() throws immediately
  • Nested keys missing when the model hits max_tokens mid-object
  • true/false rendered as True/False (Python literals, not JSON)

The root cause is always the same: Claude optimizes for human readability unless you constrain the output channel.


Solution

Step 1: Strip Fences with a Regex Fallback (Quick Fix)

This is the 80% solution — add it before any json.loads() call in your codebase.

import re
import json

def extract_json(text: str) -> dict:
    # Strip markdown fences — Claude wraps JSON in ```json ... ``` when not constrained
    cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.MULTILINE)
    return json.loads(cleaned)

Expected output: Clean dict from any Claude response that contains valid JSON, fenced or not.

If it fails:

  • json.JSONDecodeError: Expecting value → Claude added prose before the JSON. Use the tool_use pattern in Step 3 instead.
  • AttributeError: 'NoneType' → The regex didn't match — check text is the .content[0].text field, not the full response object.

Step 2: Constrain the Prompt (Reliable for Simple Schemas)

Add three directives to your system prompt. This handles ~95% of simple extraction tasks.

import anthropic

client = anthropic.Anthropic()

SYSTEM = """You are a data extraction API.
Respond with ONLY valid JSON — no markdown, no explanation, no extra keys.
Your output must parse with Python's json.loads() without any preprocessing."""

def extract_structured(user_prompt: str, schema_hint: str) -> dict:
    response = client.messages.create(
        model="claude-sonnet-4-20250514",  # Claude 4.5 Sonnet — best cost/quality for JSON tasks
        max_tokens=1024,
        system=SYSTEM,
        messages=[
            {
                "role": "user",
                "content": f"{user_prompt}\n\nReturn this exact schema:\n{schema_hint}"
            }
        ]
    )
    return json.loads(response.content[0].text)

# Example: extract invoice fields from unstructured text
result = extract_structured(
    user_prompt="Invoice #1042 from Acme Corp, dated 2026-02-14, total $3,200.00 USD, net-30.",
    schema_hint='{"invoice_id": "string", "vendor": "string", "date": "YYYY-MM-DD", "total_usd": "number", "payment_terms": "string"}'
)
print(result)
# {"invoice_id": "1042", "vendor": "Acme Corp", "date": "2026-02-14", "total_usd": 3200.0, "payment_terms": "net-30"}

Expected output: {"invoice_id": "1042", "vendor": "Acme Corp", ...}

If it fails:

  • KeyError on a required field → Claude inferred it was optional. Add "REQUIRED" as the value in the schema hint: {"invoice_id": "string REQUIRED"}.
  • Number returned as string → Specify "number" not "string" in schema hint, or use Pydantic coercion in Step 3.

Step 3: Use tool_use as a JSON Schema Constraint (Production Pattern)

This is the correct long-term approach. Claude's tool_use feature forces output to match a JSON Schema — the model cannot emit arbitrary text.

import anthropic
from pydantic import BaseModel, field_validator
from typing import Any
import json

client = anthropic.Anthropic()

# Define your target schema as a Pydantic model
class Invoice(BaseModel):
    invoice_id: str
    vendor: str
    date: str  # YYYY-MM-DD
    total_usd: float
    payment_terms: str

    @field_validator("total_usd", mode="before")
    @classmethod
    def coerce_total(cls, v: Any) -> float:
        # Claude occasionally returns "$3,200.00" — strip currency symbols before coercion
        if isinstance(v, str):
            return float(v.replace("$", "").replace(",", ""))
        return v

# Define the tool that enforces the schema
EXTRACT_TOOL = {
    "name": "extract_invoice",
    "description": "Extract structured invoice data from unstructured text.",
    "input_schema": {
        "type": "object",
        "properties": {
            "invoice_id": {"type": "string", "description": "Invoice number without # prefix"},
            "vendor": {"type": "string", "description": "Vendor or company name"},
            "date": {"type": "string", "description": "Invoice date in YYYY-MM-DD format"},
            "total_usd": {"type": "number", "description": "Total amount in USD as a float"},
            "payment_terms": {"type": "string", "description": "Payment terms e.g. net-30"}
        },
        "required": ["invoice_id", "vendor", "date", "total_usd", "payment_terms"]
    }
}

def extract_invoice(text: str) -> Invoice:
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1024,
        tools=[EXTRACT_TOOL],
        tool_choice={"type": "tool", "name": "extract_invoice"},  # Force this specific tool — no free-text fallback
        messages=[{"role": "user", "content": text}]
    )

    # tool_use blocks are always at content[0] when tool_choice forces a specific tool
    tool_block = next(b for b in response.content if b.type == "tool_use")
    return Invoice.model_validate(tool_block.input)

invoice = extract_invoice(
    "Invoice #1042 from Acme Corp, dated Feb 14 2026, total $3,200.00, net-30 terms."
)
print(invoice.model_dump())

Expected output:

{'invoice_id': '1042', 'vendor': 'Acme Corp', 'date': '2026-02-14', 'total_usd': 3200.0, 'payment_terms': 'net-30'}

If it fails:

  • StopIteration on next(b for b in ...) → Claude returned a text block despite tool_choice. Check you're using claude-sonnet-4-20250514 or later — older snapshots have inconsistent tool_choice enforcement.
  • ValidationError from Pydantic → A required field has an unexpected type. Add @field_validator to coerce the problematic field, or widen the JSON Schema type to ["string", "number"].
  • anthropic.BadRequestError: tool_choice.name → The name in tool_choice must exactly match the name in your tools list.

Step 4: Add Retry Logic for Transient Failures

The Anthropic API rate-limits at 60 requests/min on the default tier ($0.003/1K input tokens for Sonnet). Add retries for 529 overload errors.

import time
import anthropic
from anthropic import APIStatusError

def extract_with_retry(text: str, max_retries: int = 3) -> Invoice:
    for attempt in range(max_retries):
        try:
            return extract_invoice(text)
        except APIStatusError as e:
            if e.status_code == 529 and attempt < max_retries - 1:
                # 529 = API overloaded; exponential backoff avoids hammering during peak hours
                wait = 2 ** attempt
                print(f"Overloaded, retrying in {wait}s...")
                time.sleep(wait)
            else:
                raise
    raise RuntimeError("Max retries exceeded")

Verification

# Run this after implementing Step 3
test_texts = [
    "INV-9901 · Stripe Inc · 2026-01-31 · $149.00 · net-15",
    "Invoice number: 0042, billed by AWS us-east-1 region, March 1 2026, $2,847.33 total, due net-30",
    "Vendor: GitHub, invoice #GH-2026-03, amount due $84/mo (USD), issued 2026-03-01"
]

for text in test_texts:
    result = extract_with_retry(text)
    assert isinstance(result.total_usd, float), "total_usd must be float"
    assert "-" in result.date, "date must be YYYY-MM-DD"
    print(f"✅ {result.invoice_id} | {result.vendor} | ${result.total_usd}")

You should see:

✅ 9901 | Stripe Inc | $149.0
✅ 0042 | AWS | $2847.33
✅ GH-2026-03 | GitHub | $84.0

What You Learned

  • tool_use with tool_choice: {"type": "tool", "name": "..."} is the only guaranteed way to get schema-valid JSON from Claude. Prompt-only approaches break on adversarial or complex inputs.
  • Pydantic @field_validator(mode="before") is your safety net for type coercion. Claude sometimes returns numbers as currency strings — always validate at runtime, not just at schema design time.
  • The regex fence-stripper (Step 1) is safe to add everywhere as a no-op fallback. It adds zero latency and catches the most common failure mode in legacy codebases.
  • When NOT to use this pattern: if you need streaming output or very long JSON arrays (>100 items), the tool_use approach blocks until completion. Switch to prompt-constrained extraction with streaming and validate chunks incrementally.

Tested on claude-sonnet-4-20250514, anthropic SDK 0.25.x, Python 3.12, macOS & Ubuntu 24.04


FAQ

Q: Does Claude 4.5 support a native JSON mode like OpenAI's response_format: {type: "json_object"}? A: Not directly. The equivalent is tool_choice with a named tool — it enforces a JSON Schema, which is strictly more powerful than OpenAI's untyped JSON mode. Use the tool_use pattern in Step 3 for the closest equivalent.

Q: What is the difference between tool_choice: "auto" and tool_choice: {"type": "tool", "name": "..."}? A: auto lets Claude decide whether to call a tool or respond with text — unreliable for structured extraction. {"type": "tool", "name": "..."} forces Claude to always invoke that specific tool, eliminating free-text responses entirely.

Q: What is the minimum Python version required for the Anthropic SDK? A: Python 3.8+ is supported, but use Python 3.11 or 3.12. Pydantic v2 model_validate and @field_validator with mode="before" require Pydantic 2.0+, which itself requires Python 3.8+ but performs significantly better on 3.12.

Q: Can I use this pattern with Claude Haiku for lower cost? A: Yes. Replace claude-sonnet-4-20250514 with claude-haiku-4-5-20251001 ($0.00025/1K input tokens). Haiku respects tool_choice equally well. Use Sonnet when schema complexity is high (10+ fields, nested objects) — Haiku occasionally omits optional nested fields under token pressure.

Q: Does tool_use JSON extraction work inside LangChain or LlamaIndex? A: Yes, but pass the Anthropic tool definition directly via bind_tools() on the ChatAnthropic class. LangChain's .with_structured_output() wraps this automatically when you pass a Pydantic model — it generates the JSON Schema and sets tool_choice for you.