APIRouter Prefix vs Sub-Application Mounting in FastAPI
Key takeaways:
include_routercopies routes onto the parent;mounthands a path prefix to a separate ASGI application.- A mounted app generates its own OpenAPI document with its own title and version — the parent schema does not list it.
- A mounted app does not inherit the parent's exception handlers, and the resulting failure is a
RuntimeError, not a clean 500. - A mounted app does sit behind the parent's middleware, which is the opposite of what most write-ups claim.
- Mount for genuine isolation; otherwise
include_routerkeeps one schema and one error contract.
This decision guide belongs to Modular Router Organization. If your question is how to split one API across version prefixes rather than how to isolate a section, Versioning APIs with FastAPI Routers is the more direct answer.
The Problem This Solves
Both calls put routes under a path prefix, and a smoke test cannot tell them apart:
app.include_router(admin_router, prefix="/internal") # routes at /internal/*
app.mount("/internal", admin_app) # also routes at /internal/*
The difference only shows up later, in the places that are expensive to change: the published API document, the error contract, and the startup sequence. Teams usually discover it when a client SDK generated from /openapi.json is missing half the API, or when a carefully built error envelope stops applying to one section of the service for no visible reason.
Why It Happens
include_router is a loop, not a link. It walks the router's routes list and re-registers each route on the target, applying the prefix, tags, and dependencies you passed. Afterwards there is no router object in the request path at all — the parent owns copies of those routes, and the parent's schema generator sees them like any other route. That is why prefixes compose when you nest routers, and why changing a router after including it has no effect on the app.
mount adds a single Mount route to the parent. When a path matches its prefix, Starlette rewrites root_path and path in the ASGI scope and calls the mounted application. The parent is now finished: it does not look at the remaining path, it does not consult its own handler table, and it does not contribute to the schema for anything under that prefix. The mounted object only has to be an ASGI callable, which is exactly why you can mount a WSGI app, a static-files app, or a completely unrelated framework there.
The consequences follow mechanically from where each mechanism sits in the ASGI stack:
- Schema generation walks
app.routes. AMountis not anAPIRoute, so it contributes nothing to the parent document. - Exception handlers live in
ExceptionMiddleware, which eachFastAPI()builds inside its own stack. The sub-application has its own. - Middleware you register with
add_middlewareis built outside the parent's router, so it wraps theMountroute along with everything else. This is the one thing that does cross the boundary, and it is routinely reported the other way around. - Lifespan is driven by the server against the top-level app only. A mounted app's
lifespannever runs unless you arrange it yourself.
The Fix
The way to settle this is to build both in one process and interrogate them. The example registers a StampMiddleware and an OutOfStock exception handler on the parent, then includes one router and mounts two sub-applications — one bare, one with the cross-cutting concerns re-registered:
class OutOfStock(Exception):
def __init__(self, sku: str) -> None:
self.sku = sku
internal = FastAPI(title="Internal Admin", version="9.9.9")
@internal.get("/boom")
async def internal_boom() -> dict[str, Any]:
# Identical to the parent's /boom. The parent has a handler for this exception.
raise OutOfStock(sku="SKU-1")
app = FastAPI(title="Storefront API", version="1.4.0")
app.add_middleware(StampMiddleware, value="parent-middleware")
@app.exception_handler(OutOfStock)
async def on_out_of_stock(request: Request, exc: OutOfStock) -> JSONResponse:
return JSONResponse(
status_code=409,
content={"error": "out_of_stock", "sku": exc.sku, "handled_by": "parent app"},
)
app.include_router(catalog)
app.mount("/internal", internal)
The two schemas are genuinely separate
Reading both OpenAPI documents straight out of the two application objects gives the real output:
$ GET /schema-comparison
200 OK
{
"parent_openapi": {
"title": "Storefront API",
"version": "1.4.0",
"paths": [
"/catalog/boom",
"/catalog/items/{item_id}",
"/schema-comparison",
"/selftest"
]
},
"mounted_openapi": {
"title": "Internal Admin",
"version": "9.9.9",
"paths": [
"/boom",
"/stats"
]
},
"parent_schema_lists_mounted_routes": false
}
Two documents, two titles, two version numbers. The mounted paths are recorded without the /internal prefix, because the sub-application has no idea it is mounted — the prefix is stripped from the scope before it ever sees the request. Anyone generating a client from the parent's /openapi.json gets an SDK with no admin API in it, and the mounted app's own document describes paths that are wrong by exactly the mount prefix unless you set root_path on it.
The exception handler does not reach across, and the failure is ugly
The same OutOfStock is raised on both sides of the boundary. This is the recorded result:
$ GET /selftest
200 OK
[
{
"request": "GET /catalog/items/7 (included router route)",
"status": 200,
"x-stamped-by": "parent-middleware",
"response": {
"app": "parent",
"item_id": 7
}
},
{
"request": "GET /internal/stats (mounted sub-app route)",
"status": 200,
"x-stamped-by": "parent-middleware",
"response": {
"app": "internal",
"queue_depth": 3
}
},
{
"request": "GET /catalog/boom (included router raises OutOfStock)",
"status": 409,
"response": {
"error": "out_of_stock",
"sku": "SKU-1",
"handled_by": "parent app"
}
},
{
"request": "GET /internal/boom (mounted sub-app raises OutOfStock)",
"status": "no response produced",
"propagated_out_of_the_app": "RuntimeError: Caught handled exception, but response already started."
}
]
Three things in that transcript are worth reading slowly.
First, x-stamped-by is present on the mounted route. The parent's middleware did run for a mounted request. The widespread claim that a mount is invisible to parent middleware is wrong on FastAPI 0.139.2, and it matters: your tracing and CORS middleware do keep working across a mount.
Second, the identical exception produced a clean 409 on the included router and no response at all on the mounted one. The sub-application's own exception middleware caught something it had no handler for and began emitting a 500; the exception then propagated outward to the parent, whose handler tried to write a second response onto a stream that had already started. Starlette refuses, with RuntimeError: Caught handled exception, but response already started. Under a real server this surfaces as a dropped or malformed response and a noisy traceback — considerably worse than the 500 you were expecting.
Re-register what the sub-application needs
The fix is not clever: a mounted app is a separate app, so give it the same wiring.
internal_fixed = FastAPI(title="Internal Admin (handlers re-registered)", version="9.9.9")
internal_fixed.add_middleware(StampMiddleware, value="sub-app-middleware")
@internal_fixed.exception_handler(OutOfStock)
async def on_out_of_stock_sub(request: Request, exc: OutOfStock) -> JSONResponse:
return JSONResponse(
status_code=409,
content={"error": "out_of_stock", "sku": exc.sku, "handled_by": "mounted sub-app"},
)
With the handler registered on the sub-application, the same request behaves:
$ GET /selftest
200 OK
[
{
"request": "GET /internal-fixed/boom (handler re-registered on the sub-app)",
"status": 409,
"x-stamped-by": "parent-middleware",
"response": {
"error": "out_of_stock",
"sku": "SKU-1",
"handled_by": "mounted sub-app"
}
},
{
"request": "GET /catalog/items/1 (does sub-app middleware leak upward?)",
"status": 200,
"x-stamped-by": "parent-middleware"
}
]
The sub-application now renders its own 409. Note the header still reads parent-middleware: both middlewares ran, and the parent's is outermost, so it wrote last. Note also the final line — middleware registered on the sub-application does not leak upward onto the parent's own routes. Isolation is one-directional.
Verification
Three assertions pin the boundary in CI, and they fail loudly the day someone converts a mount into an include or vice versa:
def test_mounted_routes_are_absent_from_the_parent_schema(client):
paths = client.get("/openapi.json").json()["paths"]
assert not any(p.startswith("/internal") for p in paths)
def test_each_app_publishes_its_own_document(client):
assert client.get("/openapi.json").json()["info"]["title"] == "Storefront API"
assert client.get("/internal/openapi.json").json()["info"]["title"] == "Internal Admin"
def test_the_mounted_app_renders_our_error_envelope(client):
# Guards the failure mode above: without a handler on the sub-app this does not return 409.
body = client.get("/internal-fixed/boom").json()
assert body["error"] == "out_of_stock"
The third test is the one that earns its place. It is the difference between discovering the missing handler now and discovering it from a RuntimeError in production logs.
Trade-offs and When Not To
include_router is the correct default, and the bar for mounting should be high. Mounting costs you a single published API document, forces every cross-cutting concern to be registered twice, and gives you a second /docs that clients will find and be confused by. It also means the sub-application's lifespan does not run, so any pool or client it expects to build at startup silently never exists — the reason lifespan events deserve attention before you reach for a mount.
Mounting is right when the isolation is the point. A genuinely separate published API — an internal admin surface you do not want in the public SDK — is a good reason, because the split schema is the feature rather than the cost. So is hosting something you did not write: a WSGI application, a metrics exporter, a static site. So is a section that must keep a different middleware stack, though as the transcript shows you get the parent's middleware plus its own rather than instead of it.
If you are mounting mainly to get separate documentation for parts of one cohesive API, you almost certainly want router tags and OpenAPI grouping instead. Tags group operations inside one document, which is what most teams actually wanted.
FAQ
What is the difference between include_router and mount in FastAPI?include_router copies a router's routes onto the parent application, so they land in the parent's OpenAPI schema and are dispatched by the parent's router. mount attaches a whole separate ASGI application at a path prefix; the parent forwards the request to it and stops participating in routing. The first is composition, the second is delegation.
Does a mounted sub-application inherit the parent's middleware?
Yes, in practice. Parent middleware is registered outside the parent's router, so every request routed to a mount passes through it and the mounted routes are stamped by it — the transcript above shows x-stamped-by: parent-middleware on a mounted route. What a mounted app does not inherit is the parent's exception handlers, because those live inside the sub-application's own ASGI stack.
Do mounted routes appear in the parent's OpenAPI schema?
No. A verified run shows the parent schema listing only its own paths, with parent_schema_lists_mounted_routes reporting false. The mounted application generates a completely separate document served at its own /openapi.json and rendered at its own /docs, with its own title and version.
Why does an exception raised inside a mounted app not reach the parent's handler?
The sub-application has its own exception middleware, which does not know the type and starts producing a 500 response. By the time the exception reaches the parent's handler the response has already begun, and Starlette raises RuntimeError: Caught handled exception, but response already started.
When should I mount instead of including a router?
Mount when a section must be genuinely independent: a separate published API document, a different middleware stack, an isolated lifespan, a WSGI or non-FastAPI application, or a third-party ASGI app. For anything that is part of one product API, include_router is simpler and keeps one schema.
Related Reading
- Up to the topic: Modular Router Organization, which frames how routers are composed in the first place.
- for splitting one API across version prefixes rather than isolating a section: Versioning APIs with FastAPI Routers.
- for grouping operations inside a single document instead of splitting it: Router Tags and OpenAPI Grouping.
- for the startup hook a mounted app never receives: Lifespan Events vs Startup and Shutdown.
- for the error contract you must re-register on every mounted app: Global Exception Handlers for Consistent API Responses.