Lesson 06 Structured Outputs & JSON Mode
Theme:
Lesson 06 · Slide 1

Structured Outputs & JSON Mode

Real applications need predictable, parseable data — not free-form text. Learn three increasingly robust ways to get structured output from Claude every time.

Learning Objectives

Lesson 06 · Slide 2

Why Structure Matters

Downstream code assumes a contract. When an LLM breaks it, the pipeline breaks too.

The Problem

  • LLMs are nondeterministic — same prompt can produce different formats
  • Models may add prose, apologise, add markdown fences around JSON
  • Hallucinated keys or wrong types cause silent downstream failures

The Breakage

import json
data = json.loads(response)
# JSONDecodeError: Expecting value: line 1
# column 1 (char 0)
# because Claude said:
# "Sure! Here is the JSON: ..."

Three approaches exist, each with different trade-offs between simplicity, reliability, and cost.

Lesson 06 · Slide 3

Approach 1: Prompt-Based JSON

Instruct Claude explicitly to return only JSON. Fast and cheap — but not guaranteed.

import json

def extract_prompt_based(text: str) -> dict:
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=500,
        messages=[{
            "role": "user",
            "content": (
                "Extract the following fields from the job description below. "
                "Respond with ONLY valid JSON — no markdown, no preamble.\n\n"
                "Schema: {\"title\": str, \"company\": str, \"location\": str, "
                "\"salary_range\": str|null, \"remote\": bool, \"requirements\": [str]}\n\n"
                f"Job description:\n{text}"
            )
        }]
    )
    raw = response.content[0].text.strip()
    # Strip markdown fences if Claude added them anyway
    if raw.startswith("```"):
        raw = raw.split("```")[1]
        if raw.startswith("json"):
            raw = raw[4:]
    return json.loads(raw)

Failure rate: roughly 5–15% depending on model and prompt complexity. Always wrap in a try/except and have a fallback strategy.

Lesson 06 · Slide 4

Approach 2: Tool Use for Structure

Define your schema as a tool input schema. Claude must call the tool — the API guarantees the output matches the schema.

EXTRACT_TOOL = {
    "name": "extract_job_posting",
    "description": "Extract structured job posting data from raw text.",
    "input_schema": {
        "type": "object",
        "properties": {
            "title":        {"type": "string"},
            "company":      {"type": "string"},
            "location":     {"type": "string"},
            "salary_range": {"type": ["string", "null"]},
            "remote":       {"type": "boolean"},
            "requirements": {"type": "array", "items": {"type": "string"}}
        },
        "required": ["title", "company", "location", "remote", "requirements"]
    }
}

response = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=500,
    tools=[EXTRACT_TOOL],
    tool_choice={"type": "tool", "name": "extract_job_posting"},
    messages=[{"role": "user", "content": f"Extract job data:\n\n{job_text}"}]
)
# response.content[0] is always a ToolUseBlock with .input as a dict
data = response.content[0].input

Reliability: near 100%. If a required field is missing from the input text, Claude fills it with a reasonable default or null rather than omitting the key.

Lesson 06 · Slide 5

Pydantic v2 Models

Once you have a dict from Claude, validate it with Pydantic. This catches type mismatches, missing required fields, and lets you add custom validators.

from pydantic import BaseModel, field_validator

class JobPosting(BaseModel):
    title: str
    company: str
    location: str
    salary_range: str | None = None
    remote: bool
    requirements: list[str]

    @field_validator('requirements')
    @classmethod
    def non_empty_requirements(cls, v: list[str]) -> list[str]:
        if len(v) == 0:
            raise ValueError('requirements list must not be empty')
        return v

# Validate data from either extraction approach:
try:
    posting = JobPosting.model_validate(data)
    print(posting.model_dump())
except ValidationError as e:
    print(f"Validation failed: {e}")

Tip: model_validate() accepts a plain dict. Use model_validate_json() to validate directly from a JSON string — it's faster and avoids the intermediate parse step.

Lesson 06 · Slide 6

Retry on ValidationError

When validation fails, tell Claude exactly what went wrong and ask it to fix the output. This recovers from ~80% of failures in practice.

from pydantic import ValidationError
import json

def extract_with_retry(text: str, max_retries: int = 3) -> JobPosting:
    messages = [{
        "role": "user",
        "content": f"Extract job data as JSON matching the JobPosting schema:\n\n{text}"
    }]

    for attempt in range(max_retries):
        response = client.messages.create(
            model="claude-haiku-4-5", max_tokens=500,
            messages=messages
        )
        raw = response.content[0].text.strip()

        try:
            data = json.loads(raw)
            return JobPosting.model_validate(data)
        except (json.JSONDecodeError, ValidationError) as e:
            if attempt == max_retries - 1:
                raise
            # Feed the error back to Claude
            messages.append({"role": "assistant", "content": raw})
            messages.append({"role": "user",
                "content": f"That response had errors: {e}\nPlease fix and return valid JSON only."})

    raise RuntimeError("Exhausted retries")
Lesson 06 · Slide 7

Demo

Live extraction of job postings from raw text using both approaches.

Demo Steps

  1. Paste a real job description into the terminal
  2. Run prompt-based extraction — show a clean parse and then trigger a failure by adding "Here is the JSON:" preamble text manually
  3. Run tool-use extraction on the same text — observe the guaranteed structure in the response
  4. Intentionally pass a bad input (e.g., "Looking for someone great!") — watch the retry loop fix the output
  5. Compare: reliability, token count, and latency between the two approaches
Notes: Extract a JobPosting from a real job ad, show prompt-based vs tool-use. Emphasise that tool-use always gives you a dict with the right keys — the API enforces it.
Lesson 06 · Slide 8

Lab & Homework

Lab Exercises

  1. Prompt-Based — define JobPosting as a Pydantic model; implement extract_prompt_based(text) and test on 3 provided job descriptions
  2. Tool-Based — define EXTRACT_TOOL with the full JSON schema; implement extract_tool_based(text); compare reliability vs Exercise 1
  3. Retry Loop — implement extract_with_retry(text, max_retries=3) that feeds ValidationError back to Claude; test on deliberately ambiguous input

Homework Tasks

  1. Build a structured extractor for your own domain (invoice, meeting notes, bug report, etc.). Define a Pydantic model with 5+ fields. Test on 5 real examples.
  2. Add retry logic that sends the ValidationError back to Claude. Test on 3 deliberately tricky inputs — does Claude fix the errors? Document your findings.
  3. Reliability test: run prompt-based vs tool-use on 20 samples each. Tally failure rate (JSON parse error or Pydantic validation error). Document which approach is more reliable and by how much.
1 / ?