FastAPI converts a path parameter to the right type automatically, but type checking alone is not enough: /items/0 or /items/-3 still slip through even though an ID in the database is never negative or zero. With Path() and ge, le or pattern, FastAPI rejects bad values with a 422 error right at the API gateway, before your code touches the database. This applies to any endpoint with an ID-like or username-like path parameter.

The problem

Suppose you have an endpoint that fetches a product by ID, declared only as int:

from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int):
return {"item_id": item_id}

The int type only guarantees the value parses as an integer: /items/abc is blocked with a 422, but /items/0 and /items/-3 run straight into the handler. From there the code has to check by itself, or worse, carries a meaningless value down to the database and only gets a 404 back. The problem is that the API gateway has not set its own “rules of the game” for its parameter.

The solution

Declare the constraint right on the parameter with Path() and Annotated:

from typing import Annotated
from fastapi import FastAPI, Path
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(
item_id: Annotated[int, Path(ge=1, le=100000, title="Product ID")],
):
return {"item_id": item_id}

Now /items/5 returns 200 as usual, while /items/0 gets a 422 whose loc is ["path", "item_id"] and whose message says the value must be greater than or equal to 1. Validation runs before the handler, so the database never sees a bad ID. The title parameter also shows up in the OpenAPI docs at /docs.

The same mechanism works for strings, for example a username made only of lowercase letters, digits and underscores:

@app.get("/users/{username}")
def read_user(
username: Annotated[str, Path(min_length=3, max_length=20, pattern="^[a-z0-9_]+$")],
):
return {"username": username}

With this, /users/ab is blocked for being too short, /users/Nguyen_Van is blocked for not matching the pattern, and /users/nguyen_van passes normally.

Apply it to your project

Attach Path() to every ID-like path parameter as soon as you write the route. Pick the ge/le bounds from your real data domain — do not blindly copy the 1–100000 from the example. Path() only checks shape and range; whether the ID exists is still the database layer’s job, where you return 404 when the record is not found.

Related posts

Frequently asked questions

How is Path() different from Query()?

Path() is for parameters inside the path, like /items/{item_id}, while Query() is for parameters after the ?, like /items?skip=0. Both accept the same constraint set — ge, le, min_length, pattern — so you do not have to learn two different ways.

How do I write Path() without Annotated?

You still can: item_id: int = Path(ge=1) is the older style and still works. The Annotated style is recommended because it separates the data type from the metadata cleanly and plays better with type-checking tools.

Can Path() replace checking whether a record exists?

No. Path() only knows whether a value is formally valid — ID 5 is a valid number, but it does not know whether ID 5 is in the database. Keep the lookup step and return 404 as usual after this validation layer.

What should I watch out for when choosing a pattern for strings?

pattern is a Python regular expression; anchor it with ^ and $ if you want to match the whole string. Test the pattern with TestClient before going to production, because a pattern that is too strict will block valid usernames too.

💬 Have you tried this in your own project? If you hit an error or know a cleaner way, leave a comment below — I read them all and will fold the best ideas into the post.

Sources