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.
Define a Pydantic BaseModelQuest 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.