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.
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_tokensmid-object true/falserendered asTrue/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 — checktextis the.content[0].textfield, 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:
KeyErroron 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:
StopIterationonnext(b for b in ...)→ Claude returned atextblock despitetool_choice. Check you're usingclaude-sonnet-4-20250514or later — older snapshots have inconsistent tool_choice enforcement.ValidationErrorfrom Pydantic → A required field has an unexpected type. Add@field_validatorto coerce the problematic field, or widen the JSON Schema type to["string", "number"].anthropic.BadRequestError: tool_choice.name→ Thenameintool_choicemust exactly match thenamein yourtoolslist.
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_usewithtool_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.