Parsing, Validation & Retries (Pydantic)
Introduction
You can now make a model emit shape-valid JSON (structured outputs) and typed tool arguments (tool calling). But there's a gap between "this parses" and "this is safe to put in my database." A response can be perfectly valid JSON and still say quantity: -3, email: "n/a", or a ship_date before the order_date.
This lesson is the production layer that closes the loop: turn raw output into a validated, typed object your code can trust — and when it's wrong, retry intelligently instead of crashing. We'll lean on the same idea from last lesson — errors are prompts — but pointed inward: a validation error fed back to the model lets it fix its own output.
In this lesson you'll learn:
- The two kinds of "wrong" — and why strict mode catches only one of them
- Pydantic v2: from a JSON blob to a typed object in one line
- The clean SDK path: Pydantic model in, parsed object out
- Encoding business rules strict mode can't express (validators)
- Self-healing retries — feed the error back, cap the attempts (Instructor)
Two Kinds of "Wrong"
Before reaching for tools, get this distinction crisp — it explains why you still need a validation layer even with strict structured outputs:
1. Wrong shape — malformed JSON, missing fields, wrong types, extra keys. This is what strict / response schemas (lesson 59) and strict tool schemas (lesson 61) already eliminate via constrained decoding. With strict on, you rarely see these.
2. Wrong values — perfectly-shaped data that is nonsense or violates your rules:
quantity: -3(negative) ·rating: 11(out of range) ·email: "n/a"(not an email)status: "unknown"when onlyopen|shipped|cancelledare real- line items that don't sum to the stated
total; aship_datebefore theorder_date
Structured output guarantees SHAPE, not CORRECTNESS. The model can hand you flawlessly-typed garbage.
Catching the second kind is the job of validation — and Pydantic is the standard tool for it in Python.
Pydantic: From JSON Blob to Typed Object
Pydantic lets you declare the data you expect as a Python class, then parse-and-validate raw JSON against it in a single call. If anything is off, it raises a precise ValidationError; if all is well, you get a typed object — autocomplete, no data["key"] guessing, no silent Nones.
Define the model with types and constraints, then call model_validate_json:
from pydantic import BaseModel, Field, EmailStr
from enum import Enum
class Status(str, Enum):
open = "open"
shipped = "shipped"
cancelled = "cancelled"
class Order(BaseModel):
order_id: str
quantity: int = Field(ge=1, description="Units ordered; must be >= 1")
email: EmailStr # must be a real email shape
status: Status # must be one of the enum values
raw = '{"order_id": "A-100", "quantity": -3, "email": "n/a", "status": "open"}'
order = Order.model_validate_json(raw) # parses AND validates in one step
# → raises ValidationError:
# quantity: Input should be greater than or equal to 1
# email: value is not a valid email addressThat ValidationError is the whole point: the bad data is rejected at the boundary, before it can reach your database or business logic. Wrap the call in try/except ValidationError and you have a clean place to decide what happens next — including a retry.
The Clean Path: Pydantic Model In, Typed Object Out
You don't have to glue structured output and validation together by hand — the SDKs fuse them. You pass your Pydantic model, and the SDK (a) converts it to a JSON schema, (b) constrains generation to it, and (c) deserializes the response into your typed object:
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class Order(BaseModel):
order_id: str
quantity: int
status: str
resp = client.responses.parse( # Responses API parse helper
model="gpt-5.5",
input=[{"role": "user", "content": "Extract the order: 5 units of A-100, shipped."}],
text_format=Order, # ← pass the Pydantic model
)
order = resp.output_parsed # ← already a typed Order instance
print(order.quantity) # 5 (an int, not a string)
# (Chat Completions equivalent: client.chat.completions.parse(..., response_format=Order)
# then completion.choices[0].message.parsed)This handles shape beautifully. But notice it still won't catch a valid-but-wrong value unless your Pydantic model encodes the rule — which is the next piece.
Validating What Strict Can't: Business Rules
Strict mode and basic types can't express rules like "the line items must sum to the total" or "ship date can't precede order date." Pydantic can — via field constraints for simple bounds and validators for custom and cross-field logic.
from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import date
class LineItem(BaseModel):
sku: str
qty: int = Field(ge=1)
price: float = Field(gt=0)
class Order(BaseModel):
items: list[LineItem] = Field(min_length=1) # at least one item
total: float
order_date: date
ship_date: date | None = None
@field_validator("total")
@classmethod
def total_is_positive(cls, v: float) -> float:
if v <= 0:
raise ValueError("total must be positive")
return v
@model_validator(mode="after") # cross-field rule
def checks(self) -> "Order":
if abs(sum(i.qty * i.price for i in self.items) - self.total) > 0.01:
raise ValueError("line items do not sum to total")
if self.ship_date and self.ship_date < self.order_date:
raise ValueError("ship_date cannot be before order_date")
return selfNow your model is an executable specification of what 'correct' means. Anything that violates it is caught the instant you parse — and the error message names exactly what's wrong, which is precisely what you'll feed back to the model.
Retries: Turn the Error Into a Prompt

When validation fails, crashing is rarely the best move. The error message says exactly what's wrong — so hand it back to the model and let it fix its own output. (This is "errors are prompts" from last lesson, turned inward.)
The manual loop is simple: parse → on ValidationError, append the error text to the conversation → ask again → re-parse → repeat. But you don't need to write it yourself — Instructor (the standard library for this, built on Pydantic) does it natively with response_model + max_retries:
import instructor
from pydantic import BaseModel, field_validator
class Order(BaseModel):
quantity: int
@field_validator("quantity")
@classmethod
def positive(cls, v):
if v < 1:
raise ValueError("quantity must be >= 1")
return v
client = instructor.from_provider("openai/gpt-5.5") # or from_openai / from_anthropic
order = client.chat.completions.create(
response_model=Order, # validate the response against this
max_retries=3, # on ValidationError, feed it back & retry
messages=[{"role": "user", "content": "Order: minus three units."}],
)
# Attempt 1: quantity=-3 → ValidationError('quantity must be >= 1')
# Instructor sends that error back; the model corrects → quantity=3 → returns a valid OrderUse it well:
- Cap
max_retries(2–3). Each retry is another API call — cost and latency add up. - Not every error is self-fixable. If the input genuinely lacks the data, retrying just loops — detect that and fail gracefully (a fallback, a human, a
nullfield) rather than burning attempts. - Validate before side effects. Never write to a DB or charge a card until the object has passed validation.
🧪 Try It Yourself
Predict the validation. You define this model and feed it model output:
class Review(BaseModel):
rating: int = Field(ge=1, le=5)
sentiment: Literal["positive", "negative", "neutral"]
summary: str = Field(max_length=100)
For each output, predict: passes, or which field raises — and is it shape-wrong or value-wrong?
{"rating": 7, "sentiment": "positive", "summary": "Great!"}{"rating": 4, "sentiment": "happy", "summary": "Loved it"}{"rating": "5", "sentiment": "negative", "summary": "Bad"}
→ 1: fails on rating (value-wrong: 7 > 5). 2: fails on sentiment (value-wrong: not in the allowed set). 3: passes — Pydantic coerces the string "5" to int 5 (a feature, not a bug). Now add a max_retries=2 Instructor loop and watch outputs 1 and 2 get auto-corrected on the next attempt.

Mental-Model Corrections
- "Strict mode means I don't need validation." Strict guarantees shape, never values. Negative quantities and impossible dates sail right through — Pydantic is what catches them.
- "A ValidationError is a failure." It's a feature — your boundary working as designed. It's also the exact text you feed back to the model to self-correct.
- "Just retry until it works." Cap retries. If the input lacks the data, retrying loops forever and burns money — detect unfixable cases and fail gracefully.
- "I'll validate after I save it." Validate before any side effect (DB write, payment, email). The whole point is to reject bad data at the boundary.
- "A dict is fine." A typed Pydantic object gives you autocomplete, type-checking, and one obvious place where 'valid' is defined — worth it the moment data leaves the model.
Key Takeaways
- Shape ≠ correctness. Structured outputs/strict guarantee valid JSON in the right shape; they don't stop
quantity: -3. Validation is a separate, necessary layer. - Pydantic turns a JSON blob into a validated, typed object in one call (
model_validate_json), raising a preciseValidationErroron bad data. - The SDKs fuse generation + parsing: pass a Pydantic model (
responses.parse(text_format=...)→output_parsed) and get a typed object back. - Encode business rules strict can't —
Fieldconstraints,field_validator,model_validator(cross-field). Your model becomes an executable spec of 'correct'. - Retry by feeding the error back to the model (errors are prompts). Instructor does this natively via
response_model+max_retries— a self-healing loop. Cap retries, detect unfixable inputs, validate before side effects. - This completes the structured-data toolkit. Next: a hands-on lesson putting it all together to extract structured data from messy text.