Skip to main content

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 only open|shipped|cancelled are real
  • line items that don't sum to the stated total; a ship_date before the order_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 address

That 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 self

Now 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

An infographic titled 'Parse, Validate, Retry — the Self-Healing Output Loop'. A three-stage flow: the model returns JSON where strict guarantees the shape (e.g. {"qty": -3, "email": "n/a"}); Pydantic validates the values (types, constraints, business rules) via Order.model_validate_json; then it branches into a green 'valid, typed object' outcome you use safely, or a red 'ValidationError' (qty must be >= 0, email invalid). A dashed retry bar: on a ValidationError, feed the error back to the model as a prompt and it self-corrects, up to max_retries (Instructor does this for you). A callout contrasts strict/response-schema (guarantees SHAPE) with Pydantic validation (guarantees VALUES). Bottom banner: a ValidationError isn't a crash, it's feedback.

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 Order

Use 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 null field) 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?

  1. {"rating": 7, "sentiment": "positive", "summary": "Great!"}
  2. {"rating": 4, "sentiment": "happy", "summary": "Loved it"}
  3. {"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.

Validation Lab — a Pydantic Review model (rating an int 1–5, sentiment a Literal of positive/negative/neutral, summary a string of at most 100 chars) validates the values you set. The JSON already parses (strict handled the shape), so every failure here is a value error only Pydantic catches: a rating of 7 exceeds the range, a sentiment of “happy” is not in the enum, a 120-character summary is too long. Load the Try-It outputs or set the fields yourself, watch model_validate_json raise a precise ValidationError naming exactly what is wrong, then feed the error back and retry to watch the model self-correct into a valid, typed object. Lands the lesson: structured output guarantees shape, not correctness, and a ValidationError is feedback, not a crash.

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 precise ValidationError on 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 — Field constraints, 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.