Most "JSON parsing" failures in production Claude integrations aren't JSON bugs at all — they're prompting bugs wearing a parser's error message. If your pipeline throws json.JSONDecodeError or SyntaxError: Unexpected token, the fix usually isn't a better parser. It's forcing structure at the API level instead of asking for it in prose.
The real problem: you don't have a JSON bug
Asking a model to "respond in JSON" inside a plain-text prompt treats structure as a style suggestion, not a contract. The model is free to:
- Wrap the answer in
json ...fences (great for a human reading chat, useless forJSON.parse()). - Add an opening line like "Sure, here's the JSON you asked for:" before the
{. - Omit a required field when the source input is ambiguous.
- Emit
snake_caseon one call andcamelCaseon the next.
None of that is a parser problem. It's what happens when structure is a suggestion instead of a contract.
The fix: treat structured output as tool use
The Claude API doesn't have a separate "JSON mode" — and it doesn't need one. Tool use (function calling) already solves this, because it moves the structural guarantee from free text to the tool's input_schema. When the model calls a tool, tool_use.input arrives as an already-parsed object, extracted by the SDK — not a string you re-parse yourself.
The part that actually makes this reliable is forcing the call with tool_choice, instead of letting the model decide whether to call the tool or just answer in text:
# pip install anthropic
import anthropic
client = anthropic.Anthropic()
INVOICE_SCHEMA = {
"type": "object",
"properties": {
"invoice_number": {
"type": "string",
"description": "The invoice's unique identifier, e.g. INV-2026-0001."
},
"total_amount": {
"type": "number",
"description": "Total amount due, with no currency symbol."
},
"currency": {
"type": "string",
"enum": ["USD", "EUR", "BRL"]
}
},
"required": ["invoice_number", "total_amount", "currency"]
}
# any real input — an invoice, an email, text extracted from a PDF, etc.
document_text = """Invoice INV-2026-0042
Total due: $1,240.00"""
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=[{
"name": "extract_invoice",
"description": "Extracts structured data from the invoice.",
"input_schema": INVOICE_SCHEMA
}],
# forces the call — the model can't just answer in prose
tool_choice={"type": "tool", "name": "extract_invoice"},
messages=[{"role": "user", "content": document_text}]
)
tool_block = next(b for b in response.content if b.type == "tool_use")
data = tool_block.input # already a dict — nothing to json.loads()
print(data)
# {'invoice_number': 'INV-2026-0042', 'total_amount': 1240.0, 'currency': 'USD'}
This snippet is complete — export ANTHROPIC_API_KEY and run it as-is. Two choices are doing the real work here:
tool_choicenames the exact tool. Without it, the model may still prefer answering in plain text when the input is ambiguous.- Every field in the schema has a
description. It isn't decoration — it's a prompt instruction embedded in the schema, and the model uses it to decide what to put in each field.
Schemas that reduce ambiguity, not just validate it
A well-written input_schema does the work of an entire prompt. Three habits that cut down on wrong-field errors before validation even runs:
Prefer enum over free text whenever the set of values is closed. "currency": {"type": "string"} leaves the model free to write "dollars", "USD", or "US$" across different calls. "enum": ["USD", "EUR", "BRL"] removes that variance structurally — the model picks from options instead of inventing a string.
Avoid unnecessary deep nesting. Schemas with 4+ levels of depth (an object inside an array inside an object inside an object) raise the odds the model drops a required field at the innermost level. Flatten the structure where the domain allows it.
Write description for someone who has never seen the source document. "Customer name" is ambiguous if the document has both a contact name and a legal entity name. "Legal name of the purchasing company, as it appears in the invoice header" resolves the ambiguity without needing an example.
The silent failure: truncation at max_tokens
An incomplete tool_use.input doesn't throw a schema error — it just stops mid-object, and the SDK hands you whatever it managed to build up to that point. If your schema has a long list (say, dozens of line_items), an undersized max_tokens cuts the response off before the JSON closes, and you only find out when you try to read a field that never arrived.
Always check response.stop_reason before trusting the result. If it's "max_tokens", the object may be incomplete — treat that as a failure and retry with a larger token budget, never as partially valid data.
The pattern that handles the rest: validate, then hand the error back to the model
No schema eliminates 100% of errors. The realistic goal isn't "never fail" — it's fail in a way that self-corrects. The most robust pattern treats the model's response as an attempt, validates it against the schema with a real library (not a loose try/except), and feeds the specific validation error back in the next turn — letting the model fix the exact field it got wrong.
- Call the model with
tool_choiceforced - Validate
tool_use.inputagainst the schema - If invalid, send the specific error back as the next turn
- Repeat until it validates or retries run out
# pip install jsonschema
from jsonschema import validate, ValidationError
def get_structured_output(client, schema, tool_name, tool_description, messages, max_retries=2):
max_tokens = 1024
for attempt in range(max_retries + 1):
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=max_tokens,
tools=[{"name": tool_name, "description": tool_description, "input_schema": schema}],
tool_choice={"type": "tool", "name": tool_name},
messages=messages,
)
if response.stop_reason == "max_tokens":
# don't reuse the truncated message — Anthropic's own docs recommend
# resending with a bigger budget, not patching an incomplete response
max_tokens *= 2
continue
# only gets here with a complete response — now it's safe to add to history
messages.append({"role": "assistant", "content": response.content})
tool_block = next((b for b in response.content if b.type == "tool_use"), None)
if tool_block is None:
messages.append({"role": "user", "content": "You must call the tool to respond."})
continue
try:
validate(instance=tool_block.input, schema=schema)
return tool_block.input
except ValidationError as e:
messages.append({"role": "user", "content":
f"Validation failed: {e.message}. Call the tool again with the corrected value."})
raise RuntimeError("Could not get valid structured output after all retries.")
# usage — reusing the schema and document_text from the example above
data = get_structured_output(
client=client,
schema=INVOICE_SCHEMA,
tool_name="extract_invoice",
tool_description="Extracts structured data from the invoice.",
messages=[{"role": "user", "content": document_text}],
)
print(data)
The key detail: the previous assistant turn (messages.append({"role": "assistant", ...})) has to go into history before the correction — without it, the model has no memory of what it tried last time. This mirrors the exact "validation and retry loops" task statement tested in the CCA-F's Prompt Engineering domain: the model gets specific feedback about what broke, instead of a generic "try again."
At scale: batching doesn't do schema validation for you
Processing thousands of documents through the Message Batches API (up to 10,000 requests per batch, each handled independently) makes it tempting to assume volume removes the need for per-item validation. It doesn't. Every response in the batch can still truncate, can still fail validation, and in a 10,000-item batch, even a 1% error rate is 100 cases that need handling. The validate-and-hand-back pattern above stays the unit of work — batching only parallelizes how many times that unit runs, it doesn't remove the need for it.
Quick checklist before shipping
- Replaced "respond in JSON" in the prompt with
tools+input_schema? - Forced the call with
tool_choice: {"type": "tool", "name": "..."}? - Does every schema property have a specific
description, not a generic one? - Do closed-set fields use
enuminstead of free-textstring? - Do you check
stop_reason == "max_tokens"before using the result? - Does your retry loop hand the specific error back to the model, instead of a blind retry?
