Foundations · Structured Outputs

Getting Structured (JSON) Output

Two dependable ways to make Claude's output conform to a JSON shape you specify — and why you should still validate the result before your code trusts it.

☺ Explain it like I'm 10

Imagine ordering a custom T-shirt two ways. First, you email the print shop a paragraph describing what you want — they might nail it, or send back the wrong size with the logo on the wrong sleeve. Second, you fill out their order form: exact size, exact color, exact sleeve length, no blank left empty. Asking Claude for "JSON please" is the email; giving it a schema is the order form — you get the same shape back every time, though you still have to check the shirt actually fits when it arrives.

🐦Your host for this topic: Pip the Hummingbird (delivers a clean, predictable package).

Why "just ask for JSON" isn't reliable enough

☺ Like you're 10: Asking nicely for "JSON please" is like asking a friend to text you "the important stuff" — everyone's important stuff looks a little different. A form with labeled blanks is the only way to get the same shape back every time.

The obvious first attempt at structured output is to describe the shape you want in plain English and hope: "extract the name and email from this text and return it as JSON." That works often enough to feel safe in a demo, which is exactly what makes it dangerous in production. Nothing about a plain-English request constrains the model's output at the token level, so the same prompt can drift across runs — a friendly sentence before the JSON, the whole thing wrapped in a markdown code fence, a key renamed from email to email_address, or a number returned as the string "2" instead of the integer 2.

// Same prompt, two separate runs, no schema enforcement:

// Run 1
Here's the extracted contact info:
```json
{"Name": "Priya Nair", "Email": "priya.nair@fenwickrobotics.com"}
```

// Run 2
{
  "name": "Priya Nair",
  "email_address": "priya.nair@fenwickrobotics.com",
  "company": "Fenwick Robotics"
}

Every one of those variations breaks a naive json.loads() call, or silently produces the wrong field names downstream. The fix isn't a more carefully worded prompt — it's giving Claude a machine-checkable contract to fill in, rather than a description to interpret.

◆ Pattern

Define an extraction tool with a strict input_schema, force Claude to call it, and read the result out of the tool_use block. The output is grammar-constrained to match your schema, every time.

⚠ Anti-pattern

Ask in plain English for "JSON please" with no schema. Output format, key names, and casing can all vary run to run, and there's nothing to validate against except your own eyeballs.

Approach 1: force the shape with an extraction tool

☺ Like you're 10: A forced tool call is a fill-in-the-blanks worksheet Claude has to hand in — it can't turn in a five-paragraph essay instead.

This is the standard "function-calling-as-extraction" pattern: you define a tool that's never actually executed — it exists purely so its input_schema becomes the contract Claude's output must satisfy. Combine it with strict: true and a forced tool_choice, and Claude's response is produced through grammar-constrained sampling against your schema instead of free-form text generation. As Anthropic's own docs put it: without strict mode, Claude might hand back the wrong type — a string where you asked for a number — or skip a required field entirely. Strict mode is what closes that gap.

Python
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from env

extract_contact_tool = {
    "name": "extract_contact",
    "description": (
        "Extract a single contact record from unstructured text. Use this "
        "whenever the input contains a person's name and email, optionally "
        "their company and role, and the caller wants that information "
        "returned as structured data rather than prose."
    ),
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "name": {"type": "string", "description": "The person's full name"},
            "email": {"type": "string", "format": "email", "description": "The person's email address"},
            "company": {"type": "string", "description": "The company the person works for, if mentioned"},
            "role": {"type": "string", "description": "The person's job title or role, if mentioned"},
            "confidence": {
                "type": "number",
                "description": "How confident the extraction is, from 0.0 (guessing) to 1.0 (explicit in the text)"
            }
        },
        "required": ["name", "email", "confidence"],
        "additionalProperties": False
    }
}

message = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1000,
    tools=[extract_contact_tool],
    tool_choice={"type": "tool", "name": "extract_contact"},
    messages=[
        {
            "role": "user",
            "content": (
                "Reach out to Priya Nair, she's the VP of Engineering at "
                "Fenwick Robotics. Her email is priya.nair@fenwickrobotics.com."
            ),
        }
    ],
)

tool_use_block = next(block for block in message.content if block.type == "tool_use")
contact = tool_use_block.input
print(contact)
# {'name': 'Priya Nair', 'email': 'priya.nair@fenwickrobotics.com',
#  'company': 'Fenwick Robotics', 'role': 'VP of Engineering', 'confidence': 0.95}
JavaScript
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env

const extractContactTool = {
  name: "extract_contact",
  description:
    "Extract a single contact record from unstructured text. Use this " +
    "whenever the input contains a person's name and email, optionally " +
    "their company and role, and the caller wants that information " +
    "returned as structured data rather than prose.",
  strict: true,
  input_schema: {
    type: "object",
    properties: {
      name: { type: "string", description: "The person's full name" },
      email: { type: "string", format: "email", description: "The person's email address" },
      company: { type: "string", description: "The company the person works for, if mentioned" },
      role: { type: "string", description: "The person's job title or role, if mentioned" },
      confidence: {
        type: "number",
        description: "How confident the extraction is, from 0.0 (guessing) to 1.0 (explicit in the text)"
      }
    },
    required: ["name", "email", "confidence"],
    additionalProperties: false
  }
};

const message = await client.messages.create({
  model: "claude-sonnet-5",
  max_tokens: 1000,
  tools: [extractContactTool],
  tool_choice: { type: "tool", name: "extract_contact" },
  messages: [
    {
      role: "user",
      content:
        "Reach out to Priya Nair, she's the VP of Engineering at " +
        "Fenwick Robotics. Her email is priya.nair@fenwickrobotics.com."
    }
  ]
});

const toolUseBlock = message.content.find((block) => block.type === "tool_use");
const contact = toolUseBlock.input;
console.log(contact);

Note the shape of the request: tool_choice: {"type": "tool", "name": "extract_contact"} forces Claude to call exactly that tool, rather than deciding on its own whether to (the {"type": "auto"} default) or picking from several ({"type": "any"}). Because tool_choice forces a tool call, the API also skips any natural-language preamble internally — you get the tool_use block and nothing else.

Raw textunstructured input Claude + toolstrict schema, forced choice tool_use blockschema-shaped input Validated JSONchecked against your rules
⌁ Note

Forced tool use (any or tool) is compatible with the adaptive thinking that Claude Sonnet 5, Opus 5, and Fable 5 use by default. It is not compatible with manual, non-adaptive extended thinking (thinking.type: "enabled") — there, only auto and none are allowed, and forcing a specific tool returns an error. If you're targeting Claude Mythos Preview specifically, note it doesn't support forced tool use at all.

Approach 2: prefilling, and its modern replacement

The older trick for nudging Claude toward JSON was response prefilling: instead of forcing a tool call, you supply the start of the assistant's reply yourself — typically an opening { — as the last message in the array, with role: "assistant". Claude's completion then continues directly from that text, which both skips any "Here is the JSON you asked for:" preamble and biases the token-by-token generation toward continuing valid JSON.

⚠ Careful

Prefilling the final assistant turn is no longer supported starting with Claude 4.6 models and Claude Mythos Preview — a request that ends in a prefilled assistant message now returns a 400 error. That cutoff covers the current model generation this course uses (Sonnet 5, Opus 5, Fable 5, Mythos 5); only earlier models, such as the legacy Claude Sonnet 4.5, still accept it. Prefills earlier in a conversation (not the trailing turn) are unaffected — this deprecation is specifically about using prefill as a JSON-forcing trick on the latest response.

# Legacy pattern — only works on earlier model generations
# (e.g. claude-sonnet-4-5); returns a 400 error on Claude 4.6+ / Mythos.
message = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1000,
    messages=[
        {
            "role": "user",
            "content": (
                "Return the contact info below as JSON with keys name, "
                "email, company, role.\n\nPriya Nair, VP of Engineering "
                "at Fenwick Robotics, priya.nair@fenwickrobotics.com"
            ),
        },
        {"role": "assistant", "content": "{"},  # biases the continuation toward JSON
    ],
)

The official migration path for this exact use case — forcing an output format like JSON — is the Structured Outputs feature: set output_config.format to type: "json_schema" with your schema, and the entire message response is constrained to valid JSON matching it, no tool call involved.

contact_schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "email": {"type": "string", "format": "email"},
        "company": {"type": "string"},
        "role": {"type": "string"},
        "confidence": {"type": "number"}
    },
    "required": ["name", "email", "confidence"],
    "additionalProperties": False
}

message = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1000,
    output_config={"format": {"type": "json_schema", "schema": contact_schema}},
    messages=[
        {
            "role": "user",
            "content": (
                "Reach out to Priya Nair, she's the VP of Engineering at "
                "Fenwick Robotics. Her email is priya.nair@fenwickrobotics.com."
            ),
        }
    ],
)

text_block = next(block for block in message.content if block.type == "text")
contact = json.loads(text_block.text)  # guaranteed to match contact_schema

This shape isn't SDK-specific — the same output_config.format field works identically if you call the API directly over HTTP instead of through the Python or TypeScript SDKs. Both SDKs also expose a client.messages.parse() helper that takes a Pydantic model (Python) or Zod schema (TypeScript) directly and hands back a validated, typed object instead of a raw string you have to json.loads() yourself — worth reaching for once you're already defining schemas in application code.

Validate, then retry on failure

☺ Like you're 10: The form guarantees every blank is filled in with the right kind of answer — not that the answer makes sense. A permission slip can require a signature without checking whether you signed your own name or your dog's.

Strict tool schemas and json_schema output both guarantee type-correct, schema-shaped output — but the JSON Schema subset Claude supports is deliberately limited. It supports basic types, enum, const, anyOf/allOf, $ref/$def, and string formats like email or date-time. It does not support numeric constraints (minimum/maximum/multipleOf), string length constraints (minLength/maxLength), most array constraints, or recursive schemas. That confidence field from the extraction tool is guaranteed to be a number — but nothing stops Claude from returning 7.4 when your application only makes sense for values between 0.0 and 1.0. Business-rule validation still belongs in your code.

🎬 At the Claude Crew
🦊

Foxy: Wait — if the schema says confidence is a number, isn't Claude already keeping it honest?

🦉

Professor Owl: Honest about the type, yes. Claude can't hand you the string "7.4" or a whole paragraph instead of a number.

🦊

Foxy: But it could still hand back 7.4 and sail right through the schema check?

🦉

Professor Owl: Exactly. Every rule the schema can't express, your code still has to enforce.

🐦

Pip: Which is exactly why I still run mine through validate_contact() before I trust it — a clean shape isn't the same thing as a correct one.

When validation fails, feed the failure back to Claude as a tool_result with is_error: true and a description of what to fix, then let it try again — the same mechanism used for ordinary tool execution errors.

def validate_contact(contact: dict) -> None:
    if not (0.0 <= contact["confidence"] <= 1.0):
        raise ValueError("confidence must be between 0.0 and 1.0")

def extract_contact(text: str, max_attempts: int = 3) -> dict:
    messages = [{"role": "user", "content": text}]

    for attempt in range(max_attempts):
        message = client.messages.create(
            model="claude-sonnet-5",
            max_tokens=1000,
            tools=[extract_contact_tool],
            tool_choice={"type": "tool", "name": "extract_contact"},
            messages=messages,
        )
        tool_use_block = next(b for b in message.content if b.type == "tool_use")
        contact = tool_use_block.input

        try:
            validate_contact(contact)
            return contact
        except ValueError as e:
            # tool_result must immediately follow the tool_use turn, and
            # come before any text in that user message's content array.
            messages.append({"role": "assistant", "content": message.content})
            messages.append({
                "role": "user",
                "content": [{
                    "type": "tool_result",
                    "tool_use_id": tool_use_block.id,
                    "content": f"Invalid output: {e}. Call extract_contact again with a corrected value.",
                    "is_error": True,
                }],
            })

    raise RuntimeError(f"Could not extract a valid contact after {max_attempts} attempts")
⚠ Careful

Keep the ordering rules intact when you build a retry message: the assistant's response — tool_use block included — must be replayed verbatim, and the follow-up user message's tool_result block must come before any other content. Getting this wrong returns a 400 error, not a silently ignored retry.

Choosing an approach

Three techniques, three different trade-offs. Here's how they stack up side by side.

ApproachHow it worksGuaranteeCurrent model support
Extraction tool (strict + forced tool_choice)Define a single-purpose tool whose input_schema is your target shape; force Claude to call itGrammar-constrained sampling — tool name and input match your schema exactlySupported; fits naturally when extraction is one step in a larger tool-calling flow
JSON Outputs (output_config.format)Constrain the entire message response to a json_schema, no tool call involvedSame grammar-constrained guarantee, applied to plain text outputSupported; pair with messages.parse() for typed results
Response prefillingSupply a partial trailing assistant message (e.g. {) to bias the continuationBest-effort only — never a hard guarantee, even on models that accept itReturns a 400 error on Claude 4.6+ and Claude Mythos Preview; only earlier models like Sonnet 4.5 still accept it

In practice: reach for the extraction tool when structured output is one call inside a larger agentic loop that already uses tools, and reach for output_config.format when extraction is the entire point of the request and there's no reason to route it through a tool call at all. Skip prefilling for new work — it's a legacy pattern that current models don't accept.

✎ Try it yourself

Write a Python script that defines a strict extraction tool for parsing a support ticket into {"category": string enum of "billing"/"bug"/"feature_request", "priority": integer, "summary": string}. Feed it a paragraph of messy customer text, force the tool call, and add a validate_ticket() function that rejects any priority outside 1–5 (a constraint the schema itself can't express) and retries with an is_error tool_result until it passes or three attempts are exhausted.

🐦 Pip's checkpoint

You should now be able to explain why "just ask for JSON" drifts across runs, and pick the right fix: a strict extraction tool with a forced tool_choice when structured output is one step in a tool-calling flow, or output_config.format with type: "json_schema" when it's the whole point of the call. You should also know why prefilling is off the table on current models, and why schema conformance is a floor, not a ceiling — validate business rules yourself and retry on failure. Next up: see how caching a long schema or tool definition keeps repeated calls fast and cheap on prompt-caching.html.

Check your answers
  1. Why doesn't asking Claude for "JSON please" in plain English work reliably? Nothing about a plain-English request constrains the model at the token level, so the same prompt can drift across runs — a preamble sentence, a markdown code fence, renamed keys, or a number returned as a string instead of a real one.
  2. What's the difference between the extraction-tool approach and output_config.format? The extraction tool routes the request through a forced tool_choice and returns a tool_use block, which fits naturally into a larger tool-calling flow; output_config.format with type: "json_schema" constrains the entire message response directly, with no tool call involved.
  3. Why isn't schema conformance enough to trust the output? Claude's supported JSON Schema subset excludes numeric ranges, string length limits, most array constraints, and recursive schemas, so a schema-valid response can still break a business rule (like a confidence of 7.4) — validate the parsed result yourself and retry with an is_error tool_result on failure.