Module 2: APIRouter and Middleware
APIRouter and Modularization — Splitting Endpoints by Domain
Capsule overview
APIRouter is FastAPI's tool for splitting your app into modules. It's like a "mini FastAPI": it supports all the decorators (@router.get, @router.post, etc.), it has its own dependencies, and it connects to the main app with app.include_router(). Each router groups the endpoints of one domain: tasks, users, products, admin.
In this capsule you'll create routers, give them prefixes and tags, move endpoints out of main.py, and see how to share dependencies at the router level. By the end, your main.py will go from 200+ lines to under 50 — just creating the app and including routers.
Your first APIRouter
Creating a router
from fastapi import APIRouter
router = APIRouter()
@router.get("/tasks")
def list_tasks():
return [{"id": 1, "title": "Task 1"}, {"id": 2, "title": "Task 2"}]
@router.get("/tasks/{task_id}")
def get_task(task_id: int):
return {"id": task_id, "title": f"Task {task_id}"}
A router defines endpoints exactly like app does, but it isn't an app — it's a group of endpoints that needs to be connected to an app.
Connecting it to the app with include_router()
from fastapi import FastAPI
from app.routers import tasks # assuming the router lives in app/routers/tasks.py
app = FastAPI()
app.include_router(tasks.router)
Now GET /tasks and GET /tasks/{task_id} work as if they'd been defined in main.py.
A complete example in a single file
To get the concept before splitting files:
from fastapi import FastAPI, APIRouter
app = FastAPI(title="Router Demo")
tasks_router = APIRouter()
users_router = APIRouter()
@tasks_router.get("/tasks")
def list_tasks():
return [{"id": 1, "title": "Task 1"}]
@tasks_router.post("/tasks", status_code=201)
def create_task():
return {"id": 2, "title": "New Task"}
@users_router.get("/users")
def list_users():
return [{"id": 1, "name": "Alice"}]
@users_router.get("/users/{user_id}")
def get_user(user_id: int):
return {"id": user_id, "name": f"User {user_id}"}
app.include_router(tasks_router)
app.include_router(users_router)
uvicorn app.main:app --reload
Open /docs — you'll see all the endpoints from both routers together.
Prefix: adding a prefix to the router
With prefix, you define a common prefix for every endpoint in the router. That saves you from repeating the base path:
from fastapi import APIRouter
router = APIRouter(prefix="/tasks")
@router.get("/")
def list_tasks():
return [{"id": 1, "title": "Task 1"}]
@router.get("/{task_id}")
def get_task(task_id: int):
return {"id": task_id}
@router.post("/", status_code=201)
def create_task():
return {"id": 2, "title": "New"}
| Decorator in the router | Final URL |
|---|---|
@router.get("/") | GET /tasks |
@router.get("/{task_id}") | GET /tasks/{task_id} |
@router.post("/") | POST /tasks |
The prefix is applied automatically to every endpoint in the router.
Prefix in include_router
You can also define the prefix when you include the router:
router = APIRouter() # no prefix
@router.get("/")
def list_tasks():
return []
app.include_router(router, prefix="/tasks")
Both forms work. The convention is to define the prefix on the router so the router is self-documenting.
Tags: organizing the documentation
Tags group endpoints in /docs. Without tags, every endpoint shows up together. With tags, they're grouped by section:
from fastapi import FastAPI, APIRouter
app = FastAPI(title="Task Manager")
tasks_router = APIRouter(prefix="/tasks", tags=["Tasks"])
users_router = APIRouter(prefix="/users", tags=["Users"])
@tasks_router.get("/")
def list_tasks():
return []
@tasks_router.post("/", status_code=201)
def create_task():
return {"id": 1}
@users_router.get("/")
def list_users():
return []
@users_router.get("/{user_id}")
def get_user(user_id: int):
return {"id": user_id}
app.include_router(tasks_router)
app.include_router(users_router)
In /docs:
- Tasks section:
GET /tasks,POST /tasks - Users section:
GET /users,GET /users/{user_id}
Tags in include_router
You can also pass tags when you include:
app.include_router(router, prefix="/tasks", tags=["Tasks"])
If the router already has tags, the ones from include_router are added (they don't replace them).
Router-level dependencies
You can declare dependencies that apply to every endpoint in the router:
from fastapi import APIRouter, Depends, Header, HTTPException
def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != "valid-key":
raise HTTPException(status_code=403, detail="Invalid API key")
admin_router = APIRouter(
prefix="/admin",
tags=["Admin"],
dependencies=[Depends(verify_api_key)],
)
@admin_router.get("/stats")
def admin_stats():
return {"users": 100, "tasks": 500}
@admin_router.delete("/cleanup")
def admin_cleanup():
return {"message": "Cleanup done"}
Every endpoint on admin_router requires the X-API-Key header. You don't need to add dependencies=[Depends(verify_api_key)] to each endpoint.
Comparison: dependency per endpoint vs. per router
# ❌ Repetitive — a dependency on every endpoint
@router.get("/stats", dependencies=[Depends(verify_api_key)])
def stats(): ...
@router.delete("/cleanup", dependencies=[Depends(verify_api_key)])
def cleanup(): ...
@router.post("/reset", dependencies=[Depends(verify_api_key)])
def reset(): ...
# ✅ DRY — a router-level dependency
router = APIRouter(dependencies=[Depends(verify_api_key)])
@router.get("/stats")
def stats(): ...
@router.delete("/cleanup")
def cleanup(): ...
@router.post("/reset")
def reset(): ...
Splitting into files: the basic structure
Step 1: Create the routers directory
mkdir -p app/routers
touch app/routers/__init__.py
Step 2: Move the endpoints into a router
app/routers/tasks.py:
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
router = APIRouter(prefix="/tasks", tags=["Tasks"])
tasks = [
{"id": 1, "title": "Buy groceries", "completed": False, "priority": "medium"},
{"id": 2, "title": "Study FastAPI", "completed": False, "priority": "high"},
{"id": 3, "title": "Work out", "completed": True, "priority": "low"},
]
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
priority: str = Field(default="medium", pattern="^(high|medium|low)$")
class TaskUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
completed: Optional[bool] = None
priority: Optional[str] = Field(default=None, pattern="^(high|medium|low)$")
def get_task_or_404(task_id: int) -> dict:
for task in tasks:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
@router.get("/")
def list_tasks(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=10, ge=1, le=100),
):
return tasks[skip : skip + limit]
@router.get("/{task_id}")
def get_task(task: dict = Depends(get_task_or_404)):
return task
@router.post("/", status_code=201)
def create_task(task_data: TaskCreate):
new_id = max((t["id"] for t in tasks), default=0) + 1
new_task = task_data.model_dump()
new_task["id"] = new_id
new_task["completed"] = False
tasks.append(new_task)
return new_task
@router.patch("/{task_id}")
def update_task(task: dict = Depends(get_task_or_404), task_data: TaskUpdate = ...):
update = task_data.model_dump(exclude_unset=True)
task.update(update)
return task
@router.delete("/{task_id}")
def delete_task(task: dict = Depends(get_task_or_404)):
tasks.remove(task)
return {"message": f"Task '{task['title']}' deleted"}
Step 3: Simplify main.py
app/main.py:
from fastapi import FastAPI
from app.routers import tasks
app = FastAPI(
title="Task Manager API",
description="Task API with APIRouter",
version="2.0.0",
)
app.include_router(tasks.router)
@app.get("/", tags=["General"])
def root():
return {"service": "Task Manager", "docs": "/docs"}
From 200+ lines to 15. main.py only creates the app, includes routers, and defines the root endpoint.
Step 4: Verify
uvicorn app.main:app --reload
Every endpoint should work just like before. /docs shows the endpoints organized by tags.
Multiple routers
# app/routers/tasks.py
from fastapi import APIRouter
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
def list_tasks():
return [{"id": 1, "title": "Task 1"}]
# app/routers/users.py
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["Users"])
@router.get("/")
def list_users():
return [{"id": 1, "name": "Alice"}]
# app/main.py
from fastapi import FastAPI
from app.routers import tasks, users
app = FastAPI()
app.include_router(tasks.router)
app.include_router(users.router)
Each domain has its own file. main.py just wires the pieces together.
Sharing data between routers
When several routers need to reach the same data, extract the data store into a shared module:
# app/data.py
tasks = [
{"id": 1, "title": "Task 1", "completed": False},
{"id": 2, "title": "Task 2", "completed": True},
]
def get_task_store():
yield tasks
# app/routers/tasks.py
from fastapi import APIRouter, Depends
from app.data import get_task_store
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
def list_tasks(store: list = Depends(get_task_store)):
return store
# app/routers/stats.py
from fastapi import APIRouter, Depends
from app.data import get_task_store
router = APIRouter(prefix="/stats", tags=["Stats"])
@router.get("/tasks")
def task_stats(store: list = Depends(get_task_store)):
completed = sum(1 for t in store if t["completed"])
return {"total": len(store), "completed": completed}
Both routers reach the same data store through the dependency.
Nested routers (routers inside routers)
You can include routers inside other routers:
from fastapi import APIRouter
# Sub-router for task statistics
task_stats_router = APIRouter(prefix="/stats", tags=["Task Stats"])
@task_stats_router.get("/summary")
def stats_summary():
return {"total": 10, "completed": 5}
@task_stats_router.get("/by-priority")
def stats_by_priority():
return {"high": 3, "medium": 4, "low": 3}
# Main tasks router
tasks_router = APIRouter(prefix="/tasks", tags=["Tasks"])
@tasks_router.get("/")
def list_tasks():
return [{"id": 1}]
tasks_router.include_router(task_stats_router)
| Endpoint | URL |
|---|---|
list_tasks | GET /tasks |
stats_summary | GET /tasks/stats/summary |
stats_by_priority | GET /tasks/stats/by-priority |
The prefixes get concatenated: /tasks + /stats + /summary = /tasks/stats/summary.
Exercises
Exercise 1: A basic router (Easy)
Create an APIRouter with the /products prefix and the "Products" tag. Define 3 endpoints: GET / (list), GET /{id} (by ID), POST / (create). Connect it to an app with include_router.
See solution
from fastapi import FastAPI, APIRouter, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
products_router = APIRouter(prefix="/products", tags=["Products"])
products = [
{"id": 1, "name": "Laptop", "price": 999.99},
{"id": 2, "name": "Mouse", "price": 29.99},
]
class ProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0)
@products_router.get("/")
def list_products():
return products
@products_router.get("/{product_id}")
def get_product(product_id: int):
for p in products:
if p["id"] == product_id:
return p
raise HTTPException(status_code=404, detail="Product not found")
@products_router.post("/", status_code=201)
def create_product(data: ProductCreate):
new_id = max((p["id"] for p in products), default=0) + 1
product = data.model_dump()
product["id"] = new_id
products.append(product)
return product
app.include_router(products_router)
@app.get("/", tags=["General"])
def root():
return {"service": "Product API"}
curl http://127.0.0.1:8000/products
curl http://127.0.0.1:8000/products/1
curl -X POST http://127.0.0.1:8000/products \
-H "Content-Type: application/json" \
-d '{"name": "Keyboard", "price": 59.99}'
Exercise 2: Two routers with tags (Easy)
Create books_router (prefix /books, tag "Books") and authors_router (prefix /authors, tag "Authors"). Each with GET / and GET /{id}. Include them in the app. Check that /docs shows them in separate sections.
See solution
from fastapi import FastAPI, APIRouter, HTTPException
app = FastAPI(title="Library API")
books_router = APIRouter(prefix="/books", tags=["Books"])
authors_router = APIRouter(prefix="/authors", tags=["Authors"])
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author_id": 1},
{"id": 2, "title": "Don Quixote", "author_id": 2},
]
authors = [
{"id": 1, "name": "Gabriel García Márquez"},
{"id": 2, "name": "Miguel de Cervantes"},
]
@books_router.get("/")
def list_books():
return books
@books_router.get("/{book_id}")
def get_book(book_id: int):
for b in books:
if b["id"] == book_id:
return b
raise HTTPException(status_code=404, detail="Book not found")
@authors_router.get("/")
def list_authors():
return authors
@authors_router.get("/{author_id}")
def get_author(author_id: int):
for a in authors:
if a["id"] == author_id:
return a
raise HTTPException(status_code=404, detail="Author not found")
app.include_router(books_router)
app.include_router(authors_router)
In /docs: a "Books" section with 2 endpoints, an "Authors" section with 2 endpoints.
Exercise 3: A router with shared dependencies (Medium)
Create an admin_router with the /admin prefix that requires an X-Admin-Token header. Use dependencies=[Depends(...)] at the router level. Add 2 endpoints: GET /stats and DELETE /reset. Check that both require the token.
See solution
from fastapi import FastAPI, APIRouter, Depends, Header, HTTPException
app = FastAPI()
def require_admin_token(x_admin_token: str = Header(...)):
if x_admin_token != "admin-secret-123":
raise HTTPException(status_code=403, detail="Invalid admin token")
admin_router = APIRouter(
prefix="/admin",
tags=["Admin"],
dependencies=[Depends(require_admin_token)],
)
@admin_router.get("/stats")
def admin_stats():
return {"users": 42, "tasks": 128, "uptime": "3 days"}
@admin_router.delete("/reset")
def admin_reset():
return {"message": "System reset initiated"}
public_router = APIRouter(tags=["Public"])
@public_router.get("/health")
def health():
return {"status": "healthy"}
app.include_router(admin_router)
app.include_router(public_router)
# No token → 422
curl http://127.0.0.1:8000/admin/stats
# Wrong token → 403
curl http://127.0.0.1:8000/admin/stats -H "X-Admin-Token: wrong"
# Correct token → 200
curl http://127.0.0.1:8000/admin/stats -H "X-Admin-Token: admin-secret-123"
# Health is public → 200 with no token
curl http://127.0.0.1:8000/health
Exercise 4: Splitting into files (Medium)
Take exercise 2 (books + authors) and split it into files: app/routers/books.py, app/routers/authors.py, and app/main.py. Check that it still works after the split.
See solution
app/routers/books.py:
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/books", tags=["Books"])
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author_id": 1},
{"id": 2, "title": "Don Quixote", "author_id": 2},
]
@router.get("/")
def list_books():
return books
@router.get("/{book_id}")
def get_book(book_id: int):
for b in books:
if b["id"] == book_id:
return b
raise HTTPException(status_code=404, detail="Book not found")
app/routers/authors.py:
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/authors", tags=["Authors"])
authors = [
{"id": 1, "name": "Gabriel García Márquez"},
{"id": 2, "name": "Miguel de Cervantes"},
]
@router.get("/")
def list_authors():
return authors
@router.get("/{author_id}")
def get_author(author_id: int):
for a in authors:
if a["id"] == author_id:
return a
raise HTTPException(status_code=404, detail="Author not found")
app/routers/__init__.py:
from app.routers.books import router as books_router
from app.routers.authors import router as authors_router
app/main.py:
from fastapi import FastAPI
from app.routers import books_router, authors_router
app = FastAPI(title="Library API")
app.include_router(books_router)
app.include_router(authors_router)
@app.get("/", tags=["General"])
def root():
return {"service": "Library API", "docs": "/docs"}
uvicorn app.main:app --reload
curl http://127.0.0.1:8000/books
curl http://127.0.0.1:8000/authors
Exercise 5: A router with DI and filters (Medium-Hard)
Create a tasks router with: a get_task_or_404 dependency, a pagination_params dependency, a GET / endpoint with pagination, GET /{id} with lookup, and DELETE /{id} with lookup. Include some initial data and check it in /docs.
See solution
from typing import Optional
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Query
app = FastAPI()
tasks = [
{"id": 1, "title": "Task 1", "completed": False},
{"id": 2, "title": "Task 2", "completed": True},
{"id": 3, "title": "Task 3", "completed": False},
{"id": 4, "title": "Task 4", "completed": True},
{"id": 5, "title": "Task 5", "completed": False},
]
def pagination_params(
skip: int = Query(default=0, ge=0),
limit: int = Query(default=10, ge=1, le=100),
) -> dict:
return {"skip": skip, "limit": limit}
def get_task_or_404(task_id: int) -> dict:
for task in tasks:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
def list_tasks(pagination: dict = Depends(pagination_params)):
start = pagination["skip"]
end = start + pagination["limit"]
return {"total": len(tasks), "tasks": tasks[start:end]}
@router.get("/{task_id}")
def get_task(task: dict = Depends(get_task_or_404)):
return task
@router.delete("/{task_id}")
def delete_task(task: dict = Depends(get_task_or_404)):
tasks.remove(task)
return {"message": f"Deleted task: {task['title']}"}
app.include_router(router)
@app.get("/", tags=["General"])
def root():
return {"service": "Task API with Router + DI"}
curl "http://127.0.0.1:8000/tasks?skip=0&limit=2"
# {"total":5,"tasks":[...2 tasks...]}
curl http://127.0.0.1:8000/tasks/1
# {"id":1,...}
curl http://127.0.0.1:8000/tasks/999
# {"detail":"Task 999 not found"}
Troubleshooting
Problem 1: "ModuleNotFoundError: No module named 'app.routers'"
Cause: The routers/ directory is missing its __init__.py.
# Check
ls app/routers/__init__.py
# If it doesn't exist
touch app/routers/__init__.py
Problem 2: Duplicate endpoints in /docs
Cause: You defined the same endpoint in the router AND in main.py, or you included the same router twice.
# ❌ Duplicated
app.include_router(tasks.router)
app.include_router(tasks.router) # included twice
# ✅ Just once
app.include_router(tasks.router)
Problem 3: The prefix isn't being applied
Cause: You define the prefix in include_router but the router already has the prefix baked into its paths.
# ❌ Double prefix: /tasks/tasks/
router = APIRouter(prefix="/tasks")
@router.get("/tasks") # ← already has /tasks in the path
def list_tasks(): ...
# ✅ Only in one place
router = APIRouter(prefix="/tasks")
@router.get("/") # ← no extra prefix
def list_tasks(): ...
Problem 4: A circular import between routers and dependencies
Cause: routers/tasks.py imports from dependencies/tasks.py, which imports from routers/tasks.py.
Fix: Dependencies must never import from routers. The import flow has to be one-directional:
models/ ← dependencies/ ← routers/ ← main.py
Problem 5: "/tasks/stats" matches as "/tasks/{task_id}"
Cause: FastAPI evaluates endpoints in order. If /{task_id} comes before /stats, "stats" gets read as a task_id.
# ❌ Wrong order
@router.get("/{task_id}")
def get_task(task_id: int): ...
@router.get("/stats")
def stats(): ...
# ✅ Fixed paths first
@router.get("/stats")
def stats(): ...
@router.get("/{task_id}")
def get_task(task_id: int): ...
Summary
APIRoutergroups endpoints by domain — it's a "mini FastAPI"prefixadds a base path to every endpoint in the routertagsorganize the documentation in/docsinto sectionsinclude_router()connects a router to the main app- Router-level dependencies apply to all of its endpoints
- Split routers into files:
app/routers/tasks.py,app/routers/users.py main.pygets simple: create app + include routers- Prefixes get concatenated in nested routers
- Fixed paths before paths with parameters, to avoid conflicts
Additional resources
- FastAPI - Bigger Applications — The complete APIRouter tutorial
- FastAPI - APIRouter reference — Every APIRouter parameter
- FastAPI - Tags — Organizing endpoints with tags
- FastAPI - include_router — Including routers with different prefixes
- FastAPI - Metadata and docs — Customizing the documentation
- Python - Packages — init.py and package structure
What's next?
Next capsule: A Professional Folder Structure — You'll move models to app/models/, dependencies to app/dependencies/, and organize your imports with __init__.py. Your project will have the same structure that FastAPI projects use in production.