Base Models and Field Types
Pydantic is the validation engine behind FastAPI. Every request body, every response schema, and every settings object you define will be a Pydantic BaseModel. Understanding how to model your data precisely is the foundation of a reliable API.
By the end of this lesson you can: define Pydantic v2 models with typed fields, apply Field constraints, write field_validator and model_validator, and use computed_field for derived values.
Defining a BaseModel
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50, pattern=r"^[a-z0-9_]+$")
email: EmailStr
full_name: str | None = None
age: int = Field(..., ge=18, lt=120)
created_at: datetime = Field(default_factory=lambda: datetime.now(datetime.UTC))
...as the first argument toFieldmeans the field is required (no default).default_factoryaccepts a callable — called each time a new instance is created.EmailStrvalidates email format (requiresemail-validatorpackage).
Common Field Types
| Python type | Example values | Notes |
|---|---|---|
str | "hello" | Default string |
int | 42 | Integer |
float | 3.14 | Float |
bool | true/false | JSON booleans |
datetime | ISO 8601 string | Auto-parsed |
date | "2024-01-15" | Date only |
UUID | "550e8400-..." | UUID v4 |
EmailStr | "user@example.com" | Validated email |
HttpUrl | "https://example.com" | Validated URL |
list[str] | ["a", "b"] | Typed list |
dict[str, int] | {"a": 1} | Typed dict |
Field Constraints Reference
from pydantic import BaseModel, Field
from decimal import Decimal
class Product(BaseModel):
name: str = Field(..., min_length=1, max_length=200, strip_whitespace=True)
sku: str = Field(..., pattern=r"^[A-Z]{2}-\d{4}$")
price: Decimal = Field(..., gt=0, decimal_places=2)
quantity: int = Field(0, ge=0)
weight_kg: float | None = Field(None, gt=0)
tags: list[str] = Field(default_factory=list, max_length=10)
Field Validators
Use @field_validator for custom field-level validation logic:
from pydantic import BaseModel, Field, field_validator
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=8)
confirm_password: str
@field_validator("username")
@classmethod
def username_no_spaces(cls, v: str) -> str:
if " " in v:
raise ValueError("Username cannot contain spaces")
return v.lower()
@field_validator("password")
@classmethod
def password_complexity(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
return v
Model Validators (Cross-Field)
Use @model_validator for validations that span multiple fields:
from pydantic import BaseModel, model_validator
class UserCreate(BaseModel):
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> "UserCreate":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
mode="after" runs after all field validators complete. mode="before" runs on raw input data before type coercion.
Computed Fields
Derive fields from other fields without storing them separately:
from pydantic import BaseModel, computed_field
class Product(BaseModel):
name: str
price: float
tax_rate: float = 0.1
@computed_field
@property
def price_with_tax(self) -> float:
return round(self.price * (1 + self.tax_rate), 2)
computed_field values appear in serialization output and in the OpenAPI schema.
Separating Input and Output Models
A common pattern is to have distinct models for create input, update input, and API response:
from pydantic import BaseModel, Field
from datetime import datetime
class PostCreate(BaseModel):
"""Input: what the client sends"""
title: str = Field(..., min_length=5, max_length=200)
content: str = Field(..., min_length=10)
tags: list[str] = []
class PostUpdate(BaseModel):
"""Input: partial update — all fields optional"""
title: str | None = None
content: str | None = None
tags: list[str] | None = None
class PostResponse(BaseModel):
"""Output: what the API returns"""
id: int
title: str
content: str
tags: list[str]
created_at: datetime
updated_at: datetime | None
Never use the same model for both input and output if the shapes differ. Input models validate constraints; output models control what is exposed to the client.
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
class Config not working | Pydantic v1 syntax in v2 | Replace with model_config = ConfigDict(...) |
@validator not working | Pydantic v1 decorator in v2 | Replace with @field_validator |
dict() on model returns raw dict | Pydantic v2 deprecates .dict() | Use .model_dump() |
| Password exposed in response | Using UserCreate as response model | Create a separate UserResponse without password field |
computed_field not in JSON output | Forgot @property decorator | Both @computed_field and @property are required |
Hands-On Practice
from pydantic import BaseModel, Field, field_validator, model_validator, computed_field
from datetime import datetime
class OrderItem(BaseModel):
product_id: int = Field(..., ge=1)
quantity: int = Field(..., ge=1, le=100)
unit_price: float = Field(..., gt=0)
@computed_field
@property
def subtotal(self) -> float:
return round(self.quantity * self.unit_price, 2)
class OrderCreate(BaseModel):
items: list[OrderItem] = Field(..., min_length=1)
discount_pct: float = Field(0.0, ge=0.0, le=50.0)
notes: str | None = None
@field_validator("discount_pct")
@classmethod
def round_discount(cls, v: float) -> float:
return round(v, 2)
@model_validator(mode="after")
def validate_item_count(self) -> "OrderCreate":
if len(self.items) > 20:
raise ValueError("Cannot order more than 20 distinct items")
return self
@computed_field
@property
def gross_total(self) -> float:
return round(sum(i.subtotal for i in self.items), 2)
@computed_field
@property
def net_total(self) -> float:
discount = self.gross_total * (self.discount_pct / 100)
return round(self.gross_total - discount, 2)
from app.models.order import OrderCreate, OrderItem
order = OrderCreate(
items=[
OrderItem(product_id=1, quantity=2, unit_price=19.99),
OrderItem(product_id=2, quantity=1, unit_price=49.99),
],
discount_pct=10.0,
)
print(order.gross_total) # 89.97
print(order.net_total) # 80.97
# Run: python test_order_model.py