Skip to main content

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.

Learning Focus

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

app/models/user.py
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 to Field means the field is required (no default).
  • default_factory accepts a callable — called each time a new instance is created.
  • EmailStr validates email format (requires email-validator package).

Common Field Types

Python typeExample valuesNotes
str"hello"Default string
int42Integer
float3.14Float
booltrue/falseJSON booleans
datetimeISO 8601 stringAuto-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

app/models/product.py
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:

app/models/user.py
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:

app/models/user.py
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:

app/models/product.py
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:

app/models/post.py
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
tip

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

PitfallCause / SymptomFix
class Config not workingPydantic v1 syntax in v2Replace with model_config = ConfigDict(...)
@validator not workingPydantic v1 decorator in v2Replace with @field_validator
dict() on model returns raw dictPydantic v2 deprecates .dict()Use .model_dump()
Password exposed in responseUsing UserCreate as response modelCreate a separate UserResponse without password field
computed_field not in JSON outputForgot @property decoratorBoth @computed_field and @property are required

Hands-On Practice

app/models/order.py
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)
test_order_model.py
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

What's Next