C.W.K.
Stream
Lesson 05 of 05 · published

Pydantic and Runtime Validation — When Types Get Real

~18 min · pydantic, validation, fastapi, runtime

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete

Pydantic — types that actually check

Python's built-in type hints are static — type checkers see them, the interpreter ignores them. Pydantic reads the same hints and validates at runtime: instantiating a Pydantic model with bad data raises a ValidationError with a useful message. It's used by FastAPI for request/response validation, by config libraries, and anywhere "parse, don't validate" matters.

BaseModel — the foundation

Subclass BaseModel, declare fields with type hints. Instantiating with kwargs validates everything: types are coerced (a string "42" becomes int 42 if the field is int), missing required fields raise, extra fields can be ignored or rejected based on config. The model's instance is a regular Python object with attribute access.

Default values and Field()

Direct defaults work like Python's. Field() adds metadata: Field(default=10, ge=0, le=100) validates the field is between 0 and 100. Field(min_length=1, max_length=50) for strings. Field(default_factory=list) for mutable defaults — same trap as Python's mutable defaults, same fix.

Pydantic v2 vs v1

Pydantic v2 (released 2023) is a major rewrite, ~10x faster, with breaking changes from v1. Most code in the wild now is v2. The shape is similar — same BaseModel, similar fields — but v1 had .dict(), v2 has .model_dump(). Always check which version you're on; mixing is painful.

Where Pippa uses it

Pippa's FastAPI routes use Pydantic models for request/response shapes. Every chat message, every council pick, every settings update — Pydantic models. The validation happens at the API boundary; inside the application, you have nice typed objects with no parsing junk.

Code

Pydantic basics·python
# pip install pydantic
from pydantic import BaseModel, Field

class User(BaseModel):
    name: str
    age: int = Field(ge=0, le=150)
    email: str | None = None
    tags: list[str] = Field(default_factory=list)

# Construct from dict
u = User(name="alice", age=30)
print(u)                              # name='alice' age=30 email=None tags=[]
print(u.name, u.age)

# Validation at construction
try:
    bad = User(name="alice", age=200)        # age out of range
except Exception as e:
    print("ValidationError:", str(e)[:200])

# Type coercion — '30' becomes 30
u2 = User(name="bob", age="30")
print(u2.age, type(u2.age))           # 30 <class 'int'>
Round-tripping with JSON·python
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int
    tags: list[str] = []

u = User(name="alice", age=30, tags=["admin", "user"])

# Pydantic v2
print(u.model_dump())                 # dict
print(u.model_dump_json())            # JSON string

# Round-trip
from_json = User.model_validate_json(u.model_dump_json())
print(from_json == u)                 # True

# Pydantic v1 used .dict() and .json() — different method names
Validation messages — actually useful·python
from pydantic import BaseModel, ValidationError, Field

class Order(BaseModel):
    item: str = Field(min_length=1)
    quantity: int = Field(gt=0)
    price: float = Field(ge=0.0)

try:
    Order(item="", quantity=-1, price=-5)
except ValidationError as e:
    for err in e.errors():
        print(err["loc"], err["msg"])

# Output:
# ('item',)        String should have at least 1 character
# ('quantity',)    Input should be greater than 0
# ('price',)       Input should be greater than or equal to 0
Nested models — composition·python
from pydantic import BaseModel

class Address(BaseModel):
    street: str
    city: str
    zip: str

class User(BaseModel):
    name: str
    addresses: list[Address] = []

u = User(
    name="alice",
    addresses=[
        {"street": "123 Main", "city": "Seoul", "zip": "01234"},
        {"street": "456 Oak", "city": "Busan", "zip": "56789"},
    ],
)
# Nested dicts auto-validated and converted to Address instances
print(u.addresses[0].city)            # 'Seoul'
print(type(u.addresses[0]))           # <class '...Address'>
FastAPI route — what Pippa actually does·python
# Sketch of how Pippa's chat route uses Pydantic
from pydantic import BaseModel

class ChatMessage(BaseModel):
    role: str  # 'user' | 'assistant' (use Literal for tighter)
    content: str
    timestamp: float | None = None

class ChatRequest(BaseModel):
    conversation_id: str
    message: ChatMessage
    brain: str = "claude"
    extended_thinking: bool = False

class ChatResponse(BaseModel):
    conversation_id: str
    message_id: str
    content: str
    brain: str

# In FastAPI:
# @router.post('/api/chat', response_model=ChatResponse)
# async def chat(req: ChatRequest) -> ChatResponse:
#     ...
# FastAPI auto-validates the request body, auto-serializes the response.

External links

Exercise

Define a Pydantic BaseModel Quest with: name: str (min length 1), tracks: list[str] (default empty), difficulty: Literal['easy', 'medium', 'hard'], estimated_hours: float = Field(gt=0). Construct a valid Quest. Construct an invalid one (e.g., empty name, negative hours, unknown difficulty) and catch the ValidationError, printing each error's loc and msg. Round-trip via .model_dump_json() and .model_validate_json().

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.