Real applications need predictable, parseable data — not free-form text. Learn three increasingly robust ways to get structured output from Claude every time.
Downstream code assumes a contract. When an LLM breaks it, the pipeline breaks too.
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.
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.
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.
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.
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")
Live extraction of job postings from raw text using both approaches.
JobPosting as a Pydantic model; implement extract_prompt_based(text) and test on 3 provided job descriptionsEXTRACT_TOOL with the full JSON schema; implement extract_tool_based(text); compare reliability vs Exercise 1extract_with_retry(text, max_retries=3) that feeds ValidationError back to Claude; test on deliberately ambiguous inputValidationError back to Claude. Test on 3 deliberately tricky inputs — does Claude fix the errors? Document your findings.