Back to KB
Difficulty
Intermediate
Read Time
6 min

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

By Codcompass TeamΒ·Β·6 min read

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)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-nodejs-version)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)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-cant-reach-raw-apianthropiccom-endraw-)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](https://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.

## [](#tldr)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._