Python Request Validation Patterns — Core Concepts

Why request validation matters

Invalid data that slips past the API boundary causes cascading failures: database constraint violations, runtime TypeErrors buried deep in business logic, corrupted state that surfaces hours later. Catching bad data at the entry point is both cheaper and safer.

Layers of validation

Request validation is not a single check — it happens in layers:

Syntactic validation — Is the JSON well-formed? Are required fields present? Is the age field actually a number? This layer catches structural problems.

Semantic validation — Does the value make sense? An age of -5 is syntactically valid (it is a number) but semantically wrong. A start date after an end date is another example.

Business rule validation — Is this email already registered? Does this user have permission to create this resource? This layer requires database lookups or service calls.

Each layer should return clear, specific errors rather than a generic “bad request.”

Pydantic: the Python standard

Pydantic dominates Python request validation. FastAPI uses it natively, and it integrates with Flask, Django, and standalone scripts.

A Pydantic model defines the expected shape:

from pydantic import BaseModel, EmailStr, Field
from datetime import date

class CreateUserRequest(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    email: EmailStr
    birth_date: date
    role: str = Field(pattern=r"^(admin|editor|viewer)$")

When FastAPI receives a request, Pydantic validates automatically. Invalid data returns a 422 response with field-level error details — no manual checking needed.

Key validation patterns

Constrained types — Use Field() to set boundaries: min_length, max_length, ge (greater or equal), le, pattern for regex. This replaces manual if-statements.

Custom validators — For rules that go beyond simple constraints:

from pydantic import field_validator

class DateRangeRequest(BaseModel):
    start_date: date
    end_date: date

    @field_validator("end_date")
    @classmethod
    def end_after_start(cls, v, info):
        if "start_date" in info.data and v <= info.data["start_date"]:
            raise ValueError("end_date must be after start_date")
        return v

Nested models — Complex requests use composition:

class Address(BaseModel):
    street: str
    city: str
    country: str = Field(min_length=2, max_length=2)

class CreateOrderRequest(BaseModel):
    items: list[OrderItem]
    shipping_address: Address
    billing_address: Address | None = None

Discriminated unions — When a request can be one of several types:

from typing import Literal
from pydantic import Discriminator

class CardPayment(BaseModel):
    method: Literal["card"]
    card_number: str
    expiry: str

class BankPayment(BaseModel):
    method: Literal["bank"]
    account_number: str
    routing_number: str

class PaymentRequest(BaseModel):
    payment: CardPayment | BankPayment

Path and query parameter validation

Validation applies beyond request bodies. FastAPI validates path parameters and query strings:

from fastapi import Query, Path

@app.get("/users/{user_id}/orders")
async def get_orders(
    user_id: int = Path(gt=0),
    status: str = Query(default="all", pattern="^(all|pending|shipped)$"),
    limit: int = Query(default=20, ge=1, le=100),
):
    ...

Common misconception

Some developers validate in the business logic layer and skip API-level validation. This is risky because business logic assumes well-typed input. If a string arrives where an integer is expected, the error message will be cryptic and buried in a stack trace instead of a clean 422 at the boundary.

The correct approach: validate structure and types at the API boundary with Pydantic, then validate business rules in the service layer. Both layers are necessary.

Often overlooked, headers need validation too. API keys, authorization tokens, and content types should all be validated at the boundary:

from fastapi import Header

@app.post("/webhooks")
async def receive_webhook(
    x_webhook_signature: str = Header(...),
    content_type: str = Header(..., pattern="^application/json$"),
):
    ...

The one thing to remember: Validate in layers — types and structure at the API boundary with Pydantic, business rules in the service layer — and always return specific field-level errors.

pythonapivalidationpydantic

See Also