As a FastAPI application grows, putting all routes in a single main.py file becomes unmanageable. APIRouter is FastAPI’s solution — it lets you define a group of routes in a separate module, then include that group in the main application with a URL prefix and tags. The result is a cleanly organised codebase where each resource (posts, users, comments, auth) lives in its own file with its own router. Route handlers in a router file look identical to those in main.py; the only difference is they use router instead of app.
Creating and Using APIRouter
# app/routers/posts.py
from fastapi import APIRouter, Depends, HTTPException, status
from app.schemas.post import PostCreate, PostUpdate, PostResponse
from app.dependencies import get_db, get_current_user
# Create a router with a prefix and tags
router = APIRouter(
prefix = "/posts", # all routes get /posts prefix
tags = ["Posts"], # group in Swagger UI
responses = { # shared response docs for all routes
404: {"description": "Post not found"},
},
)
@router.get("/", response_model=list[PostResponse])
def list_posts(page: int = 1, limit: int = 10, db = Depends(get_db)):
offset = (page - 1) * limit
return db.query(Post).offset(offset).limit(limit).all()
@router.get("/{post_id}", response_model=PostResponse)
def get_post(post_id: int, db = Depends(get_db)):
post = db.query(Post).get(post_id)
if not post:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Post not found")
return post
@router.post("/", response_model=PostResponse, status_code=201)
def create_post(
post: PostCreate,
db = Depends(get_db),
current_user = Depends(get_current_user),
):
db_post = Post(**post.model_dump(), author_id=current_user.id)
db.add(db_post)
db.commit()
db.refresh(db_post)
return db_post
app.include_router(router, prefix="/posts"), and the router itself also has prefix="/posts", the prefixes stack — routes end up at /posts/posts/.... Avoid this by either setting the prefix in the router definition OR in include_router(), not both. The convention is to set the prefix in include_router() in main.py and leave the router’s own prefix empty, which makes it clear from main.py what URL each router is mounted at.dependencies to apply a dependency to every route in the router without repeating it. router = APIRouter(dependencies=[Depends(require_auth)]) means every endpoint in that router requires authentication — you do not need to add Depends(require_auth) to each individual route. This is the cleanest pattern for applying authentication to a whole group of routes while keeping a few public routes in a separate, unauthenticated router.posts.py needs a schema from users.py, import the schema directly, not the router. A clean dependency graph: main.py imports routers, routers import schemas and dependencies, dependencies import models, models import the database base. Never have a router import another router.Including Routers in main.py
# app/main.py
from fastapi import FastAPI
from app.routers import posts, users, auth, comments
app = FastAPI(title="Blog API", version="1.0.0")
# Include routers — prefix and tags set here (not in router files)
app.include_router(auth.router, prefix="/auth", tags=["Auth"])
app.include_router(users.router, prefix="/users", tags=["Users"])
app.include_router(posts.router, prefix="/posts", tags=["Posts"])
app.include_router(comments.router, prefix="/comments", tags=["Comments"])
# Result:
# /auth/login (from auth router)
# /auth/logout (from auth router)
# /users (from users router)
# /users/{user_id} (from users router)
# /posts (from posts router)
# /posts/{post_id} (from posts router)
Router-Level Dependencies
from fastapi import APIRouter, Depends
from app.dependencies import require_auth, require_admin
# Public router — no auth required
public_router = APIRouter(prefix="/posts", tags=["Posts (Public)"])
@public_router.get("/")
def list_public_posts(): ...
@public_router.get("/{id}")
def get_public_post(id: int): ...
# Protected router — auth required for ALL routes
protected_router = APIRouter(
prefix = "/posts",
tags = ["Posts (Auth)"],
dependencies = [Depends(require_auth)], # applied to every route below
)
@protected_router.post("/") # automatically requires auth
def create_post(): ...
@protected_router.put("/{id}") # automatically requires auth
def update_post(id: int): ...
@protected_router.delete("/{id}") # automatically requires auth
def delete_post(id: int): ...
# Admin router — admin role required
admin_router = APIRouter(
prefix = "/admin",
tags = ["Admin"],
dependencies = [Depends(require_admin)],
)
@admin_router.get("/posts") # requires admin role
def admin_list_all_posts(): ...
# In main.py: include all three routers
# app.include_router(public_router)
# app.include_router(protected_router)
# app.include_router(admin_router)
Common Mistakes
Mistake 1 — Double prefix from both router and include_router
❌ Wrong — prefix set in both places:
router = APIRouter(prefix="/posts") # prefix here
app.include_router(router, prefix="/posts") # AND here → /posts/posts/ !
✅ Correct — prefix in one place only:
router = APIRouter() # no prefix
app.include_router(router, prefix="/posts") # prefix only here ✓
Mistake 2 — Circular router imports
❌ Wrong — routers importing each other:
# posts.py
from app.routers.users import get_user # circular import risk
✅ Correct — import schemas/services, not routers:
# posts.py
from app.schemas.user import UserResponse # ✓ import schema, not router
Mistake 3 — One massive router file (defeats the purpose)
❌ Wrong — all 50 routes in one router:
# routers/all_routes.py — 1000 lines, all resources mixed together
✅ Correct — one router per resource:
# routers/posts.py, routers/users.py, routers/comments.py, etc. ✓
Quick Reference
| Task | Code |
|---|---|
| Create router | router = APIRouter() |
| Router with tags | APIRouter(tags=["Posts"]) |
| Add route to router | @router.get("/") def f(): ... |
| Include in app | app.include_router(router, prefix="/posts") |
| Router-level deps | APIRouter(dependencies=[Depends(require_auth)]) |
| Shared responses | APIRouter(responses={404: {"description": "..."}}) |