Every so often a framework change ships that looks small in the changelog and turns out to be a real architectural unlock. This is one of them.
FastAPI 0.140.0 quietly changed how include_router() works. The changelog says "preserve APIRouter/APIRoute instances instead of cloning them." That's one sentence. Here's what it actually means - and what you can build because of it.
The Old Behavior
When you called app.include_router(sub_router), FastAPI copied every route out of sub_router and rebuilt them inside the parent app. The sub_router object you kept a reference to was now stale.
Anything you added to it afterward was invisible to the running app - because the app was serving a snapshot from the moment of inclusion.
pro_router = APIRouter(prefix="/pro")
app.include_router(pro_router)
# This route is INVISIBLE - app already snapshotted the router at inclusion time
@pro_router.get("/analytics/{slug}")
async def analytics(slug: str):
...
The only way around this was to re-include the router - which meant either restarting the process or building a custom mount system most people wouldn't bother with.
The New Behavior
The original APIRouter and APIRoute instances are preserved instead of cloned.
The router object you included IS the router object serving traffic.
pro_router = APIRouter(prefix="/pro")
app.include_router(pro_router)
# This route IS visible - FastAPI holds a live reference to the same object
@pro_router.get("/analytics/{slug}")
async def analytics(slug: str):
...
Small sentence. Real consequence: a router can grow new endpoints after your app has already started - same process, no restart, no redeploy.
I Built Something to Prove It
Reading a changelog note is one thing. Watching it work in a live terminal is different. So I built SnipLink - a tiny link-shortener SaaS - specifically to demo this behavior.
The mechanic:
-
pro_routeris defined once, with zero custom routes, and included into the app at startup - Free-tier users get a
404on/pro/analytics/{slug}- the route literally does not exist yet - The moment a user calls
/billing/upgrade, that handler registers a brand-new@router.get("/analytics/{slug}")directly onpro_router- the same live object FastAPI already holds - Same process. No restart. No redeploy.
Here's the full project structure:
sniplink/
├── app/
│ ├── main.py # app setup, router inclusion
│ ├── routers/
│ │ ├── auth.py # signup, api key generation
│ │ ├── links.py # shorten, redirect
│ │ ├── billing.py # upgrade - the moment the route appears
│ │ └── pro.py # pro_router lives here, starts empty
│ └── core/
│ └── store.py # in-memory storage
├── pyproject.toml
└── requirements.txt
The Key Code
pro.py - the router that starts empty:
from fastapi import APIRouter, Depends
from app.core.store import store
from app.core.auth import get_current_user
pro_router = APIRouter(prefix="/pro", tags=["pro"])
def register_analytics_route():
"""
Called at runtime - after the router is already included in the app.
Under the old FastAPI behavior, this would have been invisible.
Under 0.140.0+, the app holds a live reference to pro_router,
so this route appears immediately, no restart needed.
"""
@pro_router.get("/analytics/{slug}")
async def analytics(slug: str, user=Depends(get_current_user)):
link = store["links"].get(slug)
if not link:
return {"detail": "Unknown slug"}
return {
"slug": slug,
"clicks": link["clicks"],
"target_url": link["target_url"]
}
billing.py - the upgrade endpoint that fires the registration:
from fastapi import APIRouter, Depends
from app.routers.pro import register_analytics_route
from app.core.auth import get_current_user
from app.core.store import store
router = APIRouter(prefix="/billing", tags=["billing"])
@router.post("/upgrade")
async def upgrade(user=Depends(get_current_user)):
# Upgrade the user's plan in storage
store["users"][user["api_key"]]["plan"] = "pro"
# Register the route on the live router object
register_analytics_route()
return {
"plan": "pro",
"message": "Pro unlocked. Try GET /pro/analytics/{slug} now."
}
main.py - inclusion happens once, at startup:
from fastapi import FastAPI
from app.routers.auth import router as auth_router
from app.routers.links import router as links_router
from app.routers.billing import router as billing_router
from app.routers.pro import pro_router
app = FastAPI(title="SnipLink")
app.include_router(auth_router)
app.include_router(links_router)
app.include_router(billing_router)
app.include_router(pro_router) # starts empty - grows at runtime
The Live Demo - Terminal Proof
Here's the full sequence, no edits:
Step 1 - Sign up (free tier):
curl -X POST localhost:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "navas123@gmail.com"}'
{"api_key": "sk_la3yzxfuetrazshtu92yhnib", "plan": "free"}
Step 2 - Shorten a link:
curl -X POST localhost:8000/links \
-H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib" \
-H "Content-Type: application/json" \
-d '{"target_url": "https://fastapi.tiangolo.com"}'
{"slug": "pneu49", "target_url": "https://fastapi.tiangolo.com/", "clicks": 0}
Step 3 - Hit the pro endpoint (free tier):
curl localhost:8000/pro/analytics/pneu49 \
-H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib"
{"detail": "Not Found"}
The route doesn't exist. This isn't a permissions error - 404 Not Found means FastAPI has no matching route registered. The endpoint is not there.
Step 4 - Upgrade:
curl -X POST localhost:8000/billing/upgrade \
-H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib"
{"plan": "pro", "message": "Pro unlocked. Try GET /pro/analytics/{slug} now."}
register_analytics_route() just fired. Same process. No restart.
Step 5 - Hit the same path again:
curl localhost:8000/pro/analytics/pneu49 \
-H "X-API-Key: sk_la3yzxfuetrazshtu92yhnib"
{"slug": "pneu49", "clicks": 0, "target_url": "https://fastapi.tiangolo.com/"}
The response changed. The route now exists.
The Detail Worth Slowing Down On
Look at Steps 3 and 5 again. Both requests hit the same URL. Same server. Same process. The difference is what happened between them.
Step 3 returns "Not Found" - FastAPI routing matched nothing.
Step 5 returns real data - the route is now registered and serving.
And open /docs between those two requests. The OpenAPI schema gains a new endpoint in real time - you can watch it appear in Swagger UI the moment the upgrade fires. No page reload trick. FastAPI regenerates the schema from the live router state.
What This Unlocks for SaaS
Before this refactor, dynamic route registration was fragile enough that most people avoided it. You'd reach for a different pattern - a middleware check, a feature flag in the handler, or just a full restart.
Now the pattern is clean: feature gating at the router level.
- User on free tier → route doesn't exist → clean 404
- User upgrades → route appears → feature is live
- No middleware, no flag checks, no infrastructure change
This is different from a permission check inside a handler. The route genuinely doesn't exist for free users. They can't hit a 403, they can't probe the endpoint shape, they see exactly what unauthenticated users see for any nonexistent path.
One Caveat Worth Knowing
register_analytics_route() is called every time someone upgrades. If multiple users upgrade, the function runs multiple times. FastAPI handles duplicate route registration by keeping the first and ignoring subsequent identical paths - but it's worth being deliberate about this.
In production you'd want a guard:
_analytics_registered = False
def register_analytics_route():
global _analytics_registered
if _analytics_registered:
return
@pro_router.get("/analytics/{slug}")
async def analytics(slug: str, user=Depends(get_current_user)):
...
_analytics_registered = True
Or use a module-level set to track which routes have been registered, keyed by path.
Deployment
One command. One minute.
fastapi deploy
FastAPI Cloud
🚀 Deploying app...
✅ Build complete!
...
✅ Ready the chicken! 🐔
Your app is ready at https://sniplink.fastapicloud.dev
One note worth saving if you use a nested app/ layout: run fastapi dev with no file path argument. Passing app/main.py explicitly hits a known import-path bug in nested layouts - it raises ModuleNotFoundError: No module named 'app'. Declare the entrypoint in pyproject.toml instead:
[tool.fastapi]
entrypoint = "app.main:app"
That sidesteps the bug for both fastapi dev and fastapi deploy.
Try It Yourself
git clone https://github.com/Navashub/sniplink
cd sniplink
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
fastapi dev
Open http://127.0.0.1:8000/docs and follow the upgrade sequence from /docs - watch the schema change in real time.
Live demo: https://sniplink.fastapicloud.dev
Requires fastapi[standard]>=0.140.0 - the first version with the router-preservation refactor this whole project exists to demo.
I'm a Data Engineer and AI Builder in Nairobi - building in public across FastAPI, RAG systems, data pipelines, and SaaS products. I write about what I'm building as I build it, mistakes included.


Top comments (0)