FastAPI Project Structure — Route Shadowing
Think of FastAPI's router as a waterfall: it tries to match incoming requests from top to bottom based on the order routes are registered. The first pattern that matches 'catches' the request, regardless of specificity.
The Setup
In a clean FastAPI project, endpoints are split across multiple files. During refactoring, you split user-related routes into an APIRouter. One endpoint fetches a specific user by ID, while another fetches the currently authenticated user session.
What Does This Print?
from fastapi import FastAPI, APIRouter
router = APIRouter()
@router.get("/users/{user_id}")
async def get_user(user_id: str):
return {"user_id": user_id, "source": "dynamic"}
@router.get("/users/me")
async def get_current_user():
return {"user_id": "authenticated_user", "source": "static"}
app = FastAPI()
app.include_router(router)
/users/me.
The Output
The request returns a 200 OK status code but with the wrong payload:
Instead of routing the request to get_current_user, FastAPI matched /users/me against the path pattern /users/{user_id}, assigning the string value "me" to the path parameter user_id.
Why Python Does This
FastAPI builds its routing table on top of Starlette's Router. Starlette matches incoming requests against path operations sequentially in the exact order they are registered. Since Python executes module code from top to bottom, the decorator @router.get("/users/{user_id}") registers its route first. When an HTTP request arrives, Starlette's routing engine evaluates the list of registered patterns one by one. Because "me" matches the regular expression generated for {user_id} (which defaults to a string match pattern), execution enters the first matching endpoint. The second route is shadowed and unreachable.
The Fix
from fastapi import FastAPI, APIRouter
router = APIRouter()
# Place static routes before dynamic path parameters to prevent shadowing
@router.get("/users/me")
async def get_current_user():
return {"user_id": "authenticated_user", "source": "static"}
@router.get("/users/{user_id}")
async def get_user(user_id: str):
return {"user_id": user_id, "source": "dynamic"}
app = FastAPI()
app.include_router(router)
Reordering the route registration ensures that the more specific /users/me path is evaluated first. When a request for /users/me arrives, it matches get_current_user directly, preventing it from falling through to the broader /users/{user_id} pattern.
How This Fails in Real Systems
A financial transaction app registered dynamic currency routing before the static total balance endpoint. For three weeks, attempts to call /transactions/balance returned validation errors because the application parsed the string 'balance' as a transaction UUID, causing severe logging noise and blocking client dashboard rendering.