When your code has to consume the model’s answer, free text is a liability. Force valid JSON, define the shape, and validate it before anything downstream touches it.
Why: if your program parses the reply, "Sure! Here is the JSON:" followed by a code fence will crash json.loads. When: any time the output feeds another function, database, or API, you need a guaranteed shape, not prose. Where: this is the single most common source of flaky LLM integrations.
Asking politely for JSON is not enough:
"Sure! Here's the data you asked for:
\`\`\`json
{ \"name\": \"Sam\" }
\`\`\`
Let me know if you need anything else!"
json.loads(...) -> JSONDecodeError. You need to FORCE the shape.Why: the most reliable way to get structured output from Claude is to define a tool whose input_schema is your desired shape, then require the model to call it — the API guarantees the arguments match the schema. When: use tool_choice to force the specific tool so the model cannot answer in prose. Where: the parsed object arrives in the tool_use block’s input.
tool = {
"name": "record_person",
"description": "Record structured details about a person.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"skills": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "age", "skills"],
},
}
response = client.messages.create(
model="claude-opus-4-8", max_tokens=512,
tools=[tool],
tool_choice={"type": "tool", "name": "record_person"}, # force it
messages=[{"role": "user",
"content": "Sam is 29 and knows Python, SQL, and Docker."}],
)
data = response.content[0].input # already a dict matching the schema
print(data) # {'name': 'Sam', 'age': 29, 'skills': ['Python', 'SQL', 'Docker']}Why: the shape is guaranteed but the values are not — the model can still invent an age or miscategorize a skill, so validate with Pydantic and handle failures. When: parse into a typed model at the boundary so the rest of your code works with real objects, not loose dicts. Where: this is the same discipline you apply to any untrusted input.
from pydantic import BaseModel, field_validator
class Person(BaseModel):
name: str
age: int
skills: list[str]
@field_validator("age")
@classmethod
def sane_age(cls, v):
if not 0 < v < 130:
raise ValueError("age out of range")
return v
person = Person(**data) # raises if the model returned something wrong
print(person.name, person.skills)Why: OpenAI and Gemini expose a response_format parameter that pins the output to a JSON Schema directly, without the tool indirection. When: prefer the native option on providers that have it — it is simpler than the tool trick. Where: the concept is identical everywhere: constrain the decoder to a schema, then validate the values.
# OpenAI structured outputs — response_format with a JSON schema.
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Sam is 29, knows Python and SQL."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "person",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"skills": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "age", "skills"],
"additionalProperties": False,
},
},
},
)
print(resp.choices[0].message.content) # a JSON string matching the schema