Skip to main content

Building Scalable FastAPI Routers

APIRouter is FastAPI's mechanism for grouping related endpoints into modular blocks, applying shared prefixes and tags, then including them in a main application. As your API grows from a dozen routes to hundreds, a flat structure becomes unmanageable. Routers let you organize by domain (users, products, orders), apply versioning (v1, v2), and reuse common configuration—each router is a mini-app that the main app composes.

I've scaled FastAPI APIs from prototype to production by starting with a single file and refactoring incrementally into routers. This guide shows you the patterns that keep code discoverable and dependencies clear.

Why Routers Beat Flat Route Files

Without routers, a growing API becomes one massive file. Every endpoint is declared on the same app object, tags are repeated, dependencies are scattered. A new engineer opening the codebase spends an hour scrolling to understand the structure. Routers solve this by grouping logical endpoints:

# Bad: all routes in one file (main.py)
from fastapi import FastAPI

app = FastAPI()

@app.get("/users")
def list_users():
pass

@app.get("/users/{user_id}")
def get_user(user_id: int):
pass

@app.post("/users")
def create_user():
pass

@app.get("/products")
def list_products():
pass

# ... 200 more endpoints in same file

This is unmaintainable. Instead, create routers for each domain:

# Good: organized with routers
from fastapi import FastAPI, APIRouter

# routers/users.py
users_router = APIRouter(prefix="/users", tags=["users"])

@users_router.get("/")
def list_users():
pass

@users_router.get("/{user_id}")
def get_user(user_id: int):
pass

@users_router.post("/")
def create_user():
pass

# routers/products.py
products_router = APIRouter(prefix="/products", tags=["products"])

@products_router.get("/")
def list_products():
pass

# main.py
app = FastAPI()
app.include_router(users_router)
app.include_router(products_router)

Each file has ~10-20 related endpoints. Tags are applied once, prefixes are consistent, and the file structure mirrors the API hierarchy.

Creating and Including Routers

An APIRouter is instantiated like a mini-app. You decorate its methods instead of app:

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

router = APIRouter(
prefix="/items",
tags=["items"],
responses={404: {"description": "Item not found"}}
)

@router.get("/")
async def list_items(
skip: int = 0,
limit: int = 10,
db: AsyncSession = Depends(get_db)
):
"""Retrieve all items with pagination."""
items = await db.execute(
select(Item).offset(skip).limit(limit)
)
return items.scalars().all()

@router.get("/{item_id}")
async def get_item(
item_id: int,
db: AsyncSession = Depends(get_db)
):
"""Fetch a single item by ID."""
item = await db.get(Item, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item

@router.post("/")
async def create_item(
item: ItemCreate,
db: AsyncSession = Depends(get_db)
):
"""Create a new item."""
db_item = Item(**item.dict())
db.add(db_item)
await db.commit()
await db.refresh(db_item)
return db_item

The prefix prepends /items to all routes, so @router.get("/") becomes GET /items. The tags parameter groups these endpoints in the auto-generated OpenAPI docs. The responses dict adds common response documentation.

In your main app, include the router:

# main.py
from fastapi import FastAPI
from routers import items, users, orders

app = FastAPI(
title="MyAPI",
version="1.0.0",
description="A production API"
)

app.include_router(items.router)
app.include_router(users.router)
app.include_router(orders.router)

Now GET /items, GET /users/{user_id}, etc., are all available. The app remains clean and routers are independently testable.

Nested Routers and Multi-Level Hierarchies

For larger APIs, nest routers inside routers. Imagine an e-commerce API with orders, order items, and order history:

# routers/orders/items.py
from fastapi import APIRouter

items_router = APIRouter(
prefix="/items",
tags=["order-items"]
)

@items_router.get("/")
async def list_order_items(order_id: int, db: AsyncSession = Depends(get_db)):
items = await db.execute(
select(OrderItem).where(OrderItem.order_id == order_id)
)
return items.scalars().all()

# routers/orders/main.py
from fastapi import APIRouter
from .items import items_router

orders_router = APIRouter(prefix="/orders", tags=["orders"])

# Include nested router (creates /orders/items)
orders_router.include_router(items_router)

@orders_router.get("/")
async def list_orders(db: AsyncSession = Depends(get_db)):
orders = await db.execute(select(Order))
return orders.scalars().all()

# main.py
from routers.orders.main import orders_router
app = FastAPI()
app.include_router(orders_router) # Adds /orders and /orders/items

Nesting creates hierarchy without code duplication. Dependencies declared on parent routers apply to children.

Applying Dependencies to Entire Routers

Apply dependencies at the router level to enforce them on all routes without repeating Depends():

from fastapi import APIRouter, Depends, HTTPException, status

async def verify_admin(user: User = Depends(get_current_user)):
"""Dependency: ensure user is admin."""
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return user

admin_router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(verify_admin)] # Applied to ALL routes
)

@admin_router.get("/users")
async def admin_list_users(db: AsyncSession = Depends(get_db)):
# verify_admin is automatically injected
users = await db.execute(select(User))
return users.scalars().all()

@admin_router.delete("/users/{user_id}")
async def admin_delete_user(user_id: int, db: AsyncSession = Depends(get_db)):
# verify_admin runs first; if it fails, this route is never called
user = await db.get(User, user_id)
await db.delete(user)
await db.commit()
return {"deleted": True}

Router-level dependencies are a clean way to enforce authorization across multiple endpoints—no copy-paste.

Versioning APIs with Router Prefixes

API versioning is a common use case for routers. Include multiple versions of the same endpoint:

# routers/v1/users.py
v1_users_router = APIRouter(prefix="/users", tags=["users-v1"])

@v1_users_router.get("/")
async def list_users_v1(db: AsyncSession = Depends(get_db)):
"""V1 returns only id and name."""
users = await db.execute(select(User.id, User.name))
return [{"id": u.id, "name": u.name} for u in users]

# routers/v2/users.py
v2_users_router = APIRouter(prefix="/users", tags=["users-v2"])

@v2_users_router.get("/")
async def list_users_v2(db: AsyncSession = Depends(get_db)):
"""V2 returns full user profile including email and created_at."""
users = await db.execute(select(User))
return users.scalars().all()

# main.py
app = FastAPI()
app.include_router(v1_users_router, prefix="/api/v1")
app.include_router(v2_users_router, prefix="/api/v2")

Now clients can use GET /api/v1/users (old format) or GET /api/v2/users (new format). You maintain backward compatibility while rolling out improvements.

Organizing Routers into Modules

For large codebases, organize routers into a package:

app/
main.py
routers/
__init__.py
users.py
products.py
orders/
__init__.py
main.py
items.py

In routers/__init__.py, export all routers:

# routers/__init__.py
from .users import router as users_router
from .products import router as products_router
from .orders.main import router as orders_router

__all__ = ["users_router", "products_router", "orders_router"]

In main.py, import them cleanly:

from fastapi import FastAPI
from routers import users_router, products_router, orders_router

app = FastAPI()

for router in [users_router, products_router, orders_router]:
app.include_router(router)

This scales to any number of routers without bloating the main file.

Testing Routers in Isolation

Routers are independently testable. Create a test app that includes only the router you want to test:

import pytest
from fastapi.testclient import TestClient
from routers.items import router as items_router

@pytest.fixture
def test_app():
app = FastAPI()
app.include_router(items_router)
return TestClient(app)

def test_list_items(test_app):
response = test_app.get("/items/")
assert response.status_code == 200

No need to set up the entire application—just the router and its dependencies.

Key Takeaways

  • Use APIRouter to group related endpoints by domain, avoiding one-file monoliths.
  • Apply prefix, tags, and dependencies at the router level for consistency.
  • Nest routers to create hierarchies (e.g., /orders/items).
  • Version APIs by including multiple routers with different prefix parameters.
  • Organize routers into a package structure that mirrors your API hierarchy.
  • Test routers in isolation using a minimal FastAPI() app.

Frequently Asked Questions

Can I have a route on a router and also on the main app?

Yes. Both app.get() and router.get() work independently. Routes on the app are added to the app directly; included routers are prefixed. There's no conflict—they coexist in the final API.

What happens if two routers define the same path?

FastAPI raises a ValueError at startup. Paths must be unique across all included routers. Use prefixes to avoid collisions (e.g., /api/v1/users vs. /api/v2/users).

How do I share dependencies between routers?

Define dependencies in a central module and import them into each router. For example, a dependencies.py file exports get_db, and both users.py and products.py import and use it.

Do router-level dependencies apply to nested routers?

Yes. If a parent router has dependencies=[Depends(verify_auth)], all child routers and their routes inherit that dependency.

Can I dynamically include routers based on configuration?

Yes. Read a config file, conditionally create routers, and include them in a loop. This is useful for feature flags or plugin systems.

Further Reading