Make Claude return schema-valid JSON: use tool use instead of “please return JSON”
TL;DR
Declare a tool with an input_schema (JSON Schema), set tool_choice to force that tool, then read the input of the tool_use block. Validate server-side and retry on failure. The Claude API also has an official structured outputs feature — check the docs to pick the right approach.
“Return JSON only, no explanation” — that prompt works 95% of the time. The other 5% is when the model prefixes the JSON with “Here’s the result:”, writes "High" instead of "high", or drops a required field… and your parser crashes at 2 a.m. With Claude there are two far more reliable options: structured outputs (constraining the output to a JSON Schema) and tool use with strict schemas.
Three ways to get JSON from Claude
| Method | Reliability | Use when |
|---|---|---|
| Prompt “return JSON” | Low — down to luck | Quick prototypes, never production |
JSON outputs (output_config.format) | High — constrained decoding follows the schema | The final answer must be parseable: extraction, classification, reports |
Strict tool use (strict: true) | High — tool names and parameters match the schema | Agents calling your functions, where bad parameters break things |
The last two can be used separately or together in one request — for instance an agent that calls tools precisely and also returns a structured final answer.
Option 1: JSON outputs
Declare a schema in output_config.format. Claude is constrained while generating tokens, so the output matches the schema (with the exceptions below) and you don’t need format-retry loops:
import json, anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=500,
messages=[{"role": "user", "content": ticket_text}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["bug", "billing", "feature", "other"]},
"priority": {"type": "string", "enum": ["P1", "P2", "P3"]},
"summary": {"type": "string"}
},
"required": ["category", "priority", "summary"],
"additionalProperties": False
}
}
},
)
data = json.loads(next(b.text for b in resp.content if b.type == "text"))
Cleaner with Pydantic (Python SDK)
from pydantic import BaseModel
from typing import Literal
class Ticket(BaseModel):
category: Literal["bug", "billing", "feature", "other"]
priority: Literal["P1", "P2", "P3"]
summary: str
resp = client.messages.parse(
model="claude-sonnet-5-5", max_tokens=500,
messages=[{"role": "user", "content": ticket_text}],
output_format=Ticket,
)
ticket = resp.parsed_output # a validated Ticket instance
The SDK converts the Pydantic model into a schema, sends it as output_config.format and validates the result. Constraints the API doesn’t support (such as minimum or maxLength) are moved into field descriptions by the SDK and still checked in your code.
Option 2: Strict tool use
When an agent calls your real functions (booking, creating tickets, querying a database), add "strict": true to the tool definition so tool names and parameters always match the schema:
tools = [{
"name": "save_ticket",
"description": "Save a classified ticket",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["bug", "billing", "feature", "other"]},
"priority": {"type": "string", "enum": ["P1", "P2", "P3"]},
"summary": {"type": "string"}
},
"required": ["category", "priority", "summary"],
"additionalProperties": False
}
}]
msg = client.messages.create(
model="claude-sonnet-5-5", max_tokens=500, tools=tools,
tool_choice={"type": "tool", "name": "save_ticket"}, # force this tool
messages=[{"role": "user", "content": ticket_text}])
data = next(b.input for b in msg.content if b.type == "tool_use")
The old trick of forcing a tool with tool_choice and reading its input still helps; adding strict moves you from “usually right” to “guaranteed to match the schema”.
JSON Schema limits to know
| Supported | Not supported (400 error) |
|---|---|
| object, array, string, integer, number, boolean, null | Recursive schemas |
enum, const, anyOf, allOf | minimum, maximum, multipleOf |
Internal $ref, default, required | minLength, maxLength |
| Formats: date-time, date, email, uri, uuid, ipv4… | additionalProperties other than false |
Simple pattern regexes | Regexes with backreferences or lookaround |
There are complexity limits too: up to 20 strict tools per request, 24 optional parameters and 16 union-type parameters across all strict schemas. Overly complex schemas fail with a “too complex for compilation” error — flatten structures, make parameters required, or mark only critical tools as strict.
When output can still deviate
stop_reason: "refusal"— the refusal message takes precedence, so the output may not match the schema.stop_reason: "max_tokens"— output is truncated mid-way and invalid. Raisemax_tokensand retry.- Enum casing — capitalisation can occasionally differ from the schema without an error. Compare case-insensitively and avoid enum values that differ only by case.
Performance and cost
- The first request with a new schema is slower because the grammar is compiled; compiled grammars are cached for 24 hours from last use.
- Changing the schema structure invalidates the cache; changing only
nameordescriptiondoesn’t. - The feature injects a short system prompt describing the format → a few extra input tokens.
- It can’t be combined with citations (400 error) or answer prefilling; it works fine with the Batch API (50% off), streaming and token counting.
Production checklist
- Use JSON outputs or strict tool use instead of prompt instructions alone.
- Still validate on the server (Pydantic / Zod) — that catches refusals and max_tokens cases.
- Check
stop_reasonbefore parsing. - Log input/output of failures to improve prompts and schemas.
- Simple extraction → small models; bulk runs → combine with the Batch API.
- Never put sensitive data (e.g. health information) in field names, enum values or patterns — schemas are cached; keep real data in message content.
How to apply it
- Developers: Find every place that
json.loads()s output from a “return JSON” prompt and switch tomessages.parse()with Pydantic. Delete the format-retry loops while you’re there. - Tech Leads: Standardise: every tool that calls a system with side effects uses
strictand gets a schema review, like an API contract. - PMs: When the team estimates an “AI data extraction” feature, the parsing and retry work is now much smaller — put the time into schema design and evaluation tests for content quality.
Sources & references
- Structured outputs — Claude Docs