Typed outputs with Pydantic
Design Pydantic schemas models fill in well, get typed data with messages.parse or strict tools, and validate the values against real records with business rules in code.
Words in this lessonStructured output · Pydantic · JSON Schema · Strict tool use · Constrained decoding
- Structured output
- An AI model's reply forced to follow a fixed shape you choose, so code can read it reliably. Like handing a customer a printed form instead of asking them to write a letter.
- Pydantic
- A Python library that checks and converts incoming data to match a model you define. Like a receiving clerk who checks every delivery against the order sheet and sends back the wrong ones.
- JSON Schema
- A standard, machine-readable description of what valid JSON looks like. Like a blank order form that shows exactly which boxes must be filled and how.
- Strict tool use
- A tool setting that guarantees the model’s arguments exactly match the tool’s schema. Like an order form the kitchen will only accept when every box is filled in correctly.
- Constrained decoding
- Steering a model while it generates so it can only produce tokens that keep the output valid. Like a keypad that only lights up the buttons you are allowed to press next.
On this page
- Arjun's first attempt, and the accountant's verdict
- The accounts department's form
- Following one refund email to a spreadsheet row
- Step 1: print the form
- Step 2: see the form the way the model sees it
- Step 3: fill it in
- Step 4: check the values against reality
- Step 5: the row Mr. Deshpande sees
- Opening the hood: three routes to a typed output
- Designing forms the model fills in well
- Running all 63, and what happened
- The same contract on OpenAI
- Where it still breaks
- Try it yourself
- Common mistakes
- Explaining it to Meera
- Recap
- Go deeper
The monsoon week left Crumb with 63 refund requests: cakes that arrived late, boxes that arrived squashed, one hamper delivered to the wrong building in Baner. Meera wanted every one reviewed and in the accounts team’s spreadsheet by Wednesday. The accountant, Mr. Deshpande, has run Crumb’s books for nineteen years and does not tolerate untidy columns.
Arjun had a script ready by Tuesday morning. It sent each refund email to Claude with the prompt “Extract the order id, refund amount, reason and date as JSON”, collected the answers and wrote a CSV file. He emailed it to Mr. Deshpande at 11.
At 11:20 the spreadsheet’s import tool rejected the file. Row 14’s amount read "Rs. 450/- (approx)". Row 22’s was "full refund". Row 31’s date was "last Tuesday". Rows 40 and 41 gave the reason as "cake was sad" and "Damaged", while row 42 said "damaged_item". And row 57 had no amount column at all.
Mr. Deshpande’s reply was one sentence: “A number column contains numbers.”
Important
The idea in one sentence: A typed output is a contract: define it once as a Pydantic model, let the API enforce it (structured outputs for data, strict tools for actions), then still check the values against your real records, because the right type is not the same as the right answer.
Arjun’s first attempt, and the accountant’s verdict
Here is the heart of Arjun’s Tuesday script:
PROMPT = "Extract the order id, refund amount, reason and date from this email as JSON.\n\n"
response = client.messages.create(model="claude-opus-5-5", max_tokens=2000,
messages=[{"role": "user", "content": PROMPT + email}])
data = json.loads(next(b.text for b in response.content if b.type == "text"))
You will recognise this from Module 05’s JSON lesson: rung 1 of the reliability ladder, with a crash waiting to happen. But the crash was not the real problem. Every reply here parsed as JSON. The trouble was that “amount” meant something different in each one. The model faithfully extracted what customers wrote, and customers write “Rs. 450/- (approx)”, “full refund” and “last Tuesday”.
That is the gap between structured and typed. Structured means “it’s JSON with the right keys”. Typed means every value is also the right kind of thing: a number where a number goes, a calendar date where a date goes, one of five known reasons where a reason goes. Mr. Deshpande needs typed.
Arjun’s second idea came from an older tutorial: force the model to call a record_refund tool, since tool arguments come back as JSON. He tried tool_choice={"type": "tool", "name": "record_refund"} and got the same 400 error as in the previous-but-one lesson: forced tool choice is not supported on claude-opus-5-5. “Good,” said Priya. “That trick was always a workaround. Let’s use the thing it was working around.”
The accounts department’s form
“Have you seen the refund form Mr. Deshpande gives the store managers?” Priya asked.
Arjun had. “Order number box. Amount box with a little ₹ sign printed in it. Date box with DD/MM/YYYY. And a row of tick boxes for the reason: late, damaged, wrong item, quality, other.”
“Why tick boxes for the reason, and not a line to write on?”
“Because then you can count them. Twelve damaged, thirty late.”
“Why the ₹ sign printed in the amount box?”
“So people write just the number.”
“And if a customer says ‘some money back, whatever’s fair’?”
Arjun thought. “The manager can’t write a number. They’d leave it blank and write a note.”
“So the form needs a way to say ‘unknown’. That’s the whole design. A type is the kind of value a box accepts: number, date, one-of-these-choices, text. A good form makes wrong values impossible to write and missing values possible to admit. Pydantic is how you print the form. Structured outputs is how you make the model fill it in with a pen that physically can’t write outside the boxes.”
Where the analogy breaks: a manager filling in a form knows the customer and can phone them. The model only has the email. If the email is ambiguous, the best the model can do is say so, which is why “unknown” and “needs review” have to be legal answers.
Following one refund email to a spreadsheet row
Here is the email from row 14, the one that broke the import:
“Hello, order CR-3307 (chocolate truffle cake, ordered for 9th Nov) arrived nearly 2 hours late and the top was completely squashed because of the rain. I paid Rs. 480 and would like Rs. 450/- back approx, keep the delivery charge. Thanks, Kavita”
Step 1: print the form
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field
class RefundDecision(BaseModel):
order_id: str | None = Field(description="Order id like CR-1042, or null if not given")
reasons: list[Literal["late_delivery", "damaged_item", "wrong_item", "quality", "other"]]
evidence: str = Field(description="Quote the customer's own words that support the reasons")
requested_amount_inr: float | None = Field(
description="Amount the customer asks for, in rupees, as a plain number. Null if not stated")
order_date: date | None = Field(description="Date the order was for, if stated")
needs_human_review: bool = Field(
description="True if the request is ambiguous, angry, or unusual in any way")
Each line is a box on the form. Literal[...] is the tick boxes. float | None is the ₹ box with permission to leave it blank. date is the DD/MM/YYYY box. list[...] allows more than one reason, because this cake was both late and damaged (Module 05’s lesson on schemas that throw information away).
Step 2: see the form the way the model sees it
Pydantic converts the class into a JSON Schema, the standard description of the shape that gets sent to the API:
print(json.dumps(RefundDecision.model_json_schema(), indent=2))
An excerpt:
"requested_amount_inr": {
"anyOf": [{"type": "number"}, {"type": "null"}],
"description": "Amount the customer asks for, in rupees, as a plain number. Null if not stated"
},
"order_date": {
"anyOf": [{"type": "string", "format": "date"}, {"type": "null"}], ...
},
"reasons": {
"type": "array",
"items": {"enum": ["late_delivery", "damaged_item", "wrong_item", "quality", "other"], "type": "string"}
},
"required": ["order_id", "reasons", "evidence", "requested_amount_inr", "order_date", "needs_human_review"]
Everything the model will know about the form is in there, including your description texts. They are not comments for humans; they are instructions for the model, one per box.
Step 3: fill it in
response = client.messages.parse(
model="claude-opus-5-5",
max_tokens=2000,
system="You review refund requests for Crumb & Co., a bakery in Pune. Today is 12 November 2026.",
messages=[{"role": "user", "content": email}],
output_format=RefundDecision,
)
decision = response.parsed_output
print(decision.model_dump_json(indent=2))
Output from Arjun’s run:
{
"order_id": "CR-3307",
"reasons": ["late_delivery", "damaged_item"],
"evidence": "arrived nearly 2 hours late and the top was completely squashed",
"requested_amount_inr": 450.0,
"order_date": "2026-11-09",
"needs_human_review": false
}
Look at what the model did inside the boxes. “Rs. 450/- back approx” became 450.0. “9th Nov” became 2026-11-09, with the year inferred from today’s date in the system prompt (without it, the model would have to guess the year). Two reasons were ticked. The call used about 700 input tokens (the schema counts as input) and about 250 output tokens: roughly $0.0078, or ₹0.69.
Step 4: check the values against reality
The form is filled in neatly. Is it right? Crumb’s order database says CR-3307 was a ₹480 order, of which ₹40 was delivery. The customer asked for ₹450 “keeping the delivery charge”, but ₹480 minus ₹40 is ₹440. The model extracted what she said, correctly. Her arithmetic was slightly off.
No schema can catch that, because it is not a shape problem. It is a business rule, so it lives in your code:
ORDERS = {"CR-3307": {"total_inr": 480, "delivery_inr": 40, "date": date(2026, 11, 9)}}
def check(decision: RefundDecision) -> list[str]:
"""Business rules the schema cannot know. Returns a list of problems (empty means OK)."""
problems = []
order = ORDERS.get(decision.order_id or "")
if order is None:
problems.append("order id not found in our records")
return problems
max_refund = order["total_inr"] - order["delivery_inr"]
if decision.requested_amount_inr is None:
problems.append("no amount stated")
elif decision.requested_amount_inr > max_refund:
problems.append(f"asks for ₹{decision.requested_amount_inr:.0f}, product value is ₹{max_refund}")
if decision.order_date and decision.order_date != order["date"]:
problems.append("order date does not match our records")
return problems
print(check(decision)) # ['asks for ₹450, product value is ₹440']
Step 5: the row Mr. Deshpande sees
order_id,reasons,amount_inr,order_date,status,notes
CR-3307,late_delivery;damaged_item,440.00,09/11/2026,approve_adjusted,"asks for ₹450, product value is ₹440"
A number in the number column. A real date. Reasons from a fixed list. And a note explaining the ₹10 difference, which Mr. Deshpande approved without comment, the highest praise he gives.
Note
Pause and think. Row 22 said “I want a full refund” without a number. With this model, what does the model return for requested_amount_inr, and how should your code turn that into a row?
Show the answer
The model should return null: no amount was stated, and the description says “Null if not stated”. It should not invent a figure, and because None is a legal value it has no pressure to. Your code then decides what “full refund” means using real data: look up the order and use the product value (₹440 for an order like CR-3307), or mark the row for review. The model’s job is to report what the customer said; turning “full” into rupees is a rule that belongs in code, where it is consistent across all 63 requests.
Opening the hood: three routes to a typed output
There are three ways to get typed data out of a model. You have now seen pieces of all three; here they are side by side.
Route 1: structured outputs (messages.parse). You pass a Pydantic class as output_format. The SDK sends its JSON Schema, the API uses constrained decoding (it only lets the model choose tokens that keep the output valid against the schema, as you saw in Module 05), and the SDK validates the result into your class. Use this when you want data back: extraction, classification, decisions.
Route 2: strict tool use. You define a tool and set "strict": True. Whenever the model chooses to call that tool, the arguments are guaranteed to match its schema. Use this when you want the model to take an action with typed arguments, and when it should decide whether to act at all.
Route 3: prompt, parse, validate, repair. Describe the format in the prompt, parse defensively, validate with Pydantic, and send errors back for one retry (Module 05’s ladder). Use this when your model or provider has no structured outputs, such as some local models in Module 20.
flowchart TD
A["I need typed output"] --> B{"Is the model choosing an action?"}
B -->|"No, I want data back"| C{"Provider supports structured outputs?"}
B -->|"Yes, it may call a tool"| D["Strict tool: strict true"]
C -->|"Yes"| E["messages.parse with output_format"]
C -->|"No"| F["Prompt, parse, validate, repair"]
Arjun’s refund pipeline is Route 1: every email must produce a decision, and nothing about it is optional or an action. If, later, the support chat should be able to file a refund request when a customer asks for one mid-conversation, that is Route 2, a tool the model may or may not call.
A strict tool can reuse the same Pydantic class, so the form is printed once and used everywhere:
from pydantic import ConfigDict
class RefundRequest(BaseModel):
model_config = ConfigDict(extra="forbid") # becomes "additionalProperties": false in the schema
order_id: str = Field(description="Order id like CR-1042")
reasons: list[Literal["late_delivery", "damaged_item", "wrong_item", "quality", "other"]]
requested_amount_inr: float | None = Field(description="Amount in rupees, or null if not stated")
FILE_REFUND_TOOL = {
"name": "file_refund_request",
"description": "File a refund request for human review when a customer asks for their money back.",
"strict": True, # arguments must match the schema exactly
"input_schema": RefundRequest.model_json_schema(),
}
# Later, in your tool loop, turn the guaranteed-valid arguments into a typed object:
# request = RefundRequest.model_validate(block.input)
Strict tools need every property listed as required and additionalProperties: false. extra="forbid" produces the second; giving every field no default (even the nullable ones) produces the first.
Designing forms the model fills in well
The schema is a prompt in disguise. A few design choices made a measurable difference across Arjun’s 63 emails:
- Fixed choices beat free text.
Literalreasons let Mr. Deshpande count them. When a real case does not fit, it lands in"other", so read the"other"pile weekly and add a category when a pattern appears. - Make “unknown” legal. Every field the email might not contain is
| None. When Arjun briefly maderequested_amount_inra plainfloat, the model, forced to write some number, filled in the order total for seven emails that never mentioned money. - Units in the name.
requested_amount_inr, notamount. The name is read by the model and by every developer after you. - Evidence before the verdict. Models generate output in order, and with structured outputs that generally follows the order of fields in your schema. Putting
evidence(quoting the customer) beforeneeds_human_reviewmeans the model has written down the facts before it makes the judgement, a lightweight version of the “think before answering” idea from Module 03’s chain-of-thought lesson. - Keep it shallow. One flat model with six fields is easy for the model and for you. Deeply nested structures with many optional branches are harder to fill correctly and harder to debug.
Note
Pause and think. Arjun wants to add confidence: float (“how sure are you, from 0 to 1?”) so he can auto-approve anything above 0.9. Is that a good idea?
Show the answer
Be wary. A self-reported confidence number is just more generated text, and it is not calibrated: a model can say 0.95 and be wrong. It is far more reliable to base auto-approval on things you can check: the order exists, the amount is within the product value, the dates match, the reason is late delivery and DashDrop’s records confirm the delay. Use needs_human_review as a cautious flag, and let code-checked facts decide. Module 23 shows how to measure whether flags like this actually agree with human reviewers.
Running all 63, and what happened
The full pipeline loops over the emails, parses each, checks it, and writes the CSV:
import csv
with open("refunds.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["order_id", "reasons", "amount_inr", "order_date", "status", "notes"])
for email in emails:
response = client.messages.parse(
model="claude-opus-5-5", max_tokens=2000,
system="You review refund requests for Crumb & Co., a bakery in Pune. Today is 12 November 2026.",
messages=[{"role": "user", "content": email}],
output_format=RefundDecision,
)
d = response.parsed_output
if d is None: # refusal or cut-off: no object to use
writer.writerow(["", "", "", "", "needs_review", "model returned no decision"])
continue
problems = check(d)
status = "needs_review" if (problems or d.needs_human_review) else "approve"
writer.writerow([
d.order_id or "",
";".join(d.reasons),
f"{d.requested_amount_inr:.2f}" if d.requested_amount_inr is not None else "",
d.order_date.strftime("%d/%m/%Y") if d.order_date else "",
status,
"; ".join(problems),
])
(For 63 emails a simple loop is fine. For 6,000 you would use the async concurrency or the Batch API from Module 05.)
The result: 63 rows, all of which imported cleanly. 41 were approved automatically. 22 were marked for review: 9 had amounts above the product value, 6 had no amount, 4 had order ids that did not exist in the database (typos, which a human matched in minutes), and 3 were flagged by the model itself, including one customer who was clearly upset about more than a cake. Total model cost: about ₹45.
The same contract on OpenAI
The idea is not Claude-specific. OpenAI’s SDK takes the very same Pydantic class:
import os
from openai import OpenAI
MODEL = os.getenv("OPENAI_MODEL", "gpt-5-mini") # model names change; see OpenAI's models page
oai = OpenAI() # reads OPENAI_API_KEY
completion = oai.chat.completions.parse(
model=MODEL,
messages=[{"role": "system", "content": "You review refund requests for Crumb & Co."},
{"role": "user", "content": email}],
response_format=RefundDecision,
)
decision = completion.choices[0].message.parsed # a RefundDecision, or None on refusal
Same form, different pen: response_format instead of output_format, .choices[0].message.parsed instead of .parsed_output. The next lesson lines up the two SDKs side by side. Library APIs change quickly. If a name has moved, check the docs linked below.
Where it still breaks
- Valid but wrong. The schema guarantees the amount is a number. It cannot know the number is right. Four of the 63 had a perfectly typed
order_idthat did not exist. Always check against your own records. - No object at all. If the model declines or hits
max_tokens,parsed_outputcan beNone. Handle it explicitly, as the loop does. - Schema changes break downstream. When Arjun renamed
amount_inrtorequested_amount_inr, the accounts import broke again. Treat the output schema like an API contract: change it deliberately, and update everything that reads it. - Some schema features are not supported. Structured outputs support the common parts of JSON Schema (objects, arrays, strings, numbers, booleans, enums, nullable fields) but not every validation keyword. Keep schemas simple, and enforce fine-grained rules like “between 1 and 5000” in your own
check()code, where you control them.
Try it yourself
- Create
RefundDecisionand runmessages.parseon Kavita’s email above. Compare with the output shown. - Run it on these two emails and print the results:
- “CR-3310 never arrived. I want my money back. - Rohit”
- “The plum cake (CR-3315) was dry and honestly a bit stale. Not asking for anything, just letting you know.”
- Add
CR-3310andCR-3315toORDERSand runcheck()on each.
Expected: the first gives reasons=["late_delivery"] (or "other") and requested_amount_inr=null, so check() reports “no amount stated”; the second gives reasons=["quality"] and requested_amount_inr=null, perhaps with needs_human_review=true because it is feedback rather than a refund request.
Show a hint
If the second email comes back with an invented amount, check that requested_amount_inr is float | None and that its description says “Null if not stated”. If the model labels the non-delivery as late_delivery, consider whether you need a not_delivered reason. That is the form telling you it is missing a tick box.
Common mistakes
- Stopping at “it’s valid JSON”. Structured is not typed. Use numbers, dates and
Literalchoices, not strings for everything. - Forcing a value that might not exist. If the input may not contain it, make it
| None. - Vague field names and no descriptions.
amountinvites “Rs. 450/-”.requested_amount_inrwith a description gets450.0. - Using a forced tool call to get JSON. Use structured outputs; on
claude-opus-5-5, forced tool choice is not even available. - Trusting a typed value without checking it. Look ids and amounts up in your own data.
- Treating self-reported confidence as truth. Base automation on facts your code can verify.
Explaining it to Meera
Mr. Deshpande’s reply to the second CSV was “Received.” Meera asked Arjun what had changed.
“The first time, I asked the AI to copy out what each customer wrote. So I got ‘Rs. 450/- approx’ and ‘last Tuesday’, because that’s what they wrote. This time I gave it Mr. Deshpande’s form: a number box for rupees, a date box, tick boxes for reasons, and permission to leave a box empty. The system physically won’t let it write words in the number box.”
“And it decides the refunds?”
“No. It reads the email and fills in the form. Then our own code checks every form against the real order: does the order exist, is the amount more than the cake cost, do the dates match. Forty-one passed every check. Twenty-two went to a person, with a note saying why.”
“How much did that cost?”
“About forty-five rupees for all sixty-three. The person-hours it saved are the bigger number.”
Recap
- Structured means “JSON with the right keys”; typed means every value is the right kind of thing. Downstream systems need typed.
- Define the contract once as a Pydantic model:
Literalfor fixed choices,| Nonefor “unknown”, units in names, descriptions as instructions. - Use
messages.parse(output_format=...)when you want data back, and astrict: truetool when the model is choosing an action. - The schema guarantees shape, not truth: check ids, amounts and dates against your own records in code.
- Base automation on facts you can verify, and route everything else to a human with a reason.
Meera had one more question before she left. “All of this runs on Claude. Farah’s team uses OpenAI for their WhatsApp summaries. If one company has an outage on Diwali, or doubles its prices, are we stuck?” Arjun opened his mouth, then closed it. Priya smiled. “Good question. Let’s put the two SDKs side by side.”
Go deeper
- Pydantic documentation (models, JSON Schema): https://docs.pydantic.dev
- Anthropic docs (structured outputs, strict tool use): https://docs.anthropic.com
- OpenAI docs (structured outputs): https://platform.openai.com/docs