FastAPI Routing and Validation: A Practical Guide
Routing and validation are most of what makes a FastAPI service clean. Here is how APIRouter, Pydantic constraints, response models and custom errors fit together, plus what changed in 2026.
Routing and validation are the two things FastAPI does for you on every single request: it decides which function handles the call, and it checks that the incoming data matches what that function expects. Getting both right is most of the work of a clean API. This guide walks through how they work, the patterns that scale to a large codebase, and the changes that landed in 2026.
How routing works: APIRouter and include_router
An APIRouter is a mountable group of endpoints with no server of its own. You build one router per resource or domain, each in its own module, then assemble them in your entry point. A router carries a shared prefix, a set of OpenAPI tags, and optional dependencies that run for every route it holds.
# routers/orders.py
from fastapi import APIRouter
router = APIRouter(prefix="/orders", tags=["orders"])
@router.get("")
async def list_orders():
...
@router.get("/{order_id}")
async def get_order(order_id: int):
...
# main.py
from fastapi import FastAPI
from routers import orders, users
app = FastAPI()
app.include_router(orders.router)
app.include_router(users.router)Nested routers and shared dependencies
A router can include another router, so you can compose a tree: an admin router that requires an authenticated admin, holding sub-routers for each admin area. A dependency declared at router level runs before every route in that router, which is the right place for authentication, tenant resolution or rate limiting instead of repeating it on each endpoint.
from fastapi import APIRouter, Depends
from .security import require_admin
from .routers import billing, audit
admin = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])
admin.include_router(billing.router)
admin.include_router(audit.router)Validating path and query parameters
Path and query parameters are validated from their type hints, and Path() and Query() add constraints: ge and le for numbers, min_length, max_length and pattern for strings, plus a default and a description that flow into the OpenAPI schema. A request that breaks a constraint gets an automatic 422 response with a precise list of what failed, before your function runs.
from fastapi import APIRouter, Path, Query
router = APIRouter(prefix="/orders", tags=["orders"])
@router.get("/{order_id}")
async def get_order(
order_id: int = Path(ge=1),
fields: str | None = Query(default=None, pattern="^[a-z_,]+$"),
limit: int = Query(default=20, ge=1, le=100),
):
...Validating request bodies with Pydantic
When a parameter is a Pydantic model, FastAPI reads the request body into it and validates every field. Field() carries the per-field constraints; field_validator handles a single field with custom logic; model_validator runs once the whole object is built, which is where cross-field rules live.
from pydantic import BaseModel, Field, field_validator
class OrderCreate(BaseModel):
customer_id: int = Field(gt=0)
currency: str = Field(default="EUR", pattern="^[A-Z]{3}$")
quantity: int = Field(gt=0, le=999)
@field_validator("currency")
@classmethod
def known_currency(cls, value: str) -> str:
if value not in {"EUR", "USD", "MGA"}:
raise ValueError("unsupported currency")
return valueShaping responses: response_model and status codes
A response_model filters the output through a schema, so internal fields never leak even if your function returns a full database object, and the response shape is documented. Pair it with an explicit status_code, and with response_model_exclude_none when nullable fields should disappear rather than serialize as null.
from fastapi import status
class OrderOut(BaseModel):
id: int
customer_id: int
total: float
@router.post("", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
async def create_order(payload: OrderCreate) -> Order:
return await orders.create(payload) # extra fields are dropped by OrderOutCustomising the 422 validation error
The default 422 body is detailed and machine-readable, but a public API often needs its errors in one consistent envelope, for example an RFC 9457 problem document. Override the RequestValidationError handler: exc.errors() gives you the structured list, so you reshape it without losing any information.
from fastapi import Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"type": "https://api.example.com/errors/validation",
"title": "Invalid request",
"status": 422,
"errors": exc.errors(),
},
media_type="application/problem+json",
)Versioning a FastAPI API
There are three ways to version. A URL prefix (/v1, /v2) is the most common: one router per version, visible in logs, easy to cache, trivial for clients. Header versioning keeps URLs clean but hides the version from caches and logs. Media-type versioning is the most correct in REST terms and the least practical to debug.
| Approach | Visible in logs / caches | Client effort | Routing complexity |
|---|---|---|---|
| URL prefix (/v1) | Yes | Low | Low, one router per version |
| Header (X-API-Version) | No | Medium | Medium, custom matching |
| Media type (Accept) | No | High | High |
For a public API, use the URL prefix. Keep the previous version running until your clients have migrated, and announce a removal date.
What changed in FastAPI in 2026
FastAPI still ships incremental releases rather than a 1.0 rewrite. Version 0.139 (July 2026) is a good snapshot of the direction: more control over routing, stricter validation by default, and a smoother path for codebases still on Pydantic v1.
- ✓APIRouter gained matches() and handle(): a router can decide for itself whether it handles a request, which makes header-based versioning a first-class pattern.
- ✓Routes added to a router after it was included in the app now register correctly, instead of being silently ignored.
- ✓Incoming JSON is checked for a valid Content-Type header before parsing, rejecting mislabelled requests instead of misparsing them.
- ✓Importing from pydantic.v1 lets v1 and v2 models coexist in one app, turning a big-bang migration into a model-by-model one.
- ✓Python 3.14 is supported, Python 3.8 is dropped from CI, and the internal syntax now targets 3.9+.
from fastapi import APIRouter, Request
class HeaderVersionedRouter(APIRouter):
def __init__(self, *args, version: str, **kwargs):
super().__init__(*args, **kwargs)
self.version = version
def matches(self, request: Request) -> bool:
return request.headers.get("X-API-Version") == self.version
router_v1 = HeaderVersionedRouter(version="1")
router_v2 = HeaderVersionedRouter(version="2")The stricter Content-Type check is the one behaviour change to test for if your clients are loosely typed. The opt-out is a per-route strict_content_type=False flag: use it deliberately for a legacy integration, not as a blanket default.
FAQ
- How should I structure routes in a large FastAPI app?
- One APIRouter per resource or domain, each in its own module with its own prefix and tags, assembled in main.py with include_router. Put cross-cutting concerns such as authentication or rate limiting in router-level dependencies rather than repeating them on every endpoint.
- What is the difference between APIRouter and the FastAPI app?
- FastAPI is the application that runs; APIRouter is a mountable group of routes with no server of its own. You build routers in feature modules and include them into the app, or into a parent router. It keeps a large codebase navigable and lets you apply a shared prefix and dependencies per group.
- How do I customise the 422 validation error in FastAPI?
- Register an exception handler for RequestValidationError and return your own response shape, for example an RFC 9457 problem document. exc.errors() gives you the structured list of what failed, so you map it to your envelope without losing detail.
- How do I version a FastAPI API?
- The most common approach is a URL prefix (/v1, /v2) with one router per version: visible in logs, easy to cache, simple for clients. Header or media-type versioning keeps URLs clean but is harder to debug and cache.
- Should I migrate from Pydantic v1 to v2?
- Yes, but incrementally. Since 2026 you can import from pydantic.v1 so v1 and v2 models coexist in the same app, which turns a risky big-bang migration into a model-by-model one. v2 is significantly faster and is where new features land.
Routing and validation in FastAPI reward a small amount of structure: routers per domain, constraints on every parameter, a response_model on every endpoint, and one consistent error shape. Do that and the framework handles the rest on every request.
Need help with this topic? REST API Design
Discover this service →