Pydantic Models
Schemas: Zod / interfaces → Pydantic BaseModel
Section titled “Schemas: Zod / interfaces → Pydantic BaseModel”In TypeScript you describe a request body shape with an interface or a Zod schema. In FastAPI you describe it with a Pydantic BaseModel subclass. Pass that class as a type hint on your route function argument and FastAPI will parse and validate the request body automatically.
// TypeScript — Zod schema + inferred typeimport { z } from 'zod';
const CreateItemSchema = z.object({ name: z.string().min(1), price: z.number().positive(), inStock: z.boolean().default(true), tags: z.array(z.string()).default([]),});
type CreateItem = z.infer<typeof CreateItemSchema>;
// NestJS DTO with class-validatorexport class CreateItemDto { @IsString() @MinLength(1) name: string; @IsNumber() @IsPositive() price: number; @IsBoolean() @IsOptional() inStock?: boolean = true;}# FastAPI — Pydantic BaseModelfrom pydantic import BaseModel
class CreateItem(BaseModel): name: str price: float in_stock: bool = True tags: list[str] = []
# Use it in a route — FastAPI parses + validates automaticallyfrom fastapi import FastAPIapp = FastAPI()
@app.post("/items", status_code=201)async def create_item(item: CreateItem): return item # Pydantic serialises back to JSONNested models
Section titled “Nested models”Pydantic models compose naturally — embed one model inside another exactly like nested Zod objects.
// Zod nested objectconst AddressSchema = z.object({ street: z.string(), city: z.string(), zip: z.string().length(5),});
const UserSchema = z.object({ name: z.string(), address: AddressSchema,});# Pydantic nested modelsfrom pydantic import BaseModel
class Address(BaseModel): street: str city: str zip_code: str
class User(BaseModel): name: str address: Address
# FastAPI will parse this nested JSON automatically:# { "name": "Alice", "address": { "street": "1 Main St", ... } }Response models — separating input from output
Section titled “Response models — separating input from output”A common pattern is having separate models for input (what the client sends) and output (what you return). Pydantic makes this easy to compose.
// TypeScript — separate input / output typesinterface CreateItemDto { name: string; price: number; }interface ItemResponse { id: number; name: string; price: number; createdAt: string; }# Pydantic — separate input / output modelsfrom pydantic import BaseModelfrom datetime import datetime, UTC
class CreateItem(BaseModel): # Request body name: str price: float
class ItemResponse(BaseModel): # Response shape id: int name: str price: float created_at: datetime
# Route uses response_model= to filter + serializefrom fastapi import FastAPIapp = FastAPI()
@app.post("/items", response_model=ItemResponse, status_code=201)async def create_item(item: CreateItem): # Normally you'd save to DB and return the DB row return ItemResponse(id=1, name=item.name, price=item.price, created_at=datetime.now(UTC))Forward references: quotes are no longer needed
Section titled “Forward references: quotes are no longer needed”In TypeScript a type can reference a type declared later in the file, because the compiler resolves types after parsing. Python used to evaluate annotations eagerly, so a self-referencing or not-yet-defined model needed a string — and Pydantic then needed model_rebuild() to resolve it.
Python 3.14 evaluates annotations lazily (PEP 649/749). A reference to a name defined later in the module just works, and from __future__ import annotations is no longer needed for this.
// TypeScript — order never matteredinterface Comment { text: string; replies: Comment[]; // self-reference}
interface Order { customer: Customer; // declared below}
interface Customer { name: string;}# Python 3.14 — no quotes, no __future__ importfrom pydantic import BaseModel
class Comment(BaseModel): text: str replies: list[Comment] = [] # self-reference: fine on 3.14
class Order(BaseModel): customer: Customer # defined further down: also fine
class Customer(BaseModel): name: str
# Pre-3.14 you had to write the annotations as strings:# replies: list["Comment"] = []# customer: "Customer"# and sometimes call Order.model_rebuild() afterwards.If you need to read annotations yourself rather than let Pydantic do it, the annotationlib module (also new in 3.14) exposes them in several formats, including a string form for tools that must not trigger evaluation.
Try it — stdlib dataclasses as a runnable stand-in
Section titled “Try it — stdlib dataclasses as a runnable stand-in”Pydantic cannot run in the browser. dataclasses from the standard library gives you the same shape (typed fields, defaults, auto __init__) without validation, so you can explore the mental model here:
from dataclasses import dataclass, field, asdictimport json
@dataclassclass CreateItem: name: str price: float in_stock: bool = True tags: list[str] = field(default_factory=list) description: str | None = None
# Instantiate — same feel as BaseModel(...)item = CreateItem(name="Widget", price=9.99, tags=["sale", "new"])print("item:", item)
# Serialize to dict (like .model_dump())data = asdict(item)print("dict :", data)
# Serialize to JSONprint("json :", json.dumps(data, indent=2))
# Nested dataclass@dataclassclass OrderLine: item: CreateItem quantity: int
line = OrderLine(item=item, quantity=3)print("\nnested:", asdict(line))Loading Python runtime (first run only)…