Skip to content

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
// TypeScript — Zod schema + inferred type
import { 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-validator
export class CreateItemDto {
@IsString() @MinLength(1) name: string;
@IsNumber() @IsPositive() price: number;
@IsBoolean() @IsOptional() inStock?: boolean = true;
}
Python
# FastAPI — Pydantic BaseModel
from 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 automatically
from fastapi import FastAPI
app = FastAPI()
@app.post("/items", status_code=201)
async def create_item(item: CreateItem):
return item # Pydantic serialises back to JSON

Pydantic models compose naturally — embed one model inside another exactly like nested Zod objects.

TypeScript
// Zod nested object
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string().length(5),
});
const UserSchema = z.object({
name: z.string(),
address: AddressSchema,
});
Python
# Pydantic nested models
from 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
// TypeScript — separate input / output types
interface CreateItemDto { name: string; price: number; }
interface ItemResponse { id: number; name: string; price: number; createdAt: string; }
Python
# Pydantic — separate input / output models
from pydantic import BaseModel
from 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 + serialize
from fastapi import FastAPI
app = 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
// TypeScript — order never mattered
interface Comment {
text: string;
replies: Comment[]; // self-reference
}
interface Order {
customer: Customer; // declared below
}
interface Customer {
name: string;
}
Python
# Python 3.14 — no quotes, no __future__ import
from 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, asdict
import json
@dataclass
class 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 JSON
print("json :", json.dumps(data, indent=2))
# Nested dataclass
@dataclass
class OrderLine:
item: CreateItem
quantity: int
line = OrderLine(item=item, quantity=3)
print("\nnested:", asdict(line))
What is the Pydantic equivalent of a Zod schema or TypeScript interface for request bodies?
In Pydantic v2, which method serializes a model instance to a Python dict?
How does FastAPI know to parse the request body into a Pydantic model rather than treating it as a query param?
In Python 3.14, how do you annotate a Pydantic field whose type is defined later in the file?