Pydantic is the backbone of FastAPI’s data validation system. Every request body, query parameter, and response model you define in FastAPI is powered by Pydantic under the hood. In this comprehensive tutorial, you will master Pydantic v2 — from basic model definitions and field constraints to custom validators, generic models, and a complete real-world registration system. By the end, you will be able to build bulletproof APIs that validate every piece of incoming data automatically.
fastapi-pydantic and follow along step by step.Pydantic is a data validation and serialization library for Python that uses standard type annotations to define data schemas. When you create a Pydantic model, every field is automatically validated based on its type annotation and any additional constraints you provide. If the data does not match, Pydantic raises a clear, structured error — no manual checking required.
FastAPI was specifically designed around Pydantic because of several key advantages:
| Feature | Benefit |
|---|---|
| Type-first validation | Uses standard Python type hints — no new DSL to learn |
| Automatic JSON Schema | Every model generates OpenAPI-compatible JSON Schema for docs |
| Performance (v2) | Core validation logic rewritten in Rust — up to 50x faster than v1 |
| Serialization | Built-in model_dump() and model_dump_json() for output control |
| Editor support | Full autocomplete and type checking in IDEs like VS Code and PyCharm |
| Ecosystem | Works with SQLAlchemy, MongoDB, settings management, and more |
Pydantic v2 was a major rewrite. Here are the most important changes:
# Pydantic v2 key improvements over v1: # # 1. pydantic-core written in Rust for massive speed gains # 2. model_dump() replaces dict() # 3. model_dump_json() replaces json() # 4. model_validate() replaces parse_obj() # 5. model_validate_json() replaces parse_raw() # 6. model_config replaces class Config # 7. @field_validator replaces @validator # 8. @model_validator replaces @root_validator # 9. ConfigDict for typed configuration # 10. Strict mode for no type coercion
# Pydantic is installed automatically with FastAPI pip install fastapi[standard] # Or install Pydantic separately pip install pydantic # Check your version python -c "import pydantic; print(pydantic.__version__)" # Should show 2.x.x
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str
# Valid data - works fine
user = User(name="Alice", age=30, email="alice@example.com")
print(user)
# name='Alice' age=30 email='alice@example.com'
print(user.name) # Alice
print(user.age) # 30
# Pydantic coerces compatible types
user2 = User(name="Bob", age="25", email="bob@example.com")
print(user2.age) # 25 (converted string "25" to int)
print(type(user2.age)) # <class 'int'>
# Invalid data - raises ValidationError
try:
bad_user = User(name="Charlie", age="not_a_number", email="charlie@example.com")
except Exception as e:
print(e)
# 1 validation error for User
# age
# Input should be a valid integer, unable to parse string as an integer
Every Pydantic model inherits from BaseModel. Fields are defined using Python type annotations, and you can set defaults, make fields optional, and compose complex structures.
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class Product(BaseModel):
# Required fields - must be provided
name: str
price: float
# Field with a default value
quantity: int = 0
# Optional field - can be None
description: Optional[str] = None
# Field with a default factory
created_at: datetime = datetime.now()
# Only required fields
p1 = Product(name="Laptop", price=999.99)
print(p1)
# name='Laptop' price=999.99 quantity=0 description=None created_at=...
# All fields provided
p2 = Product(
name="Phone",
price=699.99,
quantity=50,
description="Latest smartphone model",
created_at=datetime(2024, 1, 15)
)
print(p2.description) # Latest smartphone model
from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID, uuid4
from enum import Enum
class StatusEnum(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
PENDING = "pending"
class CompleteExample(BaseModel):
# String types
name: str
description: str = "No description"
# Numeric types
age: int
price: float
exact_price: Decimal = Decimal("0.00")
# Boolean
is_active: bool = True
# Date/time
created_at: datetime = datetime.now()
birth_date: Optional[date] = None
# UUID
id: UUID = uuid4()
# Enum
status: StatusEnum = StatusEnum.ACTIVE
# Collections
tags: List[str] = []
metadata: Dict[str, str] = {}
scores: List[float] = []
# Optional fields
nickname: Optional[str] = None
parent_id: Optional[int] = None
# Usage
item = CompleteExample(
name="Test Item",
age=5,
price=29.99,
tags=["sale", "featured"],
metadata={"color": "blue", "size": "large"},
scores=[95.5, 87.3, 92.1]
)
print(item.name) # Test Item
print(item.tags) # ['sale', 'featured']
print(item.status) # StatusEnum.ACTIVE
from pydantic import BaseModel
from typing import Optional
class FieldExamples(BaseModel):
# REQUIRED - no default, must be provided
name: str
# REQUIRED - no default, must be provided (can be None)
middle_name: Optional[str]
# OPTIONAL - has a default of None
nickname: Optional[str] = None
# OPTIONAL - has a default value
country: str = "US"
# This works - all required fields provided
user = FieldExamples(name="Alice", middle_name=None)
print(user)
# name='Alice' middle_name=None nickname=None country='US'
# This fails - middle_name is required even though it is Optional[str]
try:
user = FieldExamples(name="Bob")
except Exception as e:
print(e)
# 1 validation error for FieldExamples
# middle_name
# Field required
Optional[str] without a default value is still required. It means the field accepts str or None, but you must explicitly provide it. To make a field truly optional (not required), give it a default: Optional[str] = None.
from pydantic import BaseModel
class Employee(BaseModel):
first_name: str
last_name: str
department: str
salary: float
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
@property
def annual_salary(self) -> float:
return self.salary * 12
def get_summary(self) -> str:
return f"{self.full_name} - {self.department} (${self.salary:,.2f}/month)"
emp = Employee(
first_name="Jane",
last_name="Smith",
department="Engineering",
salary=8500.00
)
print(emp.full_name) # Jane Smith
print(emp.annual_salary) # 102000.0
print(emp.get_summary()) # Jane Smith - Engineering ($8,500.00/month)
Pydantic’s Field() function lets you add constraints to individual fields — minimum/maximum values, string lengths, regex patterns, and more. These constraints are enforced automatically and appear in the generated JSON Schema.
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(
..., # ... means required
min_length=1,
max_length=100,
description="Product name",
examples=["Laptop Pro 15"]
)
price: float = Field(
...,
gt=0, # greater than 0
le=1_000_000, # less than or equal to 1,000,000
description="Price in USD"
)
quantity: int = Field(
default=0,
ge=0, # greater than or equal to 0
le=10_000,
description="Items in stock"
)
sku: str = Field(
...,
pattern=r"^[A-Z]{2,4}-\d{4,8}$",
description="Stock keeping unit (e.g., PROD-12345)"
)
discount: float = Field(
default=0.0,
ge=0.0,
le=1.0,
description="Discount as a decimal (0.0 to 1.0)"
)
# Valid product
product = Product(
name="Wireless Mouse",
price=29.99,
quantity=150,
sku="WM-12345",
discount=0.1
)
print(product)
# Invalid - triggers multiple validation errors
try:
bad_product = Product(
name="", # too short
price=-5, # not greater than 0
quantity=-1, # not >= 0
sku="invalid", # does not match pattern
discount=1.5 # exceeds max
)
except Exception as e:
print(e)
from pydantic import BaseModel, Field
class NumericConstraints(BaseModel):
# gt = greater than (exclusive minimum)
positive: int = Field(gt=0) # must be > 0
# ge = greater than or equal (inclusive minimum)
non_negative: int = Field(ge=0) # must be >= 0
# lt = less than (exclusive maximum)
below_hundred: int = Field(lt=100) # must be < 100
# le = less than or equal (inclusive maximum)
max_hundred: int = Field(le=100) # must be <= 100
# Combine constraints
percentage: float = Field(ge=0.0, le=100.0) # 0 to 100
# multiple_of
even_number: int = Field(multiple_of=2) # divisible by 2
# Price constraint
price: float = Field(gt=0, le=999999.99)
# Valid
data = NumericConstraints(
positive=1,
non_negative=0,
below_hundred=99,
max_hundred=100,
percentage=85.5,
even_number=42,
price=199.99
)
print(data)
from pydantic import BaseModel, Field
class StringConstraints(BaseModel):
# Length constraints
username: str = Field(min_length=3, max_length=30)
# Exact length (min_length == max_length)
country_code: str = Field(min_length=2, max_length=2)
# Regex pattern for phone
phone: str = Field(pattern=r"^\+?1?\d{9,15}$")
# Email pattern
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
# URL-safe slug
slug: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
# Password length
password: str = Field(min_length=8, max_length=128)
# Valid data
data = StringConstraints(
username="john_doe",
country_code="US",
phone="+15551234567",
email="john@example.com",
slug="my-blog-post",
password="Str0ng!Pass"
)
print(data.username) # john_doe
from pydantic import BaseModel, Field
class APIResponse(BaseModel):
# alias: the key name in incoming JSON data
item_id: int = Field(alias="itemId")
item_name: str = Field(alias="itemName")
is_available: bool = Field(alias="isAvailable", default=True)
# validation_alias: only for input; serialization_alias: only for output
total_price: float = Field(
validation_alias="total_price",
serialization_alias="totalPrice"
)
# Parse from camelCase JSON
data = {"itemId": 42, "itemName": "Widget", "isAvailable": True, "total_price": 29.99}
response = APIResponse(**data)
print(response.item_id) # 42
# Serialize with Python names (default)
print(response.model_dump())
# {'item_id': 42, 'item_name': 'Widget', 'is_available': True, 'total_price': 29.99}
# Serialize with aliases
print(response.model_dump(by_alias=True))
# {'itemId': 42, 'itemName': 'Widget', 'isAvailable': True, 'totalPrice': 29.99}
from pydantic import BaseModel, EmailStr, HttpUrl, IPvAnyAddress
from pydantic import PositiveInt, PositiveFloat, NonNegativeInt
from pydantic import constr, conint, confloat
class BuiltInTypes(BaseModel):
# Email validation (requires: pip install pydantic[email])
email: EmailStr
# URL validation
website: HttpUrl
# IP address
server_ip: IPvAnyAddress
# Constrained positive numbers
user_id: PositiveInt # must be > 0
rating: PositiveFloat # must be > 0.0
retry_count: NonNegativeInt # must be >= 0
# Constrained types (alternative to Field())
username: constr(min_length=3, max_length=50)
age: conint(ge=0, le=150)
score: confloat(ge=0.0, le=100.0)
data = BuiltInTypes(
email="user@example.com",
website="https://example.com",
server_ip="192.168.1.1",
user_id=42,
rating=4.5,
retry_count=0,
username="john_doe",
age=30,
score=95.5
)
print(data.email) # user@example.com
print(data.website) # https://example.com/
Pydantic leverages Python’s type annotation system extensively. Understanding how different type hints translate to validation rules is essential for building robust models.
from pydantic import BaseModel
class BasicTypes(BaseModel):
name: str # accepts strings
age: int # accepts integers, coerces numeric strings
height: float # accepts floats, coerces ints
is_active: bool # accepts booleans, coerces "true"/"false"
data: bytes # accepts bytes and strings
# Type coercion examples
model = BasicTypes(
name="Alice",
age="30", # string "30" coerced to int 30
height=5, # int 5 coerced to float 5.0
is_active="true", # string "true" coerced to True
data="hello" # string coerced to b"hello"
)
print(f"age: {model.age} (type: {type(model.age).__name__})")
# age: 30 (type: int)
print(f"height: {model.height} (type: {type(model.height).__name__})")
# height: 5.0 (type: float)
print(f"is_active: {model.is_active} (type: {type(model.is_active).__name__})")
# is_active: True (type: bool)
from pydantic import BaseModel, Field
from typing import List, Dict, Set, Tuple, FrozenSet
class CollectionTypes(BaseModel):
tags: List[str] = []
scores: List[int] = Field(default=[], min_length=1, max_length=100)
metadata: Dict[str, str] = {}
unique_tags: Set[str] = set()
coordinates: Tuple[float, float] = (0.0, 0.0)
values: Tuple[int, ...] = ()
permissions: FrozenSet[str] = frozenset()
data = CollectionTypes(
tags=["python", "fastapi", "tutorial"],
scores=[95, 87, 92, 88],
metadata={"author": "Alice", "version": "2.0"},
unique_tags=["python", "python", "fastapi"], # duplicates removed
coordinates=(40.7128, -74.0060),
values=(1, 2, 3, 4, 5),
permissions=["read", "write", "read"] # duplicates removed
)
print(data.unique_tags) # {'python', 'fastapi'}
print(data.permissions) # frozenset({'read', 'write'})
from pydantic import BaseModel
from typing import Optional, Union, Literal
class AdvancedTypes(BaseModel):
nickname: Optional[str] = None
identifier: Union[int, str]
role: Literal["admin", "user", "moderator"]
priority: Literal[1, 2, 3, "high", "medium", "low"]
# Valid
data = AdvancedTypes(identifier=42, role="admin", priority="high")
print(data)
# Union tries types in order
data2 = AdvancedTypes(identifier="user-abc-123", role="user", priority=2)
print(data2.identifier) # user-abc-123
# Invalid literal
try:
AdvancedTypes(identifier=1, role="superadmin", priority=5)
except Exception as e:
print(e)
from pydantic import BaseModel, Field
from typing import Annotated
from pydantic.functional_validators import AfterValidator
# Create reusable validated types with Annotated
PositiveName = Annotated[str, Field(min_length=1, max_length=100)]
Percentage = Annotated[float, Field(ge=0.0, le=100.0)]
Port = Annotated[int, Field(ge=1, le=65535)]
def normalize_email(v: str) -> str:
return v.lower().strip()
NormalizedEmail = Annotated[str, AfterValidator(normalize_email)]
class ServerConfig(BaseModel):
name: PositiveName
host: str
port: Port
cpu_usage: Percentage
admin_email: NormalizedEmail
config = ServerConfig(
name="Production Server",
host="api.example.com",
port=8080,
cpu_usage=67.5,
admin_email=" ADMIN@Example.COM "
)
print(config.admin_email) # admin@example.com (normalized!)
print(config.port) # 8080
Annotated types are the recommended way in Pydantic v2 to create reusable field definitions. They keep your models clean and ensure consistent validation across your codebase.Real-world data is rarely flat. Pydantic excels at validating deeply nested structures by composing models together. Each nested model is fully validated independently.
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class Address(BaseModel):
street: str = Field(min_length=1, max_length=200)
city: str = Field(min_length=1, max_length=100)
state: str = Field(min_length=2, max_length=2)
zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
country: str = "US"
class ContactInfo(BaseModel):
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
phone: Optional[str] = Field(default=None, pattern=r"^\+?1?\d{10,15}$")
address: Address
class User(BaseModel):
id: int
name: str = Field(min_length=1, max_length=100)
contact: ContactInfo
created_at: datetime = Field(default_factory=datetime.now)
# Create with nested data
user = User(
id=1,
name="Alice Johnson",
contact={
"email": "alice@example.com",
"phone": "+15551234567",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
}
}
)
# Access nested fields
print(user.contact.email) # alice@example.com
print(user.contact.address.city) # Springfield
print(user.contact.address.zip_code) # 62701
# Nested validation works automatically
try:
bad_user = User(
id=2,
name="Bob",
contact={
"email": "bob@example.com",
"address": {
"street": "456 Oak Ave",
"city": "Chicago",
"state": "Illinois", # too long - must be 2 chars
"zip_code": "abc" # does not match pattern
}
}
)
except Exception as e:
print(e)
# Shows errors at contact -> address -> state and zip_code
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import Enum
class OrderStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class OrderItem(BaseModel):
product_name: str = Field(min_length=1)
quantity: int = Field(gt=0, le=1000)
unit_price: float = Field(gt=0)
@property
def total(self) -> float:
return round(self.quantity * self.unit_price, 2)
class ShippingAddress(BaseModel):
name: str
street: str
city: str
state: str
zip_code: str
class Order(BaseModel):
order_id: str = Field(pattern=r"^ORD-\d{6,10}$")
customer_id: int = Field(gt=0)
items: List[OrderItem] = Field(min_length=1, max_length=50)
shipping: ShippingAddress
status: OrderStatus = OrderStatus.PENDING
notes: Optional[str] = None
@property
def order_total(self) -> float:
return round(sum(item.total for item in self.items), 2)
@property
def item_count(self) -> int:
return sum(item.quantity for item in self.items)
# Create a complete order
order = Order(
order_id="ORD-123456",
customer_id=42,
items=[
{"product_name": "Laptop", "quantity": 1, "unit_price": 999.99},
{"product_name": "Mouse", "quantity": 2, "unit_price": 29.99},
{"product_name": "Keyboard", "quantity": 1, "unit_price": 79.99},
],
shipping={
"name": "Alice Johnson",
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
},
notes="Please leave at front door"
)
print(f"Order: {order.order_id}")
print(f"Items: {len(order.items)}")
print(f"Total items: {order.item_count}")
print(f"Order total: ${order.order_total}")
# Order total: $1139.96
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Optional, List
class Comment(BaseModel):
id: int
author: str
text: str = Field(min_length=1, max_length=5000)
replies: List[Comment] = [] # self-referential!
@property
def reply_count(self) -> int:
count = len(self.replies)
for reply in self.replies:
count += reply.reply_count
return count
class Category(BaseModel):
name: str
slug: str
children: List[Category] = []
def find(self, slug: str) -> Optional[Category]:
if self.slug == slug:
return self
for child in self.children:
result = child.find(slug)
if result:
return result
return None
# Build a comment tree
thread = Comment(
id=1,
author="Alice",
text="Great tutorial on Pydantic!",
replies=[
{
"id": 2,
"author": "Bob",
"text": "Thanks Alice! I learned a lot.",
"replies": [
{"id": 3, "author": "Alice", "text": "Glad to hear that!"}
]
},
{"id": 4, "author": "Charlie", "text": "Could you explain validators more?"}
]
)
print(f"Total replies: {thread.reply_count}") # Total replies: 3
# Build a category tree
categories = Category(
name="Programming",
slug="programming",
children=[
{
"name": "Python",
"slug": "python",
"children": [
{"name": "FastAPI", "slug": "fastapi"},
{"name": "Django", "slug": "django"},
]
},
{
"name": "JavaScript",
"slug": "javascript",
"children": [
{"name": "React", "slug": "react"},
{"name": "Node.js", "slug": "nodejs"},
]
}
]
)
found = categories.find("fastapi")
if found:
print(f"Found: {found.name}") # Found: FastAPI
While built-in constraints handle most cases, custom validators let you implement any validation logic you need. Pydantic v2 provides @field_validator for single fields and @model_validator for cross-field validation.
from pydantic import BaseModel, Field, field_validator
class UserRegistration(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: str
password: str = Field(min_length=8)
age: int = Field(ge=13)
@field_validator("username")
@classmethod
def username_must_be_alphanumeric(cls, v: str) -> str:
if not v.replace("_", "").isalnum():
raise ValueError("Username must contain only letters, numbers, and underscores")
if v[0].isdigit():
raise ValueError("Username cannot start with a number")
return v.lower() # normalize to lowercase
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Invalid email address")
local, domain = v.rsplit("@", 1)
if not local or not domain or "." not in domain:
raise ValueError("Invalid email address")
return v.lower().strip()
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in v):
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,./?" for c in v):
raise ValueError("Password must contain at least one special character")
return v
# Valid registration
user = UserRegistration(
username="Alice_Dev",
email="ALICE@Example.COM",
password="Str0ng!Pass",
age=25
)
print(user.username) # alice_dev (normalized)
print(user.email) # alice@example.com (normalized)
# Invalid registration
try:
UserRegistration(
username="123invalid",
email="not-email",
password="weakpass",
age=10
)
except Exception as e:
print(e)
from pydantic import BaseModel, field_validator
class DataProcessor(BaseModel):
value: int
tags: list[str]
name: str
# BEFORE validator - runs before Pydantic's type validation
@field_validator("value", mode="before")
@classmethod
def parse_value(cls, v):
if isinstance(v, str):
cleaned = v.strip().replace("$", "").replace("%", "").replace(",", "")
try:
return int(float(cleaned))
except ValueError:
raise ValueError(f"Cannot parse '{v}' as a number")
return v
# AFTER validator (default) - runs after type validation
@field_validator("tags")
@classmethod
def normalize_tags(cls, v: list[str]) -> list[str]:
seen = set()
result = []
for tag in v:
normalized = tag.lower().strip()
if normalized and normalized not in seen:
seen.add(normalized)
result.append(normalized)
return result
# BEFORE validator for type coercion
@field_validator("name", mode="before")
@classmethod
def coerce_name(cls, v):
if not isinstance(v, str):
return str(v)
return v
# Before validator handles type conversion
data = DataProcessor(
value="$1,234",
tags=["Python", "PYTHON", " fastapi ", "FastAPI", "tutorial"],
name=42
)
print(data.value) # 1234
print(data.tags) # ['python', 'fastapi', 'tutorial']
print(data.name) # 42 (converted to string)
from pydantic import BaseModel, Field, field_validator
class PriceRange(BaseModel):
min_price: float = Field(ge=0)
max_price: float = Field(ge=0)
currency: str = Field(min_length=3, max_length=3)
# Apply one validator to multiple fields
@field_validator("min_price", "max_price")
@classmethod
def round_price(cls, v: float) -> float:
return round(v, 2)
@field_validator("currency")
@classmethod
def uppercase_currency(cls, v: str) -> str:
return v.upper()
data = PriceRange(min_price=10.555, max_price=99.999, currency="usd")
print(data.min_price) # 10.56
print(data.max_price) # 100.0
print(data.currency) # USD
from pydantic import BaseModel, Field, model_validator
from datetime import date
from typing import Optional
class DateRange(BaseModel):
start_date: date
end_date: date
@model_validator(mode="after")
def validate_date_range(self):
if self.start_date >= self.end_date:
raise ValueError("start_date must be before end_date")
return self
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
confirm_password: str
@model_validator(mode="after")
def validate_passwords(self):
if self.new_password != self.confirm_password:
raise ValueError("new_password and confirm_password must match")
if self.new_password == self.current_password:
raise ValueError("New password must be different from current password")
return self
class DiscountRule(BaseModel):
discount_type: str # "percentage" or "fixed"
discount_value: float
min_order: float = 0.0
max_discount: Optional[float] = None
@model_validator(mode="after")
def validate_discount(self):
if self.discount_type == "percentage":
if not (0 < self.discount_value <= 100):
raise ValueError("Percentage discount must be between 0 and 100")
elif self.discount_type == "fixed":
if self.discount_value <= 0:
raise ValueError("Fixed discount must be positive")
else:
raise ValueError("discount_type must be 'percentage' or 'fixed'")
return self
# Valid date range
dr = DateRange(start_date="2024-01-01", end_date="2024-12-31")
print(dr)
# Invalid - passwords don't match
try:
PasswordChange(
current_password="old",
new_password="NewStr0ng!Pass",
confirm_password="DifferentPass"
)
except Exception as e:
print(e)
from pydantic import BaseModel, model_validator
from typing import Any
class FlexibleInput(BaseModel):
name: str
value: float
@model_validator(mode="before")
@classmethod
def normalize_input(cls, data: Any) -> dict:
# Handle string input like "name:value"
if isinstance(data, str):
parts = data.split(":")
if len(parts) != 2:
raise ValueError("String input must be 'name:value' format")
return {"name": parts[0].strip(), "value": float(parts[1].strip())}
# Handle tuple/list input
if isinstance(data, (list, tuple)):
if len(data) != 2:
raise ValueError("Sequence must have exactly 2 elements")
return {"name": str(data[0]), "value": float(data[1])}
# Handle dict with alternative key names
if isinstance(data, dict):
if "label" in data and "name" not in data:
data["name"] = data.pop("label")
if "amount" in data and "value" not in data:
data["value"] = data.pop("amount")
return data
# All of these work:
m1 = FlexibleInput(name="temperature", value=98.6)
m2 = FlexibleInput.model_validate("humidity:65.0")
m3 = FlexibleInput.model_validate(["pressure", 1013.25])
m4 = FlexibleInput.model_validate({"label": "wind", "amount": 15.5})
print(m1) # name='temperature' value=98.6
print(m2) # name='humidity' value=65.0
print(m3) # name='pressure' value=1013.25
print(m4) # name='wind' value=15.5
from pydantic import BaseModel, Field, ValidationError, field_validator
class StrictUser(BaseModel):
name: str = Field(min_length=2, max_length=50)
age: int = Field(ge=0, le=150)
email: str
@field_validator("email")
@classmethod
def validate_email(cls, v):
if "@" not in v:
raise ValueError("Must contain @")
return v
# Trigger multiple validation errors
try:
StrictUser(name="A", age=-5, email="invalid")
except ValidationError as e:
print(f"Error count: {e.error_count()}")
for error in e.errors():
field = ' -> '.join(str(loc) for loc in error['loc'])
print(f"\nField: {field}")
print(f" Type: {error['type']}")
print(f" Message: {error['msg']}")
print(f" Input: {error['input']}")
# JSON format for API responses
import json
print(json.dumps(e.errors(), indent=2, default=str))
Computed fields are derived from other field values and are included automatically in serialization and JSON schema output. Pydantic v2 introduced the @computed_field decorator for this purpose.
from pydantic import BaseModel, Field, computed_field
class Product(BaseModel):
name: str
base_price: float = Field(gt=0)
tax_rate: float = Field(ge=0, le=1, default=0.08)
discount: float = Field(ge=0, le=1, default=0)
@computed_field
@property
def discount_amount(self) -> float:
return round(self.base_price * self.discount, 2)
@computed_field
@property
def subtotal(self) -> float:
return round(self.base_price - self.discount_amount, 2)
@computed_field
@property
def tax_amount(self) -> float:
return round(self.subtotal * self.tax_rate, 2)
@computed_field
@property
def total_price(self) -> float:
return round(self.subtotal + self.tax_amount, 2)
product = Product(name="Laptop", base_price=999.99, discount=0.15)
print(f"Base price: ${product.base_price}")
print(f"Discount: -${product.discount_amount}")
print(f"Subtotal: ${product.subtotal}")
print(f"Tax: +${product.tax_amount}")
print(f"Total: ${product.total_price}")
# Computed fields are included in model_dump()!
data = product.model_dump()
print(data)
# Includes: discount_amount, subtotal, tax_amount, total_price
from pydantic import BaseModel, Field, computed_field
from datetime import date
from typing import List
class Student(BaseModel):
first_name: str
last_name: str
birth_date: date
grades: List[float] = Field(default=[])
@computed_field
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
@computed_field
@property
def age(self) -> int:
today = date.today()
born = self.birth_date
return today.year - born.year - (
(today.month, today.day) < (born.month, born.day)
)
@computed_field
@property
def gpa(self) -> float:
if not self.grades:
return 0.0
return round(sum(self.grades) / len(self.grades), 2)
@computed_field
@property
def letter_grade(self) -> str:
gpa = self.gpa
if gpa >= 90: return "A"
elif gpa >= 80: return "B"
elif gpa >= 70: return "C"
elif gpa >= 60: return "D"
return "F"
@computed_field
@property
def honor_roll(self) -> bool:
return self.gpa >= 85.0 and len(self.grades) >= 3
student = Student(
first_name="Alice",
last_name="Johnson",
birth_date="2000-05-15",
grades=[92, 88, 95, 91, 87]
)
print(student.model_dump())
# Includes: full_name, age, gpa, letter_grade, honor_roll
Pydantic v2 uses model_config (a ConfigDict) to control model behavior — how it handles extra fields, whether it strips whitespace, whether it can read from ORM objects, and more.
from pydantic import BaseModel, ConfigDict
class StrictModel(BaseModel):
model_config = ConfigDict(
extra="forbid", # Reject extra fields
str_strip_whitespace=True, # Strip whitespace from strings
str_to_lower=True, # Convert strings to lowercase
frozen=False, # Allow mutation
use_enum_values=True, # Use enum values in serialization
validate_default=True, # Validate default values
)
name: str
email: str
role: str = "user"
# str_strip_whitespace and str_to_lower in action
user = StrictModel(
name=" ALICE JOHNSON ",
email=" Alice@Example.COM "
)
print(user.name) # alice johnson (stripped and lowered)
print(user.email) # alice@example.com
# extra="forbid" rejects unknown fields
try:
StrictModel(name="Bob", email="bob@test.com", unknown_field="value")
except Exception as e:
print(e)
# Extra inputs are not permitted
from pydantic import BaseModel, ConfigDict
class ForbidExtra(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
class IgnoreExtra(BaseModel):
model_config = ConfigDict(extra="ignore")
name: str
class AllowExtra(BaseModel):
model_config = ConfigDict(extra="allow")
name: str
data = {"name": "Alice", "age": 30, "role": "admin"}
# forbid - raises error
try:
ForbidExtra(**data)
except Exception as e:
print(f"Forbid: {e}")
# ignore - silently drops extra fields
m2 = IgnoreExtra(**data)
print(f"Ignore: {m2.model_dump()}")
# {'name': 'Alice'}
# allow - keeps extra fields
m3 = AllowExtra(**data)
print(f"Allow: {m3.model_dump()}")
# {'name': 'Alice', 'age': 30, 'role': 'admin'}
from pydantic import BaseModel, ConfigDict
# Simulating an ORM model (like SQLAlchemy)
class UserORM:
def __init__(self, id, name, email, is_active):
self.id = id
self.name = name
self.email = email
self.is_active = is_active
class UserSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
email: str
is_active: bool
# Create from ORM object
orm_user = UserORM(id=1, name="Alice", email="alice@example.com", is_active=True)
# model_validate reads attributes from the object
user_schema = UserSchema.model_validate(orm_user)
print(user_schema)
# id=1 name='Alice' email='alice@example.com' is_active=True
print(user_schema.model_dump())
# {'id': 1, 'name': 'Alice', 'email': 'alice@example.com', 'is_active': True}
# This is essential for FastAPI + SQLAlchemy integration
from pydantic import BaseModel, Field, ConfigDict
import json
class CreateUserRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"username": "alice_dev",
"email": "alice@example.com",
"full_name": "Alice Johnson",
"age": 28
}
]
}
)
username: str = Field(
min_length=3, max_length=30,
description="Unique username for the account"
)
email: str = Field(description="Valid email address")
full_name: str = Field(min_length=1, max_length=100)
age: int = Field(ge=13, le=150, description="Must be 13 or older")
# View the generated JSON Schema
schema = CreateUserRequest.model_json_schema()
print(json.dumps(schema, indent=2))
# This schema is automatically used by FastAPI for Swagger UI
from pydantic import BaseModel, ConfigDict
class ImmutableConfig(BaseModel):
model_config = ConfigDict(frozen=True)
host: str
port: int
debug: bool = False
config = ImmutableConfig(host="localhost", port=8000, debug=True)
print(config.host) # localhost
# Cannot modify frozen model
try:
config.host = "production.example.com"
except Exception as e:
print(e) # Instance is frozen
# Create a new instance with changes using model_copy
new_config = config.model_copy(
update={"host": "production.example.com", "debug": False}
)
print(new_config.host) # production.example.com
print(config.host) # localhost (original unchanged)
Serialization is the process of converting your Pydantic model to a dictionary, JSON, or other format for output. Pydantic v2 provides powerful serialization controls through model_dump() and model_dump_json().
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class UserProfile(BaseModel):
id: int
username: str
email: str
full_name: Optional[str] = None
bio: Optional[str] = None
avatar_url: Optional[str] = None
is_active: bool = True
role: str = "user"
login_count: int = 0
created_at: datetime = Field(default_factory=datetime.now)
tags: List[str] = []
user = UserProfile(
id=1,
username="alice",
email="alice@example.com",
full_name="Alice Johnson",
bio="Software developer",
tags=["python", "fastapi"]
)
# Exclude specific fields
public_data = user.model_dump(exclude={"email", "login_count", "is_active"})
print(public_data)
# Include only specific fields
summary = user.model_dump(include={"id", "username", "full_name"})
print(summary)
# {'id': 1, 'username': 'alice', 'full_name': 'Alice Johnson'}
# Exclude None values
clean_data = user.model_dump(exclude_none=True)
print(clean_data) # avatar_url won't appear
# Exclude unset values (only fields explicitly provided)
explicit_data = user.model_dump(exclude_unset=True)
print(explicit_data)
# Only id, username, email, full_name, bio, tags appear
# Exclude defaults
non_default = user.model_dump(exclude_defaults=True)
print(non_default)
from pydantic import BaseModel
from typing import Optional
class Address(BaseModel):
street: str
city: str
state: str
zip_code: str
country: str = "US"
class ContactInfo(BaseModel):
email: str
phone: Optional[str] = None
address: Address
class UserFull(BaseModel):
id: int
name: str
password_hash: str
contact: ContactInfo
internal_notes: Optional[str] = None
user = UserFull(
id=1,
name="Alice",
password_hash="$2b$12$abc123...",
contact={
"email": "alice@example.com",
"phone": "+15551234567",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip_code": "62701"
}
},
internal_notes="VIP customer"
)
# Exclude nested fields using dict notation
public_data = user.model_dump(
exclude={
"password_hash": True,
"internal_notes": True,
"contact": {
"phone": True,
"address": {"zip_code", "country"}
}
}
)
print(public_data)
# password_hash, internal_notes, phone, zip_code, country all excluded
# Include only specific nested fields
minimal = user.model_dump(
include={
"id": True,
"name": True,
"contact": {"email": True}
}
)
print(minimal)
# {'id': 1, 'name': 'Alice', 'contact': {'email': 'alice@example.com'}}
from pydantic import BaseModel, Field
from datetime import datetime, date
from typing import List
from decimal import Decimal
from uuid import UUID, uuid4
class Invoice(BaseModel):
id: UUID = Field(default_factory=uuid4)
customer: str
amount: Decimal
tax: Decimal
issue_date: date
due_date: date
items: List[str]
created_at: datetime = Field(default_factory=datetime.now)
invoice = Invoice(
customer="Acme Corp",
amount=Decimal("1500.00"),
tax=Decimal("120.00"),
issue_date="2024-03-15",
due_date="2024-04-15",
items=["Consulting", "Development", "Testing"]
)
# model_dump_json() handles special types automatically
json_str = invoice.model_dump_json(indent=2)
print(json_str)
# model_dump() vs model_dump_json() with special types
dict_data = invoice.model_dump()
print(type(dict_data["id"])) # UUID object
print(type(dict_data["amount"])) # Decimal object
# model_dump with mode="json" converts to JSON-compatible types
json_dict = invoice.model_dump(mode="json")
print(type(json_dict["id"])) # str
print(type(json_dict["amount"])) # str
from pydantic import BaseModel, field_serializer
from datetime import datetime
from decimal import Decimal
class Transaction(BaseModel):
id: int
amount: Decimal
currency: str = "USD"
timestamp: datetime
description: str
@field_serializer("amount")
def serialize_amount(self, value: Decimal) -> str:
return f"{value:.2f}"
@field_serializer("timestamp")
def serialize_timestamp(self, value: datetime) -> str:
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
@field_serializer("description")
def serialize_description(self, value: str) -> str:
if len(value) > 100:
return value[:97] + "..."
return value
tx = Transaction(
id=1,
amount=Decimal("1234.5"),
timestamp=datetime(2024, 3, 15, 14, 30, 0),
description="Payment for consulting services"
)
print(tx.model_dump())
# amount shows as '1234.50', timestamp as '2024-03-15T14:30:00Z'
from pydantic import BaseModel, Field, ConfigDict
class APIItem(BaseModel):
model_config = ConfigDict(populate_by_name=True)
item_id: int = Field(alias="itemId")
item_name: str = Field(alias="itemName")
unit_price: float = Field(alias="unitPrice")
is_available: bool = Field(alias="isAvailable", default=True)
# Parse from camelCase (API input)
item = APIItem(**{"itemId": 1, "itemName": "Widget", "unitPrice": 9.99})
# Or parse using Python names (with populate_by_name=True)
item2 = APIItem(item_id=2, item_name="Gadget", unit_price=19.99)
# Serialize with Python names (default)
print(item.model_dump())
# {'item_id': 1, 'item_name': 'Widget', ...}
# Serialize with camelCase aliases
print(item.model_dump(by_alias=True))
# {'itemId': 1, 'itemName': 'Widget', ...}
Now that you understand Pydantic models, let us see how FastAPI integrates them for automatic request validation. FastAPI uses Pydantic to validate request bodies, query parameters, path parameters, and headers.
from fastapi import FastAPI, Body
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from datetime import datetime
app = FastAPI()
class CreateProductRequest(BaseModel):
name: str = Field(min_length=1, max_length=200, description="Product name")
description: Optional[str] = Field(default=None, max_length=5000)
price: float = Field(gt=0, le=1_000_000, description="Price in USD")
quantity: int = Field(ge=0, le=100_000, default=0)
tags: List[str] = Field(default=[], max_length=20)
sku: str = Field(pattern=r"^[A-Z]{2,4}-\d{4,8}$")
@field_validator("tags")
@classmethod
def normalize_tags(cls, v):
return [tag.lower().strip() for tag in v if tag.strip()]
class ProductResponse(BaseModel):
id: int
name: str
description: Optional[str]
price: float
quantity: int
tags: List[str]
sku: str
created_at: datetime
@app.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(product: CreateProductRequest):
# FastAPI automatically:
# 1. Reads the JSON request body
# 2. Validates it against CreateProductRequest
# 3. Returns 422 with details if validation fails
# 4. Provides the validated model to your function
return ProductResponse(
id=1,
**product.model_dump(),
created_at=datetime.now()
)
# Test with curl:
curl -X POST http://localhost:8000/products \
-H "Content-Type: application/json" \
-d '{
"name": "Wireless Mouse",
"price": 29.99,
"quantity": 100,
"tags": ["electronics", "accessories"],
"sku": "WM-12345"
}'
from fastapi import FastAPI, Query
from typing import Optional, List
from enum import Enum
app = FastAPI()
class SortOrder(str, Enum):
ASC = "asc"
DESC = "desc"
class SortField(str, Enum):
NAME = "name"
PRICE = "price"
CREATED = "created_at"
@app.get("/products")
async def list_products(
search: Optional[str] = Query(default=None, min_length=1, max_length=100),
page: int = Query(default=1, ge=1, le=10000),
page_size: int = Query(default=20, ge=1, le=100),
min_price: Optional[float] = Query(default=None, ge=0),
max_price: Optional[float] = Query(default=None, ge=0),
sort_by: SortField = Query(default=SortField.CREATED),
sort_order: SortOrder = Query(default=SortOrder.DESC),
tags: Optional[List[str]] = Query(default=None, max_length=10),
in_stock: Optional[bool] = Query(default=None),
):
# All query params are validated automatically
# Example: /products?search=mouse&page=1&min_price=10&sort_by=price
return {
"search": search,
"page": page,
"page_size": page_size,
"filters": {
"min_price": min_price,
"max_price": max_price,
"sort_by": sort_by,
"sort_order": sort_order,
"tags": tags,
"in_stock": in_stock
}
}
from fastapi import FastAPI, Path
from enum import Enum
from uuid import UUID
app = FastAPI()
class ResourceType(str, Enum):
USERS = "users"
PRODUCTS = "products"
ORDERS = "orders"
@app.get("/items/{item_id}")
async def get_item(
item_id: int = Path(..., gt=0, le=1_000_000, description="The item ID")
):
return {"item_id": item_id}
@app.get("/users/{user_id}/orders/{order_id}")
async def get_user_order(
user_id: int = Path(..., gt=0),
order_id: int = Path(..., gt=0),
):
return {"user_id": user_id, "order_id": order_id}
# UUID path parameter
@app.get("/resources/{resource_id}")
async def get_resource(resource_id: UUID):
return {"resource_id": str(resource_id)}
# Enum path parameter
@app.get("/api/{resource_type}/{resource_id}")
async def get_any_resource(
resource_type: ResourceType,
resource_id: int = Path(..., gt=0)
):
return {"resource_type": resource_type.value, "resource_id": resource_id}
from fastapi import FastAPI, Header, Cookie
from typing import Optional, List
app = FastAPI()
@app.get("/protected")
async def protected_route(
authorization: str = Header(..., alias="Authorization"),
accept_language: str = Header(default="en", alias="Accept-Language"),
x_request_id: Optional[str] = Header(default=None, alias="X-Request-ID"),
):
return {
"auth": authorization[:20] + "...",
"language": accept_language,
"request_id": x_request_id,
}
@app.get("/preferences")
async def get_preferences(
session_id: Optional[str] = Cookie(default=None),
theme: str = Cookie(default="light"),
language: str = Cookie(default="en"),
):
return {"session_id": session_id, "theme": theme, "language": language}
from fastapi import FastAPI, Body
from pydantic import BaseModel, Field
app = FastAPI()
class Item(BaseModel):
name: str = Field(min_length=1)
price: float = Field(gt=0)
description: str = ""
class UserInfo(BaseModel):
username: str
email: str
@app.post("/purchase")
async def make_purchase(
item: Item,
user: UserInfo,
quantity: int = Body(ge=1, le=100),
notes: str = Body(default=""),
):
# Expected JSON body:
# {
# "item": {"name": "Widget", "price": 9.99},
# "user": {"username": "alice", "email": "alice@example.com"},
# "quantity": 3,
# "notes": "Gift wrap please"
# }
total = item.price * quantity
return {
"item": item.model_dump(),
"user": user.model_dump(),
"quantity": quantity,
"total": total,
"notes": notes
}
# Force single model to use key in body with embed=True
@app.post("/items")
async def create_item(item: Item = Body(embed=True)):
# With embed=True, expects: {"item": {"name": "Widget", "price": 9.99}}
return item
Response models control what data your API returns. They ensure sensitive fields are never leaked, provide consistent output structures, and generate accurate API documentation.
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
app = FastAPI()
# Input model - what the client sends
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: str
password: str = Field(min_length=8)
full_name: Optional[str] = None
# Output model - what the client receives (no password!)
class UserResponse(BaseModel):
id: int
username: str
email: str
full_name: Optional[str] = None
is_active: bool
created_at: datetime
fake_db = {}
next_id = 1
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
# response_model=UserResponse ensures:
# 1. Only fields in UserResponse are returned
# 2. password is NEVER included in the response
# 3. Swagger docs show the correct response schema
global next_id
db_user = {
"id": next_id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"password_hash": f"hashed_{user.password}",
"is_active": True,
"created_at": datetime.now(),
"internal_notes": "New user"
}
fake_db[next_id] = db_user
next_id += 1
# Even though db_user has password_hash and internal_notes,
# response_model filters them out!
return db_user
@app.get("/users", response_model=List[UserResponse])
async def list_users():
return list(fake_db.values())
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
app = FastAPI()
# Shared base
class ProductBase(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: Optional[str] = None
price: float = Field(gt=0)
category: str
# Create input
class ProductCreate(ProductBase):
sku: str = Field(pattern=r"^[A-Z]{2,4}-\d{4,8}$")
initial_stock: int = Field(ge=0, default=0)
# Update input - all fields optional
class ProductUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = None
price: Optional[float] = Field(default=None, gt=0)
category: Optional[str] = None
# Full response
class ProductResponse(ProductBase):
id: int
sku: str
stock: int
is_active: bool
created_at: datetime
updated_at: datetime
# Summary response (for list views)
class ProductSummary(BaseModel):
id: int
name: str
price: float
category: str
in_stock: bool
@app.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(product: ProductCreate):
return {
"id": 1,
**product.model_dump(),
"stock": product.initial_stock,
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
@app.patch("/products/{product_id}", response_model=ProductResponse)
async def update_product(product_id: int, product: ProductUpdate):
updates = product.model_dump(exclude_unset=True)
# In real app: apply updates to database
return {
"id": product_id,
"name": updates.get("name", "Existing Product"),
"description": updates.get("description"),
"price": updates.get("price", 29.99),
"category": updates.get("category", "general"),
"sku": "PROD-1234",
"stock": 50,
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now()
}
@app.get("/products", response_model=list[ProductSummary])
async def list_products():
return [
{"id": 1, "name": "Wireless Mouse", "price": 29.99,
"category": "electronics", "in_stock": True},
{"id": 2, "name": "Keyboard", "price": 59.99,
"category": "electronics", "in_stock": False}
]
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
class UserOut(BaseModel):
id: int
name: str
email: str
bio: Optional[str] = None
avatar: Optional[str] = None
settings: dict = {}
# Exclude None values from response
@app.get("/users/{user_id}", response_model=UserOut,
response_model_exclude_none=True)
async def get_user(user_id: int):
return {
"id": user_id, "name": "Alice", "email": "alice@example.com",
"bio": None, "avatar": None, "settings": {}
}
# Response: {"id": 1, "name": "Alice", "email": "alice@example.com", "settings": {}}
# Exclude specific fields
@app.get("/users/{user_id}/public", response_model=UserOut,
response_model_exclude={"email", "settings"})
async def get_public_user(user_id: int):
return {
"id": user_id, "name": "Alice", "email": "alice@example.com",
"bio": "Developer", "settings": {"theme": "dark"}
}
# Response: {"id": 1, "name": "Alice", "bio": "Developer", "avatar": null}
# Include only specific fields
@app.get("/users/{user_id}/summary", response_model=UserOut,
response_model_include={"id", "name"})
async def get_user_summary(user_id: int):
return {"id": user_id, "name": "Alice", "email": "alice@example.com"}
# Response: {"id": 1, "name": "Alice"}
When validation fails, FastAPI automatically returns a 422 Unprocessable Entity response with detailed error information. You can customize this behavior to return errors in your preferred format.
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class UserCreate(BaseModel):
name: str = Field(min_length=2, max_length=50)
age: int = Field(ge=0, le=150)
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
@app.post("/users")
async def create_user(user: UserCreate):
return user
# Sending invalid data returns 422 with:
# {
# "detail": [
# {
# "type": "string_too_short",
# "loc": ["body", "name"],
# "msg": "String should have at least 2 characters",
# "input": "A",
# "ctx": {"min_length": 2}
# },
# ...
# ]
# }
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from datetime import datetime
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError
):
errors = []
for error in exc.errors():
field_path = " -> ".join(
str(loc) for loc in error["loc"] if loc != "body"
)
errors.append({
"field": field_path,
"message": error["msg"],
"type": error["type"],
"value": error.get("input"),
})
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"status": "error",
"message": "Validation failed",
"error_count": len(errors),
"errors": errors,
"timestamp": datetime.now().isoformat(),
"path": str(request.url)
}
)
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI()
# Simulated database
existing_usernames = {"alice", "bob", "admin"}
existing_emails = {"alice@example.com", "bob@example.com"}
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: str
password: str = Field(min_length=8)
@app.post("/users", status_code=201)
async def create_user(user: UserCreate):
# Pydantic validates structure; we validate business rules
errors = []
if user.username.lower() in existing_usernames:
errors.append({
"field": "username",
"message": f"Username '{user.username}' is already taken"
})
if user.email.lower() in existing_emails:
errors.append({
"field": "email",
"message": f"Email '{user.email}' is already registered"
})
if errors:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"status": "error",
"message": "Registration failed",
"errors": errors
}
)
return {"status": "success", "message": f"User '{user.username}' created"}
from fastapi import FastAPI, Request, HTTPException, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
app = FastAPI()
class ErrorResponse:
@staticmethod
def build(status_code: int, message: str, errors: list = None,
path: str = None) -> dict:
return {
"status": "error",
"status_code": status_code,
"message": message,
"errors": errors or [],
"timestamp": datetime.now().isoformat(),
"path": path
}
# Handle Pydantic validation errors (422)
@app.exception_handler(RequestValidationError)
async def request_validation_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
loc = [str(l) for l in error["loc"] if l != "body"]
errors.append({
"field": ".".join(loc) if loc else "unknown",
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=422,
content=ErrorResponse.build(422, "Request validation failed",
errors, str(request.url))
)
# Handle HTTP exceptions (400, 401, 403, 404, etc.)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse.build(
exc.status_code,
str(exc.detail) if isinstance(exc.detail, str) else "HTTP Error",
[exc.detail] if isinstance(exc.detail, dict) else [],
str(request.url)
)
)
# Handle unexpected exceptions (500)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
logger.error(f"Unhandled error: {exc}")
return JSONResponse(
status_code=500,
content=ErrorResponse.build(500, "An unexpected error occurred",
path=str(request.url))
)
Generic models let you create reusable response wrappers — like paginated responses, API envelopes, or standardized result types — that work with any data model. This is one of Pydantic’s most powerful features for building consistent APIs.
from pydantic import BaseModel
from typing import Generic, TypeVar, Optional, List
T = TypeVar("T")
class APIResponse(BaseModel, Generic[T]):
success: bool = True
message: str = "OK"
data: Optional[T] = None
class UserOut(BaseModel):
id: int
name: str
email: str
class ProductOut(BaseModel):
id: int
name: str
price: float
# Use with different types
user_response = APIResponse[UserOut](
data=UserOut(id=1, name="Alice", email="alice@example.com")
)
print(user_response.model_dump())
# {'success': True, 'message': 'OK',
# 'data': {'id': 1, 'name': 'Alice', 'email': 'alice@example.com'}}
product_response = APIResponse[ProductOut](
data=ProductOut(id=1, name="Laptop", price=999.99)
)
print(product_response.model_dump())
# Error response
error_response = APIResponse[None](
success=False, message="User not found", data=None
)
print(error_response.model_dump())
from pydantic import BaseModel, Field, computed_field
from typing import Generic, TypeVar, List, Optional
from math import ceil
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int = Field(ge=0)
page: int = Field(ge=1)
page_size: int = Field(ge=1, le=100)
@computed_field
@property
def total_pages(self) -> int:
return ceil(self.total / self.page_size) if self.page_size else 0
@computed_field
@property
def has_next(self) -> bool:
return self.page < self.total_pages
@computed_field
@property
def has_previous(self) -> bool:
return self.page > 1
@computed_field
@property
def next_page(self) -> Optional[int]:
return self.page + 1 if self.has_next else None
@computed_field
@property
def previous_page(self) -> Optional[int]:
return self.page - 1 if self.has_previous else None
class UserOut(BaseModel):
id: int
name: str
email: str
# Usage in FastAPI
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/users", response_model=PaginatedResponse[UserOut])
async def list_users(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100)
):
all_users = [
{"id": i, "name": f"User {i}", "email": f"user{i}@example.com"}
for i in range(1, 96)
]
total = len(all_users)
start = (page - 1) * page_size
end = start + page_size
items = all_users[start:end]
return PaginatedResponse[UserOut](
items=items, total=total, page=page, page_size=page_size
)
# Response for page=2, page_size=20:
# {
# "items": [...],
# "total": 95,
# "page": 2,
# "page_size": 20,
# "total_pages": 5,
# "has_next": true,
# "has_previous": true,
# "next_page": 3,
# "previous_page": 1
# }
from pydantic import BaseModel, Field
from typing import Generic, TypeVar, Optional, List
from datetime import datetime
T = TypeVar("T")
class ErrorDetail(BaseModel):
field: Optional[str] = None
message: str
code: str
class APIEnvelope(BaseModel, Generic[T]):
success: bool = True
status_code: int = 200
message: str = "OK"
data: Optional[T] = None
errors: List[ErrorDetail] = []
meta: dict = {}
timestamp: datetime = Field(default_factory=datetime.now)
@classmethod
def ok(cls, data: T, message: str = "OK", meta: dict = None):
return cls(data=data, message=message, meta=meta or {})
@classmethod
def error(cls, status_code: int, message: str,
errors: List[ErrorDetail] = None):
return cls(
success=False, status_code=status_code,
message=message, errors=errors or []
)
@classmethod
def not_found(cls, resource: str, id):
return cls.error(404, f"{resource} with id {id} not found")
# Usage in FastAPI
from fastapi import FastAPI, HTTPException
app = FastAPI()
class UserOut(BaseModel):
id: int
name: str
email: str
@app.get("/users/{user_id}", response_model=APIEnvelope[UserOut])
async def get_user(user_id: int):
if user_id == 1:
return APIEnvelope.ok(
data=UserOut(id=1, name="Alice", email="alice@example.com"),
meta={"cache": "hit"}
)
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
@app.get("/users", response_model=APIEnvelope[List[UserOut]])
async def list_users():
users = [
UserOut(id=1, name="Alice", email="alice@example.com"),
UserOut(id=2, name="Bob", email="bob@example.com"),
]
return APIEnvelope.ok(data=users, meta={"count": len(users)})
FastAPI handles file uploads and HTML form data differently from JSON bodies. You cannot use Pydantic’s BaseModel directly for form fields — instead, you use Form() and File() parameters with the same validation constraints.
from fastapi import FastAPI, Form, HTTPException
app = FastAPI()
@app.post("/login")
async def login(
username: str = Form(..., min_length=3, max_length=50),
password: str = Form(..., min_length=8),
remember_me: bool = Form(default=False),
):
# Accepts application/x-www-form-urlencoded or multipart/form-data
if username == "admin" and password == "Admin!234":
return {"status": "success", "username": username}
raise HTTPException(status_code=401, detail="Invalid credentials")
@app.post("/contact")
async def submit_contact_form(
name: str = Form(..., min_length=2, max_length=100),
email: str = Form(..., pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$"),
subject: str = Form(..., min_length=5, max_length=200),
message: str = Form(..., min_length=20, max_length=5000),
category: str = Form(default="general"),
priority: int = Form(default=3, ge=1, le=5),
):
return {
"status": "submitted",
"contact": {
"name": name,
"email": email,
"subject": subject,
"message_length": len(message),
"category": category,
"priority": priority,
}
}
from fastapi import FastAPI, File, UploadFile, HTTPException, status
from typing import List
app = FastAPI()
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_DOC_TYPES = {"application/pdf", "text/plain", "text/csv"}
def validate_image(file: UploadFile) -> None:
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Type '{file.content_type}' not allowed. "
f"Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}"
)
@app.post("/upload/avatar")
async def upload_avatar(file: UploadFile = File(...)):
validate_image(file)
contents = await file.read()
size = len(contents)
if size > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large. Max: {MAX_FILE_SIZE // (1024*1024)}MB"
)
await file.seek(0)
return {
"filename": file.filename,
"content_type": file.content_type,
"size_bytes": size,
"size_mb": round(size / (1024 * 1024), 2)
}
@app.post("/upload/documents")
async def upload_documents(
files: List[UploadFile] = File(..., description="Max 10 files")
):
if len(files) > 10:
raise HTTPException(400, "Maximum 10 files allowed")
results = []
errors = []
allowed = ALLOWED_IMAGE_TYPES | ALLOWED_DOC_TYPES
for idx, file in enumerate(files):
if file.content_type not in allowed:
errors.append({"file": file.filename, "error": "Type not allowed"})
continue
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
errors.append({"file": file.filename, "error": "Too large"})
continue
results.append({
"filename": file.filename,
"content_type": file.content_type,
"size_bytes": len(contents)
})
return {"uploaded": len(results), "failed": len(errors),
"files": results, "errors": errors}
from fastapi import FastAPI, File, Form, UploadFile, HTTPException
from typing import List
app = FastAPI()
@app.post("/products")
async def create_product_with_image(
# Form fields
name: str = Form(..., min_length=1, max_length=200),
description: str = Form(default="", max_length=5000),
price: float = Form(..., gt=0),
category: str = Form(..., min_length=1),
tags: str = Form(default=""), # comma-separated
# File fields
main_image: UploadFile = File(...),
additional_images: List[UploadFile] = File(default=[]),
):
# When mixing Form and File, request must use multipart/form-data
if main_image.content_type not in {"image/jpeg", "image/png", "image/webp"}:
raise HTTPException(400, "Main image must be JPEG, PNG, or WebP")
if len(additional_images) > 5:
raise HTTPException(400, "Maximum 5 additional images")
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
main_size = len(await main_image.read())
additional_info = []
for img in additional_images:
content = await img.read()
additional_info.append({
"filename": img.filename, "size": len(content)
})
return {
"product": {
"name": name, "description": description,
"price": price, "category": category, "tags": tag_list
},
"images": {
"main": {"filename": main_image.filename, "size": main_size},
"additional": additional_info
}
}
# Test with curl: curl -X POST http://localhost:8000/products \ -F "name=Wireless Mouse" \ -F "price=29.99" \ -F "category=electronics" \ -F "tags=sale,featured" \ -F "main_image=@mouse.jpg" \ -F "additional_images=@mouse_side.jpg"
Let us bring everything together in a comprehensive user registration API with multi-step validation, address handling, password strength checking, and email verification.
registration_system/ ├── main.py # FastAPI application and routes ├── models/ │ ├── __init__.py │ ├── user.py # User Pydantic models │ ├── address.py # Address models with validation │ ├── common.py # Shared/generic models │ └── validators.py # Reusable validators ├── services/ │ ├── __init__.py │ └── user_service.py # Business logic └── requirements.txt
# models/validators.py
import re
from typing import Annotated
from pydantic import AfterValidator
def validate_password_strength(password: str) -> str:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
if len(password) > 128:
raise ValueError("Password must not exceed 128 characters")
if not re.search(r"[A-Z]", password):
raise ValueError("Must contain at least one uppercase letter")
if not re.search(r"[a-z]", password):
raise ValueError("Must contain at least one lowercase letter")
if not re.search(r"\d", password):
raise ValueError("Must contain at least one digit")
if not re.search(r"[!@#$%^&*()_+\-=\[\]{}|;:,./?\\\"]", password):
raise ValueError("Must contain at least one special character")
common_patterns = ["password", "12345678", "qwerty", "abc123"]
if password.lower() in common_patterns:
raise ValueError("Password is too common")
return password
def validate_phone_number(phone: str) -> str:
cleaned = phone.strip()
if cleaned.startswith("+"):
digits = "+" + re.sub(r"\D", "", cleaned[1:])
else:
digits = re.sub(r"\D", "", cleaned)
if not digits.startswith("+"):
if len(digits) == 10:
digits = "+1" + digits
elif len(digits) == 11 and digits.startswith("1"):
digits = "+" + digits
else:
raise ValueError("Invalid phone number format")
digit_count = len(digits.replace("+", ""))
if digit_count < 10 or digit_count > 15:
raise ValueError("Phone number must be 10-15 digits")
return digits
def validate_username(username: str) -> str:
username = username.strip().lower()
if len(username) < 3:
raise ValueError("Username must be at least 3 characters")
if len(username) > 30:
raise ValueError("Username must not exceed 30 characters")
if not re.match(r"^[a-z][a-z0-9_]*$", username):
raise ValueError("Must start with letter; only lowercase, numbers, underscores")
if "__" in username:
raise ValueError("No consecutive underscores")
reserved = {"admin", "root", "system", "api", "null", "undefined", "support"}
if username in reserved:
raise ValueError(f"Username '{username}' is reserved")
return username
# Create reusable annotated types
StrongPassword = Annotated[str, AfterValidator(validate_password_strength)]
PhoneNumber = Annotated[str, AfterValidator(validate_phone_number)]
Username = Annotated[str, AfterValidator(validate_username)]
# models/address.py
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, Literal
US_STATES = {
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA",
"HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD",
"MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC",
"SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY",
"DC", "PR", "VI", "GU", "AS", "MP"
}
class Address(BaseModel):
street_line_1: str = Field(min_length=1, max_length=200)
street_line_2: Optional[str] = Field(default=None, max_length=200)
city: str = Field(min_length=1, max_length=100)
state: str = Field(min_length=2, max_length=2)
zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
country: Literal["US"] = "US"
@field_validator("state")
@classmethod
def validate_state(cls, v: str) -> str:
v = v.upper().strip()
if v not in US_STATES:
raise ValueError(f"Invalid US state code: {v}")
return v
@field_validator("city")
@classmethod
def normalize_city(cls, v: str) -> str:
return v.strip().title()
class AddressCreate(Address):
address_type: Literal["home", "work", "billing", "shipping"] = "home"
is_primary: bool = False
@model_validator(mode="after")
def validate_po_box(self):
if self.address_type == "shipping":
street = self.street_line_1.lower()
if "po box" in street or "p.o. box" in street:
raise ValueError("Shipping address cannot be a PO Box")
return self
# models/user.py
from pydantic import (
BaseModel, Field, ConfigDict, field_validator,
model_validator, computed_field
)
from typing import Optional, List, Annotated
from datetime import date, datetime
from enum import Enum
from pydantic import AfterValidator
import re
# Inline password validator for self-contained example
def _validate_password(p: str) -> str:
if len(p) < 8:
raise ValueError("Password must be at least 8 characters")
if not re.search(r"[A-Z]", p):
raise ValueError("Must contain uppercase letter")
if not re.search(r"[a-z]", p):
raise ValueError("Must contain lowercase letter")
if not re.search(r"\d", p):
raise ValueError("Must contain digit")
if not re.search(r"[!@#$%^&*()\-_=+]", p):
raise ValueError("Must contain special character")
return p
StrongPassword = Annotated[str, AfterValidator(_validate_password)]
class Gender(str, Enum):
MALE = "male"
FEMALE = "female"
NON_BINARY = "non_binary"
PREFER_NOT_TO_SAY = "prefer_not_to_say"
class NotificationPreference(str, Enum):
EMAIL = "email"
SMS = "sms"
PUSH = "push"
NONE = "none"
class UserRegistration(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
# Account Info
username: str = Field(
min_length=3, max_length=30,
pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$"
)
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w{2,}$")
password: StrongPassword
confirm_password: str
# Personal Info
first_name: str = Field(min_length=1, max_length=50)
last_name: str = Field(min_length=1, max_length=50)
date_of_birth: date
gender: Optional[Gender] = None
phone: Optional[str] = Field(default=None, pattern=r"^\+?1?\d{10,15}$")
# Preferences
notifications: List[NotificationPreference] = Field(
default=[NotificationPreference.EMAIL]
)
newsletter: bool = False
terms_accepted: bool
@field_validator("username")
@classmethod
def normalize_username(cls, v: str) -> str:
return v.lower()
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
return v.lower()
@field_validator("first_name", "last_name")
@classmethod
def capitalize_name(cls, v: str) -> str:
return v.strip().title()
@field_validator("date_of_birth")
@classmethod
def validate_age(cls, v: date) -> date:
today = date.today()
age = today.year - v.year - ((today.month, today.day) < (v.month, v.day))
if age < 13:
raise ValueError("Must be at least 13 years old")
if age > 120:
raise ValueError("Invalid date of birth")
return v
@field_validator("terms_accepted")
@classmethod
def must_accept_terms(cls, v: bool) -> bool:
if not v:
raise ValueError("You must accept the terms and conditions")
return v
@model_validator(mode="after")
def validate_registration(self):
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
if self.username.lower() in self.password.lower():
raise ValueError("Password must not contain your username")
if NotificationPreference.SMS in self.notifications and not self.phone:
raise ValueError("Phone number required for SMS notifications")
return self
@computed_field
@property
def display_name(self) -> str:
return f"{self.first_name} {self.last_name}"
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: str
first_name: str
last_name: str
display_name: str
date_of_birth: date
gender: Optional[Gender]
phone: Optional[str]
notifications: List[NotificationPreference]
is_active: bool
is_verified: bool
created_at: datetime
class UserUpdate(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
first_name: Optional[str] = Field(default=None, min_length=1, max_length=50)
last_name: Optional[str] = Field(default=None, min_length=1, max_length=50)
phone: Optional[str] = Field(default=None, pattern=r"^\+?1?\d{10,15}$")
gender: Optional[Gender] = None
notifications: Optional[List[NotificationPreference]] = None
newsletter: Optional[bool] = None
# main.py
from fastapi import FastAPI, HTTPException, Request, Query, Path, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from datetime import datetime
from typing import Optional
import secrets
import re
app = FastAPI(
title="User Registration System",
description="Complete registration with Pydantic validation",
version="1.0.0"
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
loc = [str(l) for l in error["loc"] if l != "body"]
errors.append({
"field": ".".join(loc) if loc else "general",
"message": error["msg"],
"type": error["type"]
})
return JSONResponse(
status_code=422,
content={
"success": False, "message": "Validation failed",
"errors": errors, "timestamp": datetime.now().isoformat()
}
)
# Simulated Database
users_db: dict = {}
next_user_id = 1
email_verifications: dict = {}
@app.post("/api/v1/register", status_code=201)
async def register_user(registration):
global next_user_id
for user in users_db.values():
if user["username"] == registration.username:
raise HTTPException(409, {"field": "username", "message": "Already taken"})
if user["email"] == registration.email:
raise HTTPException(409, {"field": "email", "message": "Already registered"})
user_data = {
"id": next_user_id,
"username": registration.username,
"email": registration.email,
"first_name": registration.first_name,
"last_name": registration.last_name,
"display_name": registration.display_name,
"date_of_birth": registration.date_of_birth.isoformat(),
"gender": registration.gender.value if registration.gender else None,
"phone": registration.phone,
"notifications": [n.value for n in registration.notifications],
"is_active": True,
"is_verified": False,
"created_at": datetime.now().isoformat()
}
users_db[next_user_id] = user_data
token = secrets.token_urlsafe(32)
email_verifications[token] = next_user_id
next_user_id += 1
return {
"success": True,
"message": "Registration successful! Check your email to verify.",
"data": {
"id": user_data["id"],
"username": user_data["username"],
"email": user_data["email"],
"display_name": user_data["display_name"]
}
}
@app.get("/api/v1/verify-email/{token}")
async def verify_email(token: str):
user_id = email_verifications.get(token)
if not user_id or user_id not in users_db:
raise HTTPException(400, "Invalid or expired verification token")
users_db[user_id]["is_verified"] = True
del email_verifications[token]
return {"success": True, "message": "Email verified successfully!"}
@app.get("/api/v1/users/{user_id}")
async def get_user(user_id: int = Path(..., gt=0)):
user = users_db.get(user_id)
if not user:
raise HTTPException(404, f"User {user_id} not found")
return {"success": True, "data": user}
@app.get("/api/v1/users")
async def list_users(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
search: Optional[str] = Query(default=None, min_length=1),
):
filtered = list(users_db.values())
if search:
s = search.lower()
filtered = [u for u in filtered
if s in u["username"] or s in u["email"]
or s in u["display_name"].lower()]
total = len(filtered)
start = (page - 1) * page_size
items = filtered[start:start + page_size]
return {
"success": True,
"data": {
"items": items, "total": total,
"page": page, "page_size": page_size,
"total_pages": -(-total // page_size)
}
}
@app.get("/api/v1/check-username/{username}")
async def check_username(username: str = Path(..., min_length=3, max_length=30)):
taken = any(u["username"] == username.lower() for u in users_db.values())
return {"username": username.lower(), "available": not taken}
@app.get("/api/v1/password-strength")
async def check_password_strength(
password: str = Query(..., min_length=1)
):
score = 0
feedback = []
if len(password) >= 8: score += 1
else: feedback.append("Use at least 8 characters")
if len(password) >= 12: score += 1
if len(password) >= 16: score += 1
if re.search(r"[A-Z]", password): score += 1
else: feedback.append("Add an uppercase letter")
if re.search(r"[a-z]", password): score += 1
else: feedback.append("Add a lowercase letter")
if re.search(r"\d", password): score += 1
else: feedback.append("Add a number")
if re.search(r"[!@#$%^&*()\-_=+]", password): score += 1
else: feedback.append("Add a special character")
if score <= 2: strength = "weak"
elif score <= 4: strength = "fair"
elif score <= 5: strength = "good"
else: strength = "strong"
return {
"score": score, "max_score": 7, "strength": strength,
"feedback": feedback, "meets_requirements": score >= 4
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
# Start the server
uvicorn main:app --reload
# 1. Check username availability
curl http://localhost:8000/api/v1/check-username/alice_dev
# 2. Check password strength
curl "http://localhost:8000/api/v1/password-strength?password=Str0ng!Pass"
# 3. Register a new user
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"username": "alice_dev",
"email": "alice@example.com",
"password": "Str0ng!Pass#1",
"confirm_password": "Str0ng!Pass#1",
"first_name": "Alice",
"last_name": "Johnson",
"date_of_birth": "1995-03-15",
"gender": "female",
"phone": "+15551234567",
"notifications": ["email", "sms"],
"newsletter": true,
"terms_accepted": true
}'
# 4. Try invalid registration (triggers multiple errors)
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"username": "a",
"email": "not-email",
"password": "weak",
"confirm_password": "different",
"first_name": "",
"last_name": "Johnson",
"date_of_birth": "2020-01-01",
"terms_accepted": false
}'
# 5. List users
curl "http://localhost:8000/api/v1/users?page=1&page_size=10"
# 6. Get specific user
curl http://localhost:8000/api/v1/users/1
Here is a summary of everything you have learned about Pydantic models and validation in FastAPI:
| Topic | Key Points |
|---|---|
| BaseModel | Inherit from BaseModel, define fields with type annotations, use defaults and Optional |
| Field() | Add constraints: min_length, max_length, gt, lt, ge, le, pattern, multiple_of |
| Type Annotations | Use str, int, float, bool, List, Dict, Optional, Union, Literal, Annotated |
| Nested Models | Compose models within models; validation is recursive and automatic |
| @field_validator | Custom validation for individual fields; use mode="before" for pre-processing |
| @model_validator | Cross-field validation after all fields are set; use for password confirmation, date ranges |
| @computed_field | Derived values included in serialization; use with @property |
| model_config | extra="forbid", str_strip_whitespace, from_attributes, frozen |
| Serialization | model_dump() with exclude, include, exclude_none, exclude_unset, by_alias |
| Request Validation | Body, Query, Path, Header, Cookie — all validated automatically by FastAPI |
| Response Models | response_model filters output; use different input/output schemas to protect sensitive data |
| Error Handling | Customize RequestValidationError handler for consistent API error format |
| Generic Models | Generic[T] for reusable wrappers: paginated responses, API envelopes |
| Form & Files | Use Form() and File() for non-JSON input; cannot use BaseModel directly |
extra="forbid" on input models to catch typos and unexpected fieldsAnnotated types for reusable validation logic across models@field_validator and cross-field checks in @model_validatormodel_dump(exclude_unset=True) for PATCH endpoints to only update provided fieldsfrom_attributes=True when working with SQLAlchemy ORM objectsIn the next tutorial, FastAPI – Testing, you will learn how to write comprehensive tests for all of these validation rules using pytest and FastAPI’s TestClient. You will see how to test valid inputs, invalid inputs, edge cases, and custom error responses — ensuring your validation logic is bulletproof.