DEV Community

Cover image for Guaranteed JSON Every Time: Using Claude's Structured Outputs with JSON Schema
Claude API
Claude API

Posted on • Originally published at claudeapi.com

Guaranteed JSON Every Time: Using Claude's Structured Outputs with JSON Schema

If you've ever tried to get structured data out of an LLM with a prompt like "Please respond in valid JSON with the following fields...", you already know the story. It works 95% of the time. The other 5% it returns prose, fenced markdown, a trailing apology, or — my personal favorite — { "name": "Alice", "age": 30, } with a trailing comma that explodes your parser at 3 AM.

This guide walks through a technique that makes Claude return JSON that conforms to your schema on every single call, with no regex parsing and no retry loops. It works because we're not really asking Claude for JSON — we're tricking it into a code path that produces JSON as a side effect.

About this guide. I'm one of the maintainers of claudeapi.com, a third-party Claude API gateway. All code below targets the official Anthropic endpoint (api.anthropic.com); a comparison of alternative gateways for developers in restricted regions appears near the end. Skip it if you're on the official endpoint and it works fine for you.

The trick: hijack tool use

Claude's tool_use feature was designed so the model can call your functions. Under the hood, when you define a tool, you give Claude a JSON Schema for its arguments. The model is fine-tuned to produce arguments that match that schema, validated server-side before being returned to you.

So the trick is: define a fake tool whose input schema is the structure you want, then force Claude to call it. You never actually execute the "tool" — you just read its arguments. That's your guaranteed JSON.

Example 1: basic product extraction

import anthropic
import json

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

product_schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "price_usd": {"type": "number"},
        "in_stock": {"type": "boolean"},
        "tags": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["name", "price_usd", "in_stock"],
}

response = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=1024,
    tools=[{
        "name": "extract_product",
        "description": "Extract structured product information from text.",
        "input_schema": product_schema,
    }],
    tool_choice={"type": "tool", "name": "extract_product"},  # forces the call
    messages=[{
        "role": "user",
        "content": "The new SoundCore Mini 3 is $39.99, currently in stock, available in black and red.",
    }],
)

# The structured data is in the tool_use block's input field
for block in response.content:
    if block.type == "tool_use":
        data = block.input
        print(json.dumps(data, indent=2))
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "name": "SoundCore Mini 3",
  "price_usd": 39.99,
  "in_stock": true,
  "tags": ["black", "red"]
}
Enter fullscreen mode Exit fullscreen mode

The key line is tool_choice={"type": "tool", "name": "extract_product"}. Without it, Claude might decide it doesn't need a tool and just chat back. With it, the model must emit a tool_use block matching your schema.

Example 2: nested structures with enums

Real-world schemas have nested objects, arrays of objects, and constrained values. JSON Schema handles all of it.

order_schema = {
    "type": "object",
    "properties": {
        "order_id": {"type": "string"},
        "status": {
            "type": "string",
            "enum": ["pending", "shipped", "delivered", "cancelled"],
        },
        "customer": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "email": {"type": "string", "format": "email"},
            },
            "required": ["name", "email"],
        },
        "items": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "sku": {"type": "string"},
                    "quantity": {"type": "integer", "minimum": 1},
                    "unit_price": {"type": "number"},
                },
                "required": ["sku", "quantity", "unit_price"],
            },
        },
    },
    "required": ["order_id", "status", "customer", "items"],
}

response = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=2048,
    tools=[{
        "name": "parse_order",
        "description": "Parse an order from a free-text customer email.",
        "input_schema": order_schema,
    }],
    tool_choice={"type": "tool", "name": "parse_order"},
    messages=[{
        "role": "user",
        "content": """
        Hi, I'm Jane Doe (jane@example.com). Following up on order #A-7821 —
        it shows as shipped but tracking hasn't updated in 4 days. The order
        was 2x SKU-BLK-001 at $19.99 each and 1x SKU-RED-042 at $34.50.
        """,
    }],
)
Enter fullscreen mode Exit fullscreen mode

The enum constraint guarantees status will be one of the four values you specified — Claude can't return "in_transit" even if it would be more semantically accurate. Treat enums as a contract.

Example 3: resume parsing (the messy real world)

resume_schema = {
    "type": "object",
    "properties": {
        "full_name": {"type": "string"},
        "email": {"type": ["string", "null"]},
        "years_experience": {"type": ["integer", "null"]},
        "skills": {"type": "array", "items": {"type": "string"}},
        "education": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "institution": {"type": "string"},
                    "degree": {"type": "string"},
                    "year": {"type": ["integer", "null"]},
                },
            },
        },
    },
    "required": ["full_name", "skills", "education"],
}

resume_text = """
JOHN SMITH
johnsmith@email.com

EXPERIENCE
Backend Engineer, Acme Corp (2019 - present)
Junior Developer, BetaStart (2016 - 2019)

SKILLS: Python, Go, PostgreSQL, Redis, Kubernetes

EDUCATION
B.S. Computer Science, State University, 2016
"""

response = client.messages.create(
    model="claude-opus-4-7",
    max_tokens=2048,
    tools=[{
        "name": "extract_resume",
        "description": "Extract structured fields from a free-form resume.",
        "input_schema": resume_schema,
    }],
    tool_choice={"type": "tool", "name": "extract_resume"},
    messages=[{"role": "user", "content": resume_text}],
)

data = next(b.input for b in response.content if b.type == "tool_use")
print(json.dumps(data, indent=2))
Enter fullscreen mode Exit fullscreen mode

Note the ["string", "null"] union type — that's how you tell Claude "this field might not be present in the source." It's much more reliable than asking Claude to "omit if missing."

TypeScript / Node.js version

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

interface Product {
  name: string;
  price_usd: number;
  in_stock: boolean;
  tags: string[];
}

const productSchema = {
  type: "object",
  properties: {
    name: { type: "string" },
    price_usd: { type: "number" },
    in_stock: { type: "boolean" },
    tags: { type: "array", items: { type: "string" } },
  },
  required: ["name", "price_usd", "in_stock"],
} as const;

const response = await client.messages.create({
  model: "claude-opus-4-7",
  max_tokens: 1024,
  tools: [{
    name: "extract_product",
    description: "Extract structured product information from text.",
    input_schema: productSchema,
  }],
  tool_choice: { type: "tool", name: "extract_product" },
  messages: [{
    role: "user",
    content: "The new SoundCore Mini 3 is $39.99, currently in stock...",
  }],
});

const toolUse = response.content.find((b) => b.type === "tool_use");
const product = toolUse?.input as Product;
Enter fullscreen mode Exit fullscreen mode

Gotchas worth knowing

max_tokens truncation. If Claude's output gets cut off mid-JSON, you get stop_reason: "max_tokens" and a partial structure. Bump max_tokens higher than you think you need; large schemas with arrays can blow past 2048 quickly.

Field name mismatch. Claude follows your schema field names exactly. If your downstream code expects price but your schema says price_usd, you'll silently get None in production. The schema is the contract — write it once, share it both directions.

Optional fields. JSON Schema's "optional" is the absence of a field from required. To say "this field might exist but might be null," use "type": ["string", "null"]. Be explicit; Claude will respect either form, but mixing them in the same schema is a recipe for surprises.

Don't try to validate semantics in the schema. A regex pattern will be respected, but complex constraints (e.g. "year must be after 1900 and before 2030") are better enforced after the fact. Claude usually honors them, but JSON Schema's validation surface is large and not all of it is equally reliable.

When you can't reach api.anthropic.com

This is the part most "guaranteed JSON" guides skip. Claude's API isn't available in every region. If you're hitting connection issues or 403s from your deployment region, you have a few options:

Option Best for Tradeoff
Self-hosted proxy (Cloudflare Workers, Vercel Edge) DIY developers in mildly restricted regions You own the infra; latency depends on your edge config
AWS Bedrock Teams already on AWS, enterprise compliance Different SDK, different model IDs, no day-one access to new releases
GCP Vertex AI Teams on GCP, EU data residency Same as Bedrock — SDK divergence
claudeapi.com China / SEA / regions with payment friction Third-party gateway; pay in USD via card or local methods. Disclosure: I'm affiliated.
OpenRouter Multi-provider testing Adds a hop; pricing markup varies by model

All of these expose an Anthropic-compatible interface, so the code above runs unchanged — only the base_url differs. If api.anthropic.com works for you, stay there; the official endpoint will always have the lowest latency and the fewest hops.

TL;DR

  • Define a JSON Schema for the structure you want
  • Pass it as the input_schema of a "fake" tool
  • Force Claude to call that tool with tool_choice={"type": "tool", "name": "..."}
  • Read tool_use.input for guaranteed-valid JSON — no parsing, no retries

That's the whole pattern. Once it clicks, you'll stop reaching for regex on LLM output.


If this was useful, the same pattern extends naturally to chained tool calls and agent workflows — happy to dig into either if there's interest in the comments.

Top comments (0)