FastAPI Routers and Modular Apps
Learn to structure FastAPI apps with routers and modular files. This lesson shows how to break down large APIs into manageable, organized modules, making code cleaner, more maintainable, and scalable. A practical walkthrough guides you through creating routers and connecting them to your main app, with troubleshooting
Focus: FastAPI routers
You've built a few FastAPI endpoints, and they work beautifully. But as your API grows — adding users, products, orders, and analytics — your single main.py file starts to resemble a tangled ball of string. Every new route makes navigation harder, merge conflicts more painful, and testing more fragile. This lesson solves that pain by showing you how to structure your FastAPI applications with routers and modular files, transforming a sprawling monolith into a clean, maintainable codebase that scales with your project.
The problem this lesson solves
A typical beginner FastAPI project lives in one file. It starts simple:
from fastapi import FastAPI
app = FastAPI()
@app.get("/users")
def get_users():
return [{"name": "Alice"}]
@app.get("/products")
def get_products():
return [{"name": "Laptop"}]
At first, this is fine. But add authentication, database connections, background tasks, and a dozen more resources, and your main.py balloons to thousands of lines. You'll spend more time scrolling than coding. Debugging becomes a hunt through one massive file. And when your team tries to work simultaneously, every edit creates a merge conflict.
The core issue: a single-file app mixes routes, logic, and configuration, violating the single-responsibility principle and making it impossible to reuse or test modules independently. Without structure, your API is a liability, not an asset.
Core concept / mental model
Think of your FastAPI application as a building. The main.py file is the foundation and the lobby — it ties everything together and greets incoming requests. Each router is a dedicated floor or wing: the users department, the products department, and so on. Each floor has its own entrance (the prefix), its own staff (path operations), and its own internal layout (modules).
A router in FastAPI is a APIRouter instance that lets you group related path operations. You define them in separate files, then include them into the main app. This is like wiring the floors' electrical systems into the building's main grid.
Key terms to keep straight:
- APIRouter: A mini FastAPI-like object that holds path operations (e.g.,
@router.get("/")). - Include: The
app.include_router()method that merges the router's routes into the app. - Prefix: A URL path prefix added to all routes in a router (e.g.,
/api/v1/users) — but the router itself doesn't define it; you apply it when including. - Modular files: Separate
.pyfiles that each contain a router, keeping concerns isolated.
Pro tip: Treat
main.pyas the composition root: it should only assemble and configure components — not implement business logic. If you find yourself writing route handlers inmain.py, you're missing the point.
How it works step by step
Here's the workflow you'll follow to modularize a FastAPI app:
- Create a router — Instantiate
APIRouter()in a new file (e.g.,app/users.py). - Define path operations — Use the router decorators (
@router.get(),@router.post()) exactly as you would withapp. - Include the router — In
main.py, callapp.include_router(users.router)and optionally add aprefixandtags. - Organize by resource (or by domain) — Each router handles one logical entity or feature, making your codebase predictable.
- Add dependencies — You can attach
dependenciesto individual routers at creation time, so all routes in that file share them without repetition. - Import with care — Use relative imports if your routers live inside a package (like
from .users import router), avoiding circular imports.
The cause-and-effect is simple: routers let you separate concerns, and include_router composes them back into one app. The app's behavior is unchanged, but the code becomes human-friendly.
Hands-on walkthrough
Let's build a modular FastAPI app step by step. We'll assume this project structure:
project/
├── main.py
├── app/
│ ├── __init__.py
│ ├── users.py
│ └── products.py
1. Create a users router — app/users.py:
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/users", tags=["users"])
USERS_DB = {"alice": {"name": "Alice", "age": 30}, "bob": {"name": "Bob", "age": 25}}
@router.get("/")
def list_users():
return list(USERS_DB.values())
@router.get("/{username}")
def get_user(username: str):
user = USERS_DB.get(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
2. Create a products router — app/products.py:
from fastapi import APIRouter
router = APIRouter(prefix="/products", tags=["products"])
PRODUCTS = [{"id": 1, "name": "Laptop", "price": 999}]
@router.get("/")
def list_products():
return PRODUCTS
3. Wire everything together — main.py:
from fastapi import FastAPI
from app.users import router as users_router
from app.products import router as products_router
app = FastAPI()
app.include_router(users_router)
app.include_router(products_router)
@app.get("/health")
def health_check():
return {"status": "ok"}
Now run with uvicorn main:app --reload and visit http://127.0.0.1:8000/docs. You'll see two sections: users and products, each with its endpoints under the correct prefix. Expected output for curl http://127.0.0.1:8000/users:
[{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
To add a version prefix at the app level (if you didn't set it in the router), change the include call:
app.include_router(users_router, prefix="/api/v1")
app.include_router(products_router, prefix="/api/v1")
Notice how the prefix defined inside the router combines with the include prefix, so user routes become /api/v1/users/. This flex to override or extend prefixes at inclusion time is a powerful feature.
Compare options / when to choose what
You have several ways to structure your FastAPI routes. Here's a comparison table to help you decide:
| Approach | When to use | Pros | Cons |
|---|---|---|---|
| Single file | Quick prototypes, < 10 endpoints | Simple, no extra files | Hard to maintain, team conflicts |
| One router per resource | Most APIs growing past a few endpoints | Clear organization, easy to test and extend | More files to manage |
| Feature-based routers (e.g., auth, admin) | Route grouping by cross-cutting concern | Logical separation by functionality | May require nested routers for sub-resources |
| Nested routers (router within a router) | Complex APIs with sub-resources like users/{id}/posts |
Reuse and nesting | Adds indirection, may be overkill |
For most projects, one router per resource is the sweet spot. If you have shared logic (e.g., an auth dependency), you can pass it as a router-level dependencies argument:
from fastapi import Depends, APIRouter
async def require_token(token: str = Header(...)):
if token != "secret":
raise HTTPException(401)
return token
router = APIRouter(dependencies=[Depends(require_token)])
This way, every route in that router automatically enforces authentication.
Pro tip: Use
tagsliberally when creating routers — they not only organize your OpenAPI docs but also make test filtering easier with tools likehttpx. Tags are free documentation.
Troubleshooting & edge cases
You'll likely run into a few common snags while modularizing. Here's how to fix them:
- Route not found (404): The router isn't included, or the path / prefix is wrong. Check the include statement; if you set a prefix in the router, don't repeat it in the include prefix.
- Circular imports: If
main.pyimports a router that imports back frommain.py, you'll see anImportError. Solution: keep routers independent; move shared state or helpers into separate modules. - Duplicate operation IDs: If you have multiple
get("/")routes across routers with the same tag, FastAPI may complain about duplicate operation IDs in OpenAPI. Addoperation_idparameters manually or ensure unique names. - Missing
__init__.py: Without it, Python won't treatappas a package, causing import errors. Create emptyapp/__init__.py. - Path operation order: When you include multiple routers, FastAPI matches routes in the order they were included. If one router has a catch-all like
/{item_id}, it might shadow later routes. Place more specific routes earlier. - Router prefix overridden unexpectedly: If you set
prefix="/api"at include time, it adds to the router's own prefix. To replace, omit the router-level prefix and set it only at include.
What you learned & what's next
You now understand how to break down a monolithic FastAPI app into modular, maintainable routers. You've practiced creating APIRouter instances, defining endpoints across multiple files, and including them into a central app with optional prefixes and tags. You can systematically organize code, avoid circular imports, and keep your codebase clean as it scales — a skill that separates hobby scripts from production-grade APIs.
Next up, you'll likely dive into dependency injection in depth, learning how to share database sessions, authentication, and reusable logic across routers — building on the modular foundation you just created. Or, if you're moving forward in the track, explore background tasks and middlewares to add cross-cutting concerns. Either way, your structured app is ready to grow.
Practice recap
Take a small existing FastAPI app (even from memory) and split it into at least two routers. Add a prefix and tags to each, include them in main.py, and verify endpoints work via /docs. Then try overriding the prefix at include time to see how it changes the URL structure.
Common mistakes
- Forgetting to include routers in main.py results in 404 errors — always call app.include_router().
- Setting a prefix both in APIRouter and in include_router leads to unexpected double prefixes like /api/api/users.
- Creating circular imports by having routers import from main.py — keep routers standalone and import shared helpers from separate modules.
Variations
- Using nested routers via APIRouter.include_router on another router to build sub-resources like /users/me/posts.
- Feature-based routers instead of resource-based — grouping by authentication, admin, or public endpoints.
- Organizing by domain with routers that combine related resources in one file for microservices.
Real-world use cases
- A growing e-commerce API with separate routers for users, orders, and payments keeps each domain maintainable and independently testable.
- A microservices gateway that exposes versioned endpoints by including routers with /v1 and /v2 prefixes
- An admin dashboard API that shares authentication dependencies across all admin routers via router-level dependencies.
Key takeaways
- Use APIRouter to group related endpoints in separate files, keeping main.py focused on configuration.
- Include routers with app.include_router() and apply prefixes/tags at inclusion time for flexibility.
- Maintain a clean package structure with app/init.py to avoid import errors.
- Avoid circular imports by keeping routers independent and importing shared utilities from dedicated modules.
- Use router-level dependencies to apply auth or validation across all routes in a group.
- Organize routers by resource or feature to make your codebase scalable and team-friendly.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.