Lesson 4 - Structured Outputs You Can Trust

Welcome to Structured Outputs You Can Trust

So far every answer you’ve gotten back has been text — something a human reads. But the moment you want to build a program around a model, you need data: fields with known names and known types that your code can index into. The bridge between “text in a chat” and “data in a program” is the structured output.

The naive approach — adding “respond in JSON” to your prompt — works in a demo and fails in production. In this lesson you’ll see exactly how it breaks, then learn two methods that make the model’s output a reliable, schema-shaped object you can load straight into a Python dict.

By the end of this lesson, you will be able to:

  • Explain why free-form “respond in JSON” prompting is fragile
  • Define the exact shape you want as a JSON schema
  • Get guaranteed-valid structured output two reliable ways
  • Parse the result into a Python dict and use the fields in code

This lesson assumes you have the Anthropic SDK installed and ANTHROPIC_API_KEY set, as in Module 1. Let’s begin.


Why “Respond in JSON” Is Fragile

Here is the request everyone writes first: take a customer message and pull out a few fields, asking for JSON in plain English.

import anthropic
import json

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from your environment

message = (
    "Hi, this is Maria Gomez. My premium account got charged twice this month "
    "and I'm pretty frustrated. I already tried logging out and back in but the "
    "duplicate charge is still there. Can someone please fix this and refund me?"
)

prompt = (
    "Extract the customer's name, sentiment, topics, and whether action is "
    "required from this message. Respond in JSON.\n\n" + message
)

resp = client.messages.create(
    model="claude-haiku-4-5", max_tokens=400,
    messages=[{"role": "user", "content": prompt}],
)
raw = resp.content[0].text
print(raw)
print("\n--- json.loads(raw) ---")
print(json.loads(raw))
```json
{
  "customer_name": "Maria Gomez",
  "sentiment": "frustrated",
  "topics": [
    "duplicate charge",
    "premium account",
    "billing issue",
    "refund request"
  ],
  "action_required": true
}

— json.loads(raw) — JSONDecodeError: Expecting value: line 1 column 1 (char 0)


The content *looks* right, but your program just crashed — and look at everything that went wrong at once:

- **Markdown fences.** The model wrapped the JSON in a ```` ```json ```` code block, so `json.loads` chokes on the very first character (the backtick).
- **Invented field names.** You asked for `name`; it gave you `customer_name`. Your code that reads `data["name"]` would raise a `KeyError`.
- **Off-menu values.** You expected a sentiment label you could branch on; it returned the free-form word `"frustrated"`, not one of a fixed set you control.

None of these are bugs in the model. They happen because *you never told it the exact shape you wanted* — so it improvised, and improvisation is the enemy of a parser. You could patch each problem with string-stripping and renaming, but that's a brittle pile of guesses. The fix is to stop asking nicely and start **specifying the shape**.

---

## Define the Shape: A JSON Schema

A **JSON schema** is a small, machine-readable description of the object you want: which fields exist, what type each one is, which are required, and — crucially — which values are even allowed. Here is the shape for our customer-message task:

```python
schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "sentiment": {"type": "string",
                      "enum": ["positive", "neutral", "negative"]},
        "topics": {"type": "array", "items": {"type": "string"}},
        "action_required": {"type": "boolean"},
    },
    "required": ["name", "sentiment", "topics", "action_required"],
    "additionalProperties": False,
}

Read it field by field. name must be a string. sentiment must be a string and one of exactly three values — that enum is what turns “frustrated” into a clean "negative" you can branch on. topics is a list of strings. action_required is a true/false boolean. The required list says all four must be present, and additionalProperties: False forbids any extra surprise keys.

additionalProperties is required here

When you use the schema-based method below on claude-haiku-4-5, every object in your schema must explicitly set additionalProperties to false. Leave it out and the request is rejected with a 400 error telling you exactly that. It’s a small rule, but forgetting it is the most common first mistake.

This schema is now the contract. Both methods that follow take this same schema and make the model honor it.


Method 1: A Guaranteed JSON Schema Format

The most direct method is to hand the schema to the API as an output format. You pass an output_config telling the model the response must match your json_schema. The model then returns clean JSON text — no fences, no commentary — that conforms to the shape.

resp = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=400,
    messages=[{"role": "user",
               "content": f"Extract structured fields from this customer message:\n\n{message}"}],
    output_config={"format": {"type": "json_schema", "schema": schema}},
)

raw = resp.content[0].text
print("RAW text:", raw)

data = json.loads(raw)          # safe: it's pure JSON, no fences
print("PARSED dict:", data)
print("stop_reason:", resp.stop_reason)
RAW text: {"name": "Maria Gomez", "sentiment": "negative", "topics": ["billing", "duplicate charges", "refund request", "account issue", "troubleshooting"], "action_required": true}
PARSED dict: {'name': 'Maria Gomez', 'sentiment': 'negative', 'topics': ['billing', 'duplicate charges', 'refund request', 'account issue', 'troubleshooting'], 'action_required': True}
stop_reason: end_turn

Compare this to the fragile version. The output is bare JSONjson.loads(raw) works on the first try. The field is name, exactly as the schema demands. And sentiment is "negative", one of your three allowed values, not the model’s improvised “frustrated.” The shape you asked for is the shape you got.

Once it’s a dict, it’s just data:

print(data["name"])             # Maria Gomez
print(data["sentiment"])        # negative
print(len(data["topics"]), "topics")
if data["action_required"]:
    print("-> route to a human")

Method 2: A Forced Tool Call

There is a second, equally reliable method that’s worth knowing because it works everywhere tool use is supported and is the pattern most production extraction pipelines are built on: define a tool whose input_schema is your target shape, then force the model to call it.

Normally a tool is something the model chooses to call to do work. Here we hijack that machinery: we don’t actually run anything — we just want the validated arguments the model produces. By setting tool_choice to require our tool, the model is obliged to emit a tool_use block whose .input is a dict matching the schema.

extract_tool = {
    "name": "save_customer_fields",
    "description": "Save the structured fields extracted from a customer message.",
    "input_schema": schema,          # the SAME schema as before
}

resp = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=400,
    tools=[extract_tool],
    tool_choice={"type": "tool", "name": "save_customer_fields"},
    messages=[{"role": "user",
               "content": f"Extract the fields from this customer message:\n\n{message}"}],
)

print("stop_reason:", resp.stop_reason)

# Find the tool_use block and read its already-parsed .input dict
tool_block = next(b for b in resp.content if b.type == "tool_use")
data = tool_block.input
print("PARSED dict:", data)
print("type:", type(data).__name__)
stop_reason: tool_use
PARSED dict: {'name': 'Maria Gomez', 'sentiment': 'negative', 'topics': ['billing', 'duplicate charge', 'account issue', 'refund request'], 'action_required': True}
type: dict

Two things to notice. First, stop_reason is tool_use, your signal that the model produced a tool call rather than plain text. Second — and this is the convenience — tool_block.input is already a Python dict. There’s no json.loads step at all; the SDK parsed and validated it against the tool’s input_schema for you. You read data["name"] exactly as before.

Which method should you reach for?

Use the json_schema output format (Method 1) when you simply want one structured object back — it’s the cleanest expression of intent and returns ready-to-parse JSON text. Reach for the forced tool call (Method 2) when you’re already working with tools, want the SDK to hand you a parsed dict directly, or need the broadest compatibility. Both were verified on claude-haiku-4-5; pick by ergonomics, not reliability — they’re equally trustworthy.


Parse Once, Use Everywhere

Whichever method you choose, you end up with the same thing: a plain dict that obeys a contract you wrote. That’s the whole point — your downstream code no longer cares how the text was generated, only that the fields are there and the types are right.

def handle_ticket(data):
    print(f"Customer: {data['name']}")
    print(f"Mood:     {data['sentiment']}")
    print(f"About:    {', '.join(data['topics'])}")
    if data["sentiment"] == "negative" and data["action_required"]:
        print("PRIORITY: escalate to a support lead")
    else:
        print("Standard queue")

handle_ticket(data)
Customer: Maria Gomez
Mood:     negative
About:    billing, duplicate charge, account issue, refund request
PRIORITY: escalate to a support lead

Because sentiment is guaranteed to be one of three known strings, the == "negative" branch is safe — it can’t silently miss because the model said “frustrated” this time and “annoyed” the next. This is what “structured output you can trust” buys you: the model becomes a dependable component, and the rest of your program can be ordinary, boring, correct code.


Practice Exercises

Exercise 1: Reproduce the fragility

Run the very first example — “respond in JSON” with no schema — three or four times. Note how the field names, the markdown fences, and the sentiment wording drift between runs.

Hint

You don’t need to fix anything here — the goal is to feel the unreliability. Try wrapping json.loads(raw) in a try/except and count how often it fails or returns keys you didn’t expect. That instability is exactly what the schema methods remove.

Exercise 2: Extend the schema

Add a new field to the schema: urgency, a string restricted to "low", "medium", or "high". Re-run Method 1 and confirm the model only ever returns one of those three values.

Hint

Add "urgency": {"type": "string", "enum": ["low", "medium", "high"]} to properties, append "urgency" to required, and keep additionalProperties set to False. The enum is what guarantees the value is always one you can branch on.

Exercise 3: Build an extraction loop

Make a list of three different short customer messages. Loop over them, run Method 2 (the forced tool call) on each, collect the parsed dicts into a Python list, and print how many had action_required == True.

Hint

Because tool_block.input is already a dict, you can results.append(tool_block.input) directly with no parsing step. Then sum(1 for r in results if r["action_required"]) counts the tickets that need a human.


Summary

Free-form “respond in JSON” prompting is fragile: the model wraps output in markdown fences, invents its own field names, and returns off-menu values — any one of which breaks your parser. The reliable fix is to define the exact shape as a JSON schema and let the API enforce it. On claude-haiku-4-5 you have two trustworthy methods: a json_schema output format that returns bare, parseable JSON text, and a forced tool call whose .input is already a validated Python dict. Either way you cross the bridge from “text in a chat” to “data in a program.”

Key Concepts

  • Structured output — model output shaped as named, typed fields your code can index, not free-form prose.
  • JSON schema — a machine-readable contract describing the fields, types, required keys, and allowed values you want.
  • enum — a schema constraint that restricts a field to a fixed set of values, making it safe to branch on.
  • json_schema output format — passing output_config so the response must match your schema; returns clean JSON text.
  • Forced tool call — defining a tool whose input_schema is your shape and using tool_choice to require it; .input is a parsed dict.

Why This Matters

Every LLM-powered application — ticket routers, data extractors, agents — depends on turning a model’s words into fields a program can act on. Schema-enforced output is what makes that boundary dependable: it lets you treat the model as a reliable component and write ordinary, correct code around it, instead of an ever-growing pile of string-cleaning patches.


Next Steps

Continue to Lesson 5 - Prompting for Data Tasks

Apply prompting and structured outputs to real data work — classifying, extracting, and transforming records at scale.

Back to Module Overview

Return to the Prompt Engineering module overview


Continue Building Your Skills

You can now make a model produce data instead of prose — a contract-shaped object you parse once and trust everywhere. Next you’ll put that skill to work on real data tasks, using prompts and schemas together to classify, extract, and transform records the way a data pipeline needs.