APIRouter

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

Overview

The problem it solves

An APIRouter is a group of related endpoints that you define in its own file and attach to the main app later. It works like a small app of its own. It has routes, tags, and dependencies, but it does not run by itself. You plug it into the real app, and its routes join the rest.

You need one as soon as your project grows past a handful of endpoints. Everything in a single main.py works at first. Then the file reaches a thousand lines, two people edit it at once, and nobody wants to open it. Routers let you split the work by topic, so users, orders, and billing each get a home.

This article starts with the problem a router solves and why the single-file approach stops scaling. It then explains that a router is a mini-app, with most of the same decorators you already know. You will see the shape a project usually takes once it is split, with a folder of routers and a short main file.

The later sections cover details that save repetition. A prefix can be added when you include the router, so each endpoint does not repeat the same start of the path. Dependencies can be attached to a whole section, so authentication is declared once instead of fifty times.

Read this when your project begins to feel crowded. The idea is simple, and the payoff is a codebase that stays easy to navigate.

Nothing about a router is hard, but the choices around it are easy to get wrong early and painful to change later. This article gives you a layout you can adopt on day one. It also explains why each part is there, so you can adjust it for your own project instead of copying it blindly. If your app already feels crowded, you can apply these ideas one router at a time, with no need for a big rewrite. Each section is short and ends with a habit you can apply straight away, so you finish with a layout that is ready to use.

each FastAPI project starts as one file. One file works for perhaps twenty routes. Past that it turns the file everybody edits, each change conflicts, and no one can find anything.

APIRouter is the answer, and the handy time to reach for it's *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 turns the file no one 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 each path in the router. @router.get("") turns /modules. The tags apply to each route. This Is what groups them into a section in the interactive docs.

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

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 a few paths. This Is the method behind version prefixes. Whether you *should* mount the same handlers at two versions is a other question. Often a new version exists because action differs. Then you want two routers.

Where this truly helps is keeping the version out of the router file fully. 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 turns short and boring. This Is what you want from the file that wires all 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. By then a thing will depend on the import layout.

Dependencies for a section

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

each route in that router now needs the key. This is the tidy way to protect a section. It's 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 isn't 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. The result is cached within the request so the work happens once.

Dependencies get a full tier next. This is the first truly handy thing they do.

Shared documentation

responses= on a router docs a failure shape for each route in it. If each 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. This 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. handy for genuine ownership. Lessons within modules. And easy to overdo. Two levels is often enough. Four makes paths no one types right.

Order still decides

The rule from the first tier, now with a longer reach. Routes match in registration order. Across routers that means inclusion order.

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

Two habits that avoid it. Keep routes for one path space in one router. Sorting is visible in one place. And annotate path params just. int rather than str. So a fixed path that gets shadowed fails loudly instead of silently matching.

When a thing doesn't route as expected, print app.routes. It shows the merged table in match order and settles the question at once.

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 use, its dependencies or its startup. It's one of the quieter gains of splitting up: the pieces become on its own 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're only used there. The dependency functions often deserve their own module. A few routers need the same ones.

What shouldn't be in there's business logic, DB access or config. A router file that imports your ORM right works and stops being testable without a DB.

Mistakes people make with APIRouter

Waiting to split. Moving three routes is trivial. Moving eighty is a weekend. By then imports depend on the layout. Create routers/ on day one.

Business logic in a router file. A router that imports your ORM right works and stops being testable without a DB. 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.

Skipping inclusion order. A var route in an early router shadows a fixed one included later. The two files make it hard to see. print(app.routes) settles it.

Nesting too deep. Two levels is often enough. Four makes paths no one types right and routers no one wants to trace.

Losing a router-level dependency isn't injected. It runs for its effect. A handler needing the value declares its own Depends. The result is cached within the request.

What a good layout looks like

For an use of any size the setup that holds up is plain. This 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 - split 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 a few routers need the same ones.

Nothing there's clever. Its value is that a newcomer can guess where anything lives. That each piece can be tested without starting the whole use.

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 docs.

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

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

It adds middleware, if any.

all else lives one spot it can be tested. The value of a boring assembly file is that it's the one place to look when asking "what does this use consist of?". The answer fits on a screen.

Configuration and startup

Two things often 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 hopeless to see what the use needs to run.

Startup work - opening a link 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. This Is how a test suite ends up needing a DB to collect.

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

Splitting by resource, not by layer

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

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 other folders.

Grouping by layer alone, with each router in one file and each schema in another, means those same files grow without bound and each 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.

Where to go after APIRouter

That ends 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. The one that most sets apart FastAPI from what came before it.

A closing thought

Structure is the cheapest thing to get right early and among the most costly 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. A diff no one can review properly - so it doesn't happen, and the file keeps growing.

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

One more benefit

Splitting an app makes it testable in pieces. 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 use, its other routers, its startup work or its unrelated dependencies.

That means a test suite that runs fast, fails namely. Doesn't need the whole system to be constructible. It's the gap between "the tests need a DB, Redis and three setting vars" and "the tests need the module under test".

APIRouter: summary

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

Inclusion order is match order. So A var route in an early router shadows a fixed one included later.

Create the folder on day one. Keep handlers thin and business logic out of router files. Each piece can be tested without starting the whole use - which is the quiet gain of splitting up. The reason it's worth doing before it hurts.

Where this leaves you

That ends the request tier. You can route by path and method, read each source of input a request has, decide what comes back and with which status, give errors that a caller can act on. Split an use before its main file turns unmanageable.

What is still missing is the thing that makes all of it composable. A way to declare that an endpoint needs a DB session, an authenticated user or a checked set of filters. Have that need met, cached and documented on its own. That is Depends, and it's the next tier.

Questions people ask about APIRouter

What is an APIRouter? A mini app that holds a group of routes. You define routes on it and attach it to the main app with include_router.

How do I add a common prefix to all routes in a router? Pass prefix="/users" to APIRouter or to include_router.

How do I group routes in the docs? Pass tags=["users"] to the router or to include_router.

Can routers be nested? Yes. A router can include another router, and prefixes and dependencies stack.

Does route order still matter across routers? Yes. Routes match in registration order, so fixed paths in an earlier router can still be shadowed by variable ones.

How should I split files? By resource, such as users.py and orders.py, not by layer. Each file keeps its routes, models, and dependencies together.

A final note

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

What FastAPI adds is that following them costs almost nothing. A router is four lines, inclusion is one. The docs 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

Move endpoints into an APIRouter, mount it with a prefix, and see how a growing FastAPI app stays organised without circular imports.

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.