Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

deadlovelll
Copy link

Automatic Route Reordering in FastAPI

Problem

Static routes (e.g., /items/stats) were sometimes being matched by dynamic routes like /items/{item_id}.
This caused path parameter validation to fail and returned HTTP 422, instead of hitting the intended static handler.

Root cause:
Starlette/FastAPI matches routes in the order they are registered. Dynamic routes could be registered before their static siblings, leading to static routes being shadowed.


Impact

  • Clients could receive 422 Unprocessable Entity errors unexpectedly.
  • Large applications with many routers/endpoints were particularly vulnerable, as maintaining correct registration order manually is difficult.

Solution

  • Added an automatic route reordering step after router registration.
  • Prioritization rules:
    1. Routes with fewer dynamic segments ({}) are prioritized first.
    2. Among routes with the same number of dynamic segments, routes with more static segments are prioritized.

Benefits

  • Ensures static routes are always matched before overlapping dynamic routes.
  • Eliminates unexpected 422 errors caused by shadowed static paths.
  • Fully backward-compatible: no changes are required by users—the reordering happens automatically when the FastAPI app starts.

Reproduction Steps

1. Save the following code as main.py:

import uvicorn
from fastapi import FastAPI

app = FastAPI()

# Dynamic route
@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}

# Static route
@app.get("/items/stats")
async def read_stats():
    return {"status": "ok"}

if __name__ == '__main__':
    uvicorn.run("main:app")

2. Run the application:

python main.py

3. Test the dynamic route:

curl -i http://localhost:8000/items/123

Response:

HTTP/1.1 200 OK
{"item_id":123}

4. Test the static route:

curl -i http://localhost:8000/items/stats

Response before fix (without route reordering):

HTTP/1.1 422 Unprocessable Entity
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "item_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "stats"
    }
  ]
}

Response after fix (with automatic route reordering):

HTTP/1.1 200 OK
{"status": "ok"}

5. Does this affect application startup time?

No, the impact on startup is negligible. You can verify this with the following benchmark:

import time
import asyncio
from fastapi import FastAPI
import httpx
import uvicorn

NUM_ENDPOINTS = 100

def route_sort_key(router):
    path = getattr(router, "path", None) or getattr(router, "path_format", "") or ""
    parts = [p for p in path.strip("/").split("/") if p]
    dyn = sum(1 for p in parts if p.startswith("{"))
    static = len(parts) - dyn
    total = len(parts)
    return (dyn, -static, -total, path)

def create_app(reorder=False) -> FastAPI:
    app = FastAPI()
    for i in range(NUM_ENDPOINTS):
        @app.get(f"/items/{I}")
        async def read_item(item_id: int, idx=i):
            return {"item_id": idx}
        
        @app.get(f"/items/static-{I}")
        async def read_static(idx=i):
            return {"static": idx}

    if reorder:
        app.router.routes.sort(key=route_sort_key)
    return app

async def benchmark_app(app: FastAPI):
    config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="error")
    server = uvicorn.Server(config)
    
    loop = asyncio.get_event_loop()
    task = loop.create_task(server.serve())
    
    await asyncio.sleep(0.5)
    
    async with httpx.AsyncClient() as client:
        start = time.perf_counter()
        for i in range(NUM_ENDPOINTS):
            await client.get(f"http://127.0.0.1:8000/items/{i}")
            await client.get(f"http://127.0.0.1:8000/items/static-{i}")
        elapsed = time.perf_counter() - start
    
    server.should_exit = True
    await task
    return elapsed

async def main():
    print("Benchmarking without route reordering...")
    app = create_app(reorder=False)
    t1 = await benchmark_app(app)
    print(f"Time taken: {t1:.4f} seconds\n")
    
    print("Benchmarking with route reordering...")
    app_reordered = create_app(reorder=True)
    t2 = await benchmark_app(app_reordered)
    print(f"Time taken: {t2:.4f} seconds\n")

if __name__ == "__main__":
    asyncio.run(main())

Result:

(venv) ➜  fastapi-test python main.py
Benchmarking without route reordering...
Time taken: 0.1129 seconds

Benchmarking with route reordering...
Time taken: 0.1270 seconds

Its only costs 1ms per 100 routes

Copy link
Contributor

github-actions bot commented Sep 7, 2025

@svlandeg svlandeg added the feature New feature or request label Sep 8, 2025
@svlandeg svlandeg changed the title Fix Automatic Route Shadowing in FastAPI ✨ Prioritize static routes to avoid route shadowing of dynamic routes Sep 8, 2025
Copy link
Contributor

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@deadlovelll, thanks for your interest!

I also tried solving this problem, with no success. The thing is that there are lots of edge cases we need to consider..

In this implementation reorder_routes_on_startup is called in the constructor (__init__()) of the app, so it actually only sorts internal automatically added routes ('/openapi.json', '/docs', '/docs/oauth2-redirect', '/redoc') - all user-defined routes are added later and they are not reordered.
Just run this test that is created from the example from tutorial001:
(see in the details)

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


# Dynamic route
@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}


# Static route
@app.get("/items/stats")
async def read_stats():
    return {"status": "ok"}


def test_():
    client = TestClient(app)

    resp = client.get("/items/stats")
    assert resp.status_code == 200  #  assert 422 == 200

That's why we need to cover all code examples from docs by tests.. 🤓

For now to make it working you need to manually call app.reorder_routes_on_startup() after adding all routes.

Just a couple of use cases that are not handled in your implementation (there are more of them):

  1. Route {path:path} parameter will shadow all routes with more than 1 path parameter:
@app.get("/case1/{item_id}/{action}/")
def act(item_id: int, action: str):
    return {"item_id": item_id, "action": action}

@app.get("/case1/{path:path}")
def default(path: str):
    return {"path": path}
  1. imagine we want to handle int item_ids separately from str ones:
@app.get("/case2/{item_id:int}/{action}/")
def int_ids(item_id: int, action: str):
    return {"route": "int_ids"}

@app.get("/case2/{item_id:str}/add/")
def str_ids_add(item_id: str):
    return {"route": "str_ids_add"}

@app.get("/case2/{item_id:str}/{action}/")
def str_ids_default(item_id: str, action: str):
    return {"route": "str_ids_default"}

In both these cases reordering will brake the app..

See tests in the details

from fastapi import FastAPI
from fastapi.testclient import TestClient


app = FastAPI()

# 1


@app.get("/case1/{item_id}/{action}/")
def act(item_id: int, action: str):
    return {"item_id": item_id, "action": action}


@app.get("/case1/{path:path}")
def default(path: str):
    return {"path": path}


# 2


@app.get("/case2/{item_id:int}/{action}/")
def int_ids(item_id: int, action: str):
    return {"route": "int_ids"}


@app.get("/case2/{item_id:str}/add/")
def str_ids_add(item_id: str):
    return {"route": "str_ids_add"}


@app.get("/case2/{item_id:str}/{action}/")
def str_ids_default(item_id: str, action: str):
    return {"route": "str_ids_default"}


app.reorder_routes_on_startup()


# Tests

client = TestClient(app)


# 1
def test_case1_add():
    resp = client.get("/case1/1/add/")
    assert resp.status_code == 200, resp.json()
    assert resp.json() == {"item_id": 1, "action": "add"}


def test_case1_default():
    resp = client.get("/case1/something/")
    assert resp.status_code == 200, resp.json()
    assert resp.json() == {"path": "something/"}


# 2


def test_case2_int():
    resp = client.get("/case2/1/add/")
    assert resp.status_code == 200, resp.json()
    assert resp.json() == {"route": "int_ids"}


def test_case2_str_add():
    resp = client.get("/case2/asd/add/")
    assert resp.status_code == 200, resp.json()
    assert resp.json() == {"route": "str_ids_add"}


def test_case2_str_other():
    resp = client.get("/case2/asd/other/")
    assert resp.status_code == 200, resp.json()
    assert resp.json() == {"route": "str_ids_default"}

@deadlovelll
Copy link
Author

Thank you for your feedback) i will check it and make some work under my mistakes

@github-actions github-actions bot removed the waiting label Sep 8, 2025
@YuriiMotov YuriiMotov marked this pull request as draft September 8, 2025 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature New feature or request waiting
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants