datarekha

Pydantic

Type hints validate nothing at runtime. Pydantic does — and it is now the de-facto data layer for FastAPI, LLM outputs, and config in modern Python.

9 min read Intermediate Python Lesson 35 of 41

What you'll learn

  • Why type hints alone are silent at runtime
  • BaseModel, Field constraints, and the v2 validator decorators
  • Serializing with model_dump / model_dump_json and parsing with model_validate
  • When to use Pydantic versus a plain dataclass

Before you start

A Python type hint is documentation, not a guarantee. Annotate a parameter age: int, pass it "42", and nothing complains. Pydantic is the library that takes those hints seriously and turns them into runtime behaviour: it validates (checks that incoming data matches your types and constraints), coerces (turns "42" into 42 when that is safe), and serialises (converts typed objects back to dicts or JSON). The point of all three is one idea — bad data should fail as it enters your system, at the HTTP boundary or the file read or the LLM response, not three functions later with a stack trace that points nowhere useful.

raw inputJSON / dict / formSignup(BaseModel)validate + coercetyped object ✓ValidationError ✗
Bad data fails right here, at the boundary — not three functions later.

Pydantic v2 is the data layer underneath FastAPI, LangChain, Instructor, and most LLM SDKs. You will meet it everywhere, so it is worth knowing well.

The problem it solves

def signup(email: str, age: int):
    # the type hints are NOT checked at runtime
    print(f"Signing up {email}, age {age}")

signup("not-an-email", "definitely not a number")
Signing up not-an-email, age definitely not a number

Python accepted obvious garbage without a murmur — and your downstream code will be the one that blows up, several calls away, with a confusing trace. Pydantic moves that failure to the entry point and makes it loud.

BaseModel — your first model

from pydantic import BaseModel, Field, ValidationError

class Signup(BaseModel):
    email: str = Field(pattern=r"[^@]+@[^@]+")
    age: int = Field(ge=13, le=120)
    name: str = Field(min_length=1, max_length=50)

# A valid payload parses cleanly.
user = Signup(email="aarav@datarekha.com", age=27, name="Aarav")
print(user)

# An invalid payload raises ValidationError with every failure at once.
try:
    Signup(email="not-an-email", age=9, name="")
except ValidationError as e:
    print(e.error_count(), "validation errors:")
    for err in e.errors():
        print(" ", err["loc"][0], "->", err["type"])
email='aarav@datarekha.com' age=27 name='Aarav'
3 validation errors:
  email -> string_pattern_mismatch
  age -> greater_than_equal
  name -> string_too_short

Two things to notice. Field(ge=13, le=120) adds constraints beyond the type — greater-or-equal 13, less-or-equal 120 — and Field(pattern=...) and min_length do the same for strings. And the error reports every failing field at once (three of them here), not just the first — which is exactly what you want when returning a 422 to a frontend so the user can fix everything in one pass. (We printed a tidy summary; the full print(e) gives a detailed multi-line report.)

Custom validation — @field_validator

When a rule will not fit in Field, write a validator. It either returns the (possibly transformed) value or raises ValueError:

from pydantic import BaseModel, field_validator, ValidationError

class Order(BaseModel):
    sku: str
    quantity: int

    @field_validator("sku")
    @classmethod
    def sku_must_be_upper(cls, v: str) -> str:
        if not v.isupper():
            raise ValueError("sku must be uppercase")
        return v                      # always return the validated value

    @field_validator("quantity")
    @classmethod
    def quantity_positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("quantity must be positive")
        return v

try:
    Order(sku="abc-123", quantity=-1)
except ValidationError as e:
    print(e.error_count(), "errors:")
    for err in e.errors():
        print(" ", err["loc"][0], "->", err["msg"])
2 errors:
  sku -> Value error, sku must be uppercase
  quantity -> Value error, quantity must be positive

The @classmethod and the cls parameter are required in Pydantic v2 — that detail tripped up a lot of people during the v1-to-v2 migration. Note again that both fields were checked, not just the first.

Cross-field rules — @model_validator

When a rule spans several fields, use @model_validator(mode="after"), which runs once the individual fields are validated and self is a real typed instance:

from pydantic import BaseModel, model_validator
from typing import Self

class DateRange(BaseModel):
    start: int   # epoch seconds, for the demo
    end: int

    @model_validator(mode="after")
    def end_after_start(self) -> Self:
        if self.end <= self.start:
            raise ValueError("end must be after start")
        return self

print(DateRange(start=100, end=200))   # fine

try:
    DateRange(start=200, end=100)
except Exception as e:
    print(type(e).__name__)
    for err in e.errors():
        print(" ", err["msg"])
start=100 end=200
ValidationError
  Value error, end must be after start

Use mode="after" (the common case) when your rule reads the typed fields; reach for mode="before" only when you need to inspect or rewrite the raw input dict before field validation runs.

Serialising and parsing

You will move between dicts/JSON and models constantly, and the v2 method names are worth memorising:

from pydantic import BaseModel

class Product(BaseModel):
    id: int
    name: str
    price: float

p = Product(id=1, name="Headphones", price=199.99)

print(p.model_dump())              # to a dict — for passing to other code
print(p.model_dump_json(indent=2)) # to a JSON string — for HTTP, logs, files

data = {"id": 2, "name": "Cable", "price": 9.5}
print(Product.model_validate(data))           # from a dict — the input boundary

raw = '{"id": 3, "name": "Mouse", "price": 25}'
print(Product.model_validate_json(raw))       # from a JSON string
{'id': 1, 'name': 'Headphones', 'price': 199.99}
{
  "id": 1,
  "name": "Headphones",
  "price": 199.99
}
id=2 name='Cable' price=9.5
id=3 name='Mouse' price=25.0

Notice the last line: the JSON had "price": 25 (an integer), and Pydantic coerced it to the float 25.0 to match the model. The pattern is always the same — validate at the boundary, work with typed models inside, dump back to JSON at the exit. In v1 these methods were .dict(), .json(), and .parse_obj(); if you find those in old code, you are looking at Pydantic v1.

Strict mode — when coercion bites

By default Pydantic coerces, so "42" becomes 42 for an int field — usually right at an HTTP boundary, where query strings arrive as text. When you need exact types, opt into strict mode:

from pydantic import BaseModel, ConfigDict

class StrictUser(BaseModel):
    model_config = ConfigDict(strict=True)
    age: int

StrictUser(age="42")   # raises in strict mode — no string-to-int coercion

Use strict mode for internal data that should already be typed by the time it reaches the model; leave it off at HTTP boundaries, where coercion is a feature.

In one breath

  • Type hints are silent at runtime; Pydantic validates, coerces, and serialises against them.
  • Field(ge=..., min_length=..., pattern=...) adds constraints; a ValidationError reports all failures at once.
  • @field_validator checks one field, @model_validator(mode="after") checks across fields.
  • model_dump / model_dump_json go out; model_validate / model_validate_json come in.
  • Coercion is on by default — use strict=True for internal data that is already typed.

Practice

Quick check

0/3
Q1What does `Field(ge=13, le=120)` do?
Q2How do you parse a JSON string into a Pydantic model in v2?
Q3Why use `@model_validator` instead of `@field_validator`?

What’s next

Pydantic’s killer use case is not HTTP payloads — it is coaxing structured output from LLMs. Define a model, hand its JSON schema to the model, and parse the reply straight back into typed fields. That is Pydantic for LLM outputs, next.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How do you reliably get structured outputs (JSON, typed objects) from an LLM?

Modern APIs offer constrained decoding — the model's token sampling is restricted to only produce tokens that are valid continuations of a JSON schema. Combined with Pydantic validation in application code, this eliminates the JSON-parsing errors that plagued earlier prompt-only approaches. When constrained decoding is unavailable, few-shot examples plus output parsing with retry is the fallback.

What is data poisoning, and why is loading a pickle model file dangerous?

Data poisoning is an attack where an adversary injects malicious or mislabeled examples into the training data to bias the model, create backdoors, or degrade it, and it is hard to detect because the model still trains successfully. Loading a pickle model is dangerous because Python's pickle executes arbitrary code on deserialization, so a malicious .pkl or .pt file from an untrusted source can run attacker code the moment you load it. Defenses include trusted data provenance and validation, and using safe formats like safetensors plus scanning model files.

What is a data contract, and how does it prevent ML pipelines from breaking silently?

A data contract is an explicit, enforced agreement between a data producer and consumers that specifies schema, types, semantics, and quality or freshness expectations, plus rules for how it can evolve. It prevents silent breakage by validating data at ingestion so violations are caught and quarantined or alerted instead of flowing into the model. Combined with a schema registry and backward-compatible evolution rules, it lets producers change data without unexpectedly corrupting downstream features and predictions.

What merge types does pandas support, and what does the validate parameter do?

pandas merge supports inner, left, right, and outer joins that mirror SQL semantics. The validate parameter enforces key cardinality ('one-to-one', 'one-to-many', 'many-to-one', 'many-to-many') and raises MergeError immediately when the data violates the expectation, preventing silent row multiplication.

Related lessons

Explore further

Skip to content