APIRouter is a mini-app that collects route definitions and is then mounted
onto the main FastAPI instance. It lets you split routes across files without
a circular import on the app object.
# routers/items.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
async def list_items(): ...
@router.post("/")
async def create_item(): ...
# main.py
from fastapi import FastAPI
from routers import items
app = FastAPI()
app.include_router(items.router, prefix="/items", tags=["items"])
Rule of thumb: one router per resource/domain; import and mount them all in
main.py — this is the FastAPI equivalent of Flask Blueprints.
Pass dependencies=[Depends(fn)] to the APIRouter constructor or to
include_router:
from fastapi import APIRouter, Depends
from .auth import verify_api_key
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(verify_api_key)],
)
@router.get("/stats")
async def stats(): ... # verify_api_key runs automatically
Dependencies at the router level run before route-level dependencies. Both sets stack — they don't replace each other.
Rule of thumb: put auth/rate-limit dependencies on the router so new routes can't accidentally bypass them.
Call parent_router.include_router(child_router, prefix=...):
# routers/orders/items.py
items_router = APIRouter()
@items_router.get("/")
async def list_order_items(): ...
# routers/orders/__init__.py
orders_router = APIRouter(prefix="/orders")
orders_router.include_router(items_router, prefix="/{order_id}/items")
# main.py
app.include_router(orders_router)
# → GET /orders/{order_id}/items/
Nesting is unlimited; prefixes concatenate.
Rule of thumb: nest routers to mirror resource hierarchy in the URL — it keeps path prefixes DRY and makes the app structure easy to navigate.
Not directly on APIRouter, but you can set responses (error response docs)
and dependencies at the router level. Default response_model and status_code
must still be set per-route.
A common pattern is to use route_class to inject default behaviour:
from fastapi.routing import APIRoute
class LoggedRoute(APIRoute):
def get_route_handler(self):
original = super().get_route_handler()
async def custom(request):
log_request(request)
return await original(request)
return custom
router = APIRouter(route_class=LoggedRoute)
Rule of thumb: use route_class for cross-cutting concerns (logging, timing);
set response_model and status_code per route for explicitness.
Pass responses to include_router. These are merged with per-route responses
in the OpenAPI schema.
app.include_router(
admin_router,
prefix="/admin",
responses={
401: {"description": "Not authenticated"},
403: {"description": "Forbidden"},
},
)
All routes in admin_router now document 401 and 403 in the schema without
repeating it on every decorator.
Rule of thumb: declare common error responses (401, 403, 429) at the router level; declare business-logic errors (404, 409) at the individual route level.
include_router integrates routes into the same FastAPI app — they share
middleware, exception handlers, dependency injection and the OpenAPI schema.
mount attaches a separate ASGI application at a path prefix. The mounted
app has its own middleware and schema; requests to its prefix are fully delegated.
# include_router — routes join the parent app
app.include_router(users_router, prefix="/users")
# mount — separate sub-app, own /docs
v2_app = FastAPI()
app.mount("/v2", v2_app)
mount is appropriate for: serving static files (StaticFiles), mounting a
separate versioned API that has diverged significantly, or embedding non-FastAPI
ASGI apps.
Rule of thumb: use include_router for 95% of cases; use mount when you
genuinely need a separate ASGI application with independent middleware.
app/
├── main.py # FastAPI() instance, include all routers
├── dependencies.py # shared Depends() functions
├── models/
│ ├── user.py # Pydantic + ORM models
│ └── order.py
├── routers/
│ ├── users.py # APIRouter for /users
│ └── orders.py # APIRouter for /orders
├── services/
│ ├── user_service.py
│ └── order_service.py
└── db/
├── session.py # engine + get_db dependency
└── models.py # SQLAlchemy ORM models
# main.py
from fastapi import FastAPI
from app.routers import users, orders
app = FastAPI()
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(orders.router, prefix="/orders", tags=["orders"])
Rule of thumb: keep route handlers thin — they call service functions, not business logic directly; separate ORM models from Pydantic schemas.
No — APIRouter has no built-in lifespan support. Lifespan events belong to
the FastAPI app. For modular startup/shutdown, compose context managers inside
the single app lifespan:
from contextlib import asynccontextmanager
@asynccontextmanager
async def db_lifespan(app):
await db.connect()
yield
await db.disconnect()
@asynccontextmanager
async def cache_lifespan(app):
await cache.connect()
yield
await cache.disconnect()
@asynccontextmanager
async def app_lifespan(app):
async with db_lifespan(app):
async with cache_lifespan(app):
yield
app = FastAPI(lifespan=app_lifespan)
Rule of thumb: compose multiple async context managers inside the app's lifespan function rather than trying to distribute startup across routers.
Pass default_response_class to the APIRouter:
from fastapi import APIRouter
from fastapi.responses import ORJSONResponse
router = APIRouter(default_response_class=ORJSONResponse)
@router.get("/items")
async def list_items():
return [{"id": 1}] # serialised via orjson automatically
Or set it on the FastAPI() instance to affect all routes:
app = FastAPI(default_response_class=ORJSONResponse)
Rule of thumb: set ORJSONResponse globally on the app for consistent fast
serialisation; override per-route only for special cases like HTMLResponse.
Wrap the router in a minimal FastAPI app in the test file:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.routers.users import router
test_app = FastAPI()
test_app.include_router(router, prefix="/users")
client = TestClient(test_app)
def test_list_users():
resp = client.get("/users/")
assert resp.status_code == 200
Override dependencies on test_app with test_app.dependency_overrides to
inject fakes without touching the real app object.
Rule of thumb: always test routers through a minimal FastAPI wrapper — it
exercises the full request/response pipeline including middleware and DI.
Use a simple if guard before include_router:
import os
from fastapi import FastAPI
from app.routers import debug_tools
app = FastAPI()
if os.getenv("ENV") == "development":
app.include_router(debug_tools.router, prefix="/debug")
This is evaluated at import time, so the routes are never registered in production even if someone guesses the URL.
Rule of thumb: gate debug/admin routers behind an env check at startup rather
than include_in_schema=False — environment-gated routes truly don't exist in
production, not just hidden from docs.
Two approaches:
Option A — prefix every include_router call:
app.include_router(users_router, prefix="/api/v1/users")
Option B — mount the FastAPI app on a prefix using Starlette's root_path:
app = FastAPI(root_path="/api/v1")
root_path adjusts the OpenAPI servers base URL but doesn't actually prefix routes
in the ASGI routing — use a reverse proxy rewrite for that.
The cleanest approach is to create a single top-level router:
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(users_router, prefix="/users")
app.include_router(api_router)
Rule of thumb: collect all routers into one api_router with the version prefix,
then mount it once — adding a new version means creating a second top-level router.
More Routing & Parameters interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.