FastAPI – Pydantic Models & Validation

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.

Prerequisites: You should have completed the previous tutorials in this FastAPI series (Introduction & Setup, Routes & Request Handling, REST API, and Database Integration). You need Python 3.10+ and FastAPI 0.100+ installed.
Source Code: All examples in this tutorial are self-contained. Create a project folder called fastapi-pydantic and follow along step by step.

1. What is Pydantic?

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.

1.1 Why FastAPI Chose Pydantic

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

1.2 Pydantic v2 Key Features

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

1.3 Installation

# 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

1.4 Your First Pydantic Model

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

2. Basic Models

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.

2.1 BaseModel Fundamentals

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

2.2 Field Types and Defaults

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

2.3 Required vs Optional Fields

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
Important: In Pydantic v2, 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.

2.4 Model Methods and Properties

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)

3. Field Validation

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.

3.1 Field() Basics

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)

3.2 All Numeric Constraints

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)

3.3 All String Constraints

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

3.4 Field Aliases and Metadata

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}

3.5 Pydantic Built-in Types

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/

4. Type Annotations Deep Dive

Pydantic leverages Python’s type annotation system extensively. Understanding how different type hints translate to validation rules is essential for building robust models.

4.1 Basic Types

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)

4.2 Collection Types

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'})

4.3 Optional, Union, and Literal

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)

4.4 Annotated Types

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
Tip: 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.

5. Nested Models

Real-world data is rarely flat. Pydantic excels at validating deeply nested structures by composing models together. Each nested model is fully validated independently.

5.1 Basic Nesting

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

5.2 Lists of Models

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

5.3 Self-Referential Models

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

6. Custom Validators

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.

6.1 @field_validator Basics

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)

6.2 Validator Modes: Before and After

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)

6.3 Validating Multiple Fields

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

6.4 @model_validator for Cross-Field Validation

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)

6.5 Before Mode Model Validator

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

6.6 ValidationError Details

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))

7. Computed Fields

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.

7.1 @computed_field

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

7.2 Computed Fields with Complex Logic

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

8. Model Configuration

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.

8.1 ConfigDict Basics

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

8.2 Extra Fields Handling

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'}

8.3 ORM Mode (from_attributes)

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

8.4 JSON Schema Customization

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

8.5 Frozen (Immutable) Models

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)

9. Serialization

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().

9.1 model_dump() Basics

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)

9.2 Nested Serialization Control

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'}}

9.3 JSON Serialization

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

9.4 Custom Serialization

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'

9.5 Alias-Based Serialization

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', ...}

10. Request Validation in FastAPI

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.

10.1 Request Body Validation

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"
  }'

10.2 Query Parameter Validation

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
        }
    }

10.3 Path Parameter Validation

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}

10.4 Header and Cookie Validation

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}

10.5 Multiple Body Parameters

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

11. Response Models

Response models control what data your API returns. They ensure sensitive fields are never leaked, provide consistent output structures, and generate accurate API documentation.

11.1 Basic Response Model

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())

11.2 Different Input and Output Schemas

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}
    ]

11.3 Response Model Options

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"}

12. Error Handling

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.

12.1 Default Validation Errors

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}
#         },
#         ...
#     ]
# }

12.2 Custom Error Response Format

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)
        }
    )

12.3 Custom HTTPException with Validation

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"}

12.4 Comprehensive Error Handling Setup

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))
    )

13. Generic Models

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.

13.1 Basic Generic Model

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())

13.2 Paginated Response

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
# }

13.3 Advanced Envelope Pattern

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)})

14. File and Form Data Validation

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.

14.1 Form Fields with Validation

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,
        }
    }

14.2 File Upload Validation

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}

14.3 Mixed Form Data and Files

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"

15. Complete Project: User Registration System

Let us bring everything together in a comprehensive user registration API with multi-step validation, address handling, password strength checking, and email verification.

15.1 Project Structure

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

15.2 Reusable Validators (models/validators.py)

# 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)]

15.3 Address Models (models/address.py)

# 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

15.4 User Models (models/user.py)

# 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

15.5 FastAPI Application (main.py)

# 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)

15.6 Testing the Registration System

# 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
Production Enhancement: In a real application, you would add: database persistence with SQLAlchemy, bcrypt password hashing, JWT authentication, rate limiting, email sending for verification, input sanitization, and CORS configuration. The Pydantic validation layer shown here forms the foundation that all of those features build upon.

16. Key Takeaways

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
Best Practices:

  • Always use extra="forbid" on input models to catch typos and unexpected fields
  • Create separate models for Create, Update, and Response operations
  • Use Annotated types for reusable validation logic across models
  • Put complex validation in @field_validator and cross-field checks in @model_validator
  • Never expose passwords or internal fields — always define a Response model
  • Use model_dump(exclude_unset=True) for PATCH endpoints to only update provided fields
  • Customize the validation error handler for a consistent API error format
  • Use from_attributes=True when working with SQLAlchemy ORM objects

In 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.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *