APIRouter

Splitting an app before main.py becomes the file nobody wants to open.

Overview

The problem it solves

Every FastAPI project starts as one file, and one file works for perhaps twenty routes. Past that it becomes the file everybody edits, every change conflicts, and nobody can find anything.

APIRouter is the answer, and the useful time to reach for it is *before* you need to.

Worth knowing

An APIRouter takes the same decorators as an app. app.include_router() merges its routes in.
prefix and tags apply to every route in the router — tags are what group endpoints in the documentation.
A prefix can be given at include time instead, so one router can be mounted at more than one path.
dependencies=[...] on a router applies to every route in it — the tidy way to require authentication across a whole section.
Routers include other routers, so a nested resource can live in its own file.
Inclusion order determines match order: a variable route in an early router shadows a fixed one included later.

APIRouter

Splitting an app before main.py becomes the file nobody wants to open.

A router is a mini-app

Register routes on it exactly as on an app, then include it. The prefix and tags apply to everything in it.

example_01.pyFastAPI
Output
router = APIRouter(prefix="/modules", tags=["modules"])

@router.get("")
def list_modules():
    ...

app.include_router(router)

The decorators are the same. The prefix is prepended to every path in the router, so @router.get("") becomes /modules. The tags apply to every route, which is what groups them into a section in the interactive documentation.

Note @router.get("") rather than @router.get("/") for the collection itself. With a prefix, the empty string gives /modules and a slash gives /modules/, and the difference is the trailing-slash redirect from earlier. Pick one convention.

Several routers, one app

This is the shape a growing project takes: one module per resource, assembled in one place.

example_02.pyFastAPI
Output

Prefixes at include time

The same router can be mounted more than once, which is how versioning is usually done.

example_03.pyFastAPI
Output

A prefix can be given when including instead of when creating:

app.include_router(router, prefix="/v1")

That lets one router be mounted at several paths, which is the mechanism behind version prefixes. Whether you *should* mount the same handlers at two versions is a different question — usually a new version exists because behaviour differs, and then you want two routers.

Where this genuinely helps is keeping the version out of the router file entirely, so the routes read as /modules and the assembly decides they live under /v1.

Dependencies for a whole router

A dependency on the router applies to every route in it — the tidy way to require a key across a section.

example_04.pyFastAPI
Output

Shared responses and a nested router

responses= on a router documents a failure for every route in it, and routers include other routers.

example_05.pyFastAPI
Output

Order across routers

Inclusion order decides match order, so a variable route in an early router can shadow a fixed one included later.

example_06.pyFastAPI
Output

The shape a project takes

One router per resource, one file each, assembled in one place:

app/
  main.py            # creates the app, includes routers
  routers/
    modules.py
    tracks.py
    admin.py

main.py becomes short and boring, which is what you want from the file that wires everything together. Each router file owns one resource and can be read on its own.

The advice worth acting on: create routers/ on day one, even with two endpoints in it. Moving three routes later is trivial; moving eighty is a weekend, and by then something will depend on the import layout.

Dependencies for a section

admin = APIRouter(prefix="/admin", dependencies=[Depends(require_key)])

Every route in that router now requires the key. This is the tidy way to protect a section, and it is better than remembering to add a dependency to each endpoint — because the one you forget is the one that matters.

Note the dependency's return value is not passed to the handlers when declared this way; it runs for its effect. When a handler needs the value, it declares its own Depends as well, and the result is cached within the request so the work happens once.

Dependencies get a full tier next, and this is the first genuinely useful thing they do.

Shared documentation

responses= on a router documents a failure shape for every route in it. If every endpoint under /modules can 404, saying so once is better than repeating it six times.

deprecated=True on a router marks a whole section as deprecated in the docs, which is a civilised way to retire a version.

Nesting

Routers include routers:

outer.include_router(inner)

Prefixes compose, so a nested resource can live in its own file and still appear under its parent's path. Useful for genuine ownership — lessons within modules — and easy to overdo. Two levels is usually enough; four produces paths nobody types correctly.

Order still decides

The rule from the first tier, now with a longer reach: routes match in registration order, and across routers that means inclusion order.

A router with /modules/{id} included before a router with /modules/latest makes the second unreachable, and the mistake is harder to see because the two routes are in different files.

Two habits that avoid it. Keep routes for one path space in one router, so ordering is visible in one place. And annotate path parameters precisely — int rather than str — so a fixed path that gets shadowed fails loudly instead of silently matching.

When something does not route as expected, print app.routes. It shows the merged table in match order and settles the question immediately.

Testing a router alone

A router can be included into a small app built for a test:

app = FastAPI()
app.include_router(modules.router)
client = TestClient(app)

That gives a test covering one resource without the rest of the application, its dependencies or its startup. It is one of the quieter benefits of splitting up: the pieces become independently testable.

What goes in a router file

Routes, and as little else as possible.

The handlers should be thin, calling into a service module. The models can live in their own file, or beside the router if they are only used there. The dependency functions usually deserve their own module, since several routers need the same ones.

What should not be in there is business logic, database access or configuration. A router file that imports your ORM directly works and stops being testable without a database.

Mistakes people make

Waiting to split. Moving three routes is trivial; moving eighty is a weekend, and by then imports depend on the layout. Create routers/ on day one.

Business logic in a router file. A router that imports your ORM directly works and stops being testable without a database. Handlers should be thin and call a service.

Inconsistent trailing slashes. With a prefix, @router.get("") gives /modules and @router.get("/") gives /modules/. Pick one across the whole app.

Ignoring inclusion order. A variable route in an early router shadows a fixed one included later, and the two files make it hard to see. print(app.routes) settles it.

Nesting too deep. Two levels is usually enough. Four produces paths nobody types correctly and routers nobody wants to trace.

Forgetting a router-level dependency is not injected. It runs for its effect; a handler needing the value declares its own Depends, and the result is cached within the request.

What a good layout looks like

For an application of any size the arrangement that holds up is unremarkable, which is the point.

main.py creates the app, registers exception handlers and includes routers. It should be short enough to read in one screen.

routers/ holds one file per resource, each owning its paths and nothing else.

schemas/ or models/ holds the Pydantic models - separated by direction, as the response-model module argued.

services/ holds the functions that do the work, importing nothing from FastAPI.

dependencies.py holds the shared Depends functions, since several routers need the same ones.

Nothing there is clever. Its value is that a newcomer can guess where anything lives, and that each piece can be tested without starting the whole application.

Assembling the app

main.py in a healthy project does four things and nothing else.

It creates the FastAPI instance with a title, version and description - which become the header of your documentation.

It registers exception handlers, so the error contract is declared in one place.

It includes routers, in an order chosen deliberately rather than by accident.

It adds middleware, if any.

Everything else lives somewhere it can be tested. The value of a boring assembly file is that it is the one place to look when asking "what does this application consist of?", and the answer fits on a screen.

Configuration and startup

Two things commonly end up in main.py that are worth separating.

Settings belong in their own module - a Pydantic settings model read once. Scattering os.getenv through routers makes it impossible to see what the application needs to run.

Startup work - opening a connection pool, loading a model - belongs in a lifespan handler rather than at import time. Work done at import happens when a test imports the module, which is how a test suite ends up needing a database to collect.

Both get proper treatment in the runtime tier. The habit worth forming now is not putting either in the file that wires routes together.

Splitting by resource, not by layer

One structural choice worth stating, because both options look reasonable.

Grouping by resource - routers/modules.py, services/modules.py, schemas/modules.py - means a change to one concept touches files with the same name in different directories.

Grouping by layer alone, with every router in one file and every schema in another, means those same files grow without bound and every change collides with every other.

For anything past a few resources, resource-first inside a shallow layer structure is what stays navigable. The test is whether a newcomer asked to add a field can guess which files to open. If the answer is "search for the word", the layout has stopped helping.

Next

That completes the request tier: methods, headers, forms, files, status codes, error handling and structure. The next tier is dependencies — Depends, the feature that makes all of the above composable, and the one that most distinguishes FastAPI from what came before it.

A closing thought

Structure is the cheapest thing to get right early and among the most expensive to fix late.

A router file created on the first day costs nothing. The same split attempted after eighty endpoints means untangling imports, moving tests, and a diff nobody can review properly - so it does not happen, and the file keeps growing.

The rule of thumb: if you can imagine a second resource, make the directory now.

One more benefit

Splitting an app makes it testable in pieces, and that is worth more than the tidiness.

A router included into a small FastAPI() built for one test file gives you a client that exercises one resource - without the rest of the application, its other routers, its startup work or its unrelated dependencies.

That means a test suite that runs fast, fails specifically, and does not require the whole system to be constructible. It is the difference between "the tests need a database, Redis and three environment variables" and "the tests need the module under test".

Summary

An APIRouter takes the same decorators as an app, carries a prefix and tags, and merges in through include_router. Routers nest, accept shared dependencies and responses, and can be mounted at more than one prefix.

Inclusion order is match order, so a variable route in an early router shadows a fixed one included later.

Create the directory on day one. Keep handlers thin and business logic out of router files, so each piece can be tested without starting the whole application - which is the quiet benefit of splitting up, and the reason it is worth doing before it hurts.

Where this leaves you

That completes the request tier. You can route by path and method, read every source of input a request has, decide what comes back and with which status, produce errors that a caller can act on, and split an application before its main file becomes unmanageable.

What is still missing is the thing that makes all of it composable: a way to declare that an endpoint needs a database session, an authenticated user or a validated set of filters, and have that requirement satisfied, cached and documented automatically. That is Depends, and it is the next tier.

A final note

None of the structure in this module is FastAPI-specific. Grouping by resource, keeping handlers thin, separating configuration and startup, and assembling in a boring file are practices that predate the framework and outlast it.

What FastAPI contributes is that following them costs almost nothing: a router is four lines, inclusion is one, and the documentation reorganises itself around your tags without being asked.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What does `prefix` on an APIRouter do?

  2. Why can a fixed route in one router be unreachable?

  3. What does `dependencies=[...]` on a router do?

  4. When should you create a routers/ directory?

Cheat sheet

APIRouter

Every FastAPI project starts as one file, and one file works for perhaps twenty routes. Past that it becomes the file everybody edits, every change conflicts, and nobody can find anything.

FASTAPI · vizlearn.in/fastapi/apirouter.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.