Values taken from the URL itself - converted, validated, and documented from one annotation.
Overview
The mechanism
A path parameter is a piece of the URL that changes from one request to the next. In /users/42, the 42 is the part that varies. You mark that part with curly braces in the route and give the function an argument with the same name. FastAPI hands you the value.
The useful part is what happens before your function runs. The raw text from the URL is a string. If you annotate the argument as an integer, FastAPI converts it and rejects anything that is not a number. The client gets a clear error, and your code only ever sees valid data. The same annotation also feeds the generated documentation.
This article starts with the mechanism: how the name in the path is matched to the argument in the function. It then explains why path parameters are always required, which follows from how a URL is built. You will see how the coercion rules you may know from Pydantic apply here too, and how to read the error when a value fails.
After that come constraints. The Path function lets you set limits such as a minimum value or a length. The article also shows how an enum restricts a parameter to a closed set of allowed values, which is a tidy way to handle things like status names or categories.
By the end you should be able to design URLs that carry identifiers safely and predict exactly what a client will see when it sends something odd.
Path parameters are also the first place where you see how much work one annotation can do. The same line that names the type also drives the conversion, the error message, and the entry in the docs. That pattern repeats across the whole framework, so understanding it here pays off later. If you have used Pydantic before, the rules will feel familiar. If you have not, each rule is explained as it appears. Plan on a slow read, because the details are small but they decide how your URLs behave in practice. Each section ends with a small habit you can apply right away, so you finish with working knowledge and not only theory.
A name in braces in the route path turns an arg to your function:
Three things happen from those two lines. The router matches the URL and pulls out the segment. Pydantic converts it per to the annotation. And the OpenAPI doc records that this endpoint takes an int path param.
The name has to match. {module_id} in the path and module_id in the signature are connected by name, not spot. A mismatch is an error at import rather than at request time. Which is the right moment to find out.
Worth knowing
A name in braces in the path becomes a parameter of the same name. The annotation decides the type it arrives as.
Path parameters are always required — they are part of the URL, so there is no way to omit one.
Errors carry loc beginning with path, which tells the caller the problem is in the URL rather than the body.
Path() takes the same constraints as Pydantic's Field — ge, le, min_length, pattern — and they appear in the schema.
An Enum gives a closed set: a clear error listing the options, and a documented enumeration a client can render.
{name:path} matches across slashes. A plain parameter stops at the next one.
Path Parameters: Values From the URL
One annotation gives you extraction, conversion, check and docs.
A placeholder becomes an argument
Whatever is in braces in the path is matched to a parameter of the same name. The annotation decides what it becomes.
example_01.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules/{module_id}")
def read(module_id: int):
return {"module_id": module_id, "type": type(module_id).__name__}
client = TestClient(app)
print(client.get("/modules/7").json())
print(client.get("/modules/0042").json(), "<- leading zeros gone; it is an int")
r = client.get("/modules/seven")
print()
print("not a number:", r.status_code, r.json()["detail"][0]["msg"])
Output
The error names the path
loc starts with path, so a caller knows the problem is in the URL rather than in what they sent.
example_02.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules/{module_id}/lessons/{lesson_id}")
def read(module_id: int, lesson_id: int):
return {"module": module_id, "lesson": lesson_id}
client = TestClient(app)
print("valid :", client.get("/modules/7/lessons/2").json())
r = client.get("/modules/7/lessons/two")
for err in r.json()["detail"]:
print("loc :", err["loc"])
print("type :", err["type"])
print("msg :", err["msg"])
Output
Constraining the value
Path() carries the same constraints as Pydantic's Field, and they reach the documentation.
example_03.pyFastAPI
import json
from fastapi import FastAPI, Path
app = FastAPI()
@app.get("/modules/{module_id}")
def read(module_id: int = Path(ge=1, le=9999,
description="Identifier of the module.")):
return {"module_id": module_id}
client = TestClient(app)
print("ok :", client.get("/modules/7").json())
r = client.get("/modules/0")
print("0 :", r.status_code, r.json()["detail"][0]["msg"])
print()
spec = app.openapi()["paths"]["/modules/{module_id}"]["get"]["parameters"][0]
print("documented as:", json.dumps(spec))
Output
A closed set of values
An Enum in the path gives you validation, a readable error and a set of choices in the docs.
example_04.pyFastAPI
from enum import Enum
from fastapi import FastAPI
class Track(str, Enum):
MATHS = "maths"
PYTHON = "python"
DSA = "dsa"
app = FastAPI()
@app.get("/tracks/{track}")
def read(track: Track):
return {"track": track.value, "label": track.name.title()}
client = TestClient(app)
print("valid :", client.get("/tracks/maths").json())
r = client.get("/tracks/astrology")
print("invalid:", r.status_code, r.json()["detail"][0]["msg"])
print()
print("in the schema:", app.openapi()["components"]["schemas"]["Track"])
Output
Paths that contain slashes
A normal parameter stops at the next slash. The :path converter lets one swallow the rest.
The rule from the last module, in its most common form: a fixed segment must be registered before a parameter that would also match it.
example_06.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules/latest")
def latest():
return {"route": "latest"}
@app.get("/modules/{module_id}")
def by_id(module_id: int):
return {"route": "by_id", "id": module_id}
client = TestClient(app)
print("/modules/latest ->", client.get("/modules/latest").json())
print("/modules/7 ->", client.get("/modules/7").json())
print()
r = client.get("/modules/oldest")
print("/modules/oldest ->", r.status_code,
"- no fixed route, and 'oldest' is not an int")
Output
They are always required
There's no such thing as an optional path param. The reason is structural. The param is part of the URL. A request that omits it's a request to a other URL. This Either matches another route or doesn't match at all.
So a default on a path param does nothing handy. If you want "with an id, or without", that is two routes: /modules and /modules/{module_id}.
Everything from the coercion rules applies
The segment arrives as text. A URL has nothing else — and the annotation says what it should become. All the Pydantic rules hold: "7" turns 7, "0042" turns 42, and "seven" raises.
UUID is worth annotating properly rather than leaving as str. A malformed identifier is then rejected at the door with a clear message, instead of reaching a DB query as an any string. The same applies to date.
Reading the error
A failure makes a 422 whose loc is ("path", "module_id").
That first element matters. A client receiving a 422 needs to know whether the problem is in the URL, the query string, a header or the body. loc[0] says which. Code that only looks at loc[-1] throws that away.
Constraints with Path()
Path() carries the same args as Pydantic's Field:
module_id: int = Path(ge=1, le=9999, description="Identifier of the module.")
ge=1 is worth more than it looks. Without it, /modules/-5 is a valid request that reaches your handler. A thing downstream deals with a minus identifier. With it, the router rejects it and the documented min appears in the schema.
The same arg from the Pydantic track applies here. A constraint reaches the docs and a checker doesn't. Path(ge=1) tells each consumer the floor. An if module_id < 1 in the handler tells no one.
Enums for a closed set
When a path segment can only be one of a few values, an Enum is the right annotation:
class Track(str, Enum):
MATHS = "maths"
PYTHON = "python"
Three gains. The error lists the permitted values instead of saying a thing vague. The schema holds an enumeration. The docs render a dropdown and a made client gets a real type. And inside your handler the value is an enum member. So Checks are checked by your editor rather than being string equality you can typo.
Inherit from str and Enum, for the reasons the Pydantic track set out. Members then compare equal to their strings and serialise as plain text.
Paths inside paths
A param matches one segment. /files/{name} won't match /files/og/maths.png. The slash ends the match.
When you truly want the rest of the URL. A file path, a nested key, a proxied route. The :path converter does it:
One warning that matters. A :path param can hold ... Using it to build a filesystem path without checking is a folder-traversal vulnerability. Resolve the path and confirm it's inside the folder you meant before opening anything. check says the value is a string. It says nothing about whether it's safe.
Ordering, again
The rule from the previous module is felt most sharply here. Path params are where shares arise:
@app.get("/modules/latest") # must come first
@app.get("/modules/{module_id}")
Registered the other way round, /modules/latest matches the var route and either 422s confusingly or, if the param is a str, succeeds with a lookup for a module called "latest".
Annotating the param int limits the damage: a non-numeric segment then fails loudly rather than quietly. It's one more reason to be precise.
Designing them
Path params find a thing. Query params show a view of a set. That gap resolves most design args about where a value belongs.
/modules/7 finds module seven. /modules?track=maths narrows a set. /modules/7?verbose=true finds a thing and adjusts how it's presented. Putting track in the path would imply that a module belongs to just one track and can only be handled through it. This May not be true.
Nest only for genuine ownership, and keep it shallow. /modules/{id}/lessons is fair. Four levels of nesting makes URLs no one types right and routes no one can maintain.
Types worth annotating
Beyond int and str, a few standard types earn their place in a path.
**UUID** rejects a malformed identifier at the router rather than passing an any string to a DB query. That is a genuine safety gain when the id comes from a URL somebody can edit.
**date** accepts 2026-08-26 and gives you a real date. /reports/{day} needs no parsing in the handler.
**Enum** for a closed set, as above.
**float** exists and is often wrong in a path. Identifiers aren't floating point. /items/1.0 matching /items/1 is rarely what anyone wants.
The general rule is the same one from the Pydantic track. Annotate as narrowly as the domain allows. Each narrowing drops a class of bad input at the door and docs itself in the schema.
Multiple parameters and their order
A route can have a few placeholders. The order in the *function signature* doesn't matter. Matching is by name.
@app.get("/tracks/{track}/modules/{module_id}")
def read(module_id: int, track: str): # order differs, works fine
That is worth knowing because it means you can order a signature for readability rather than to mirror the URL. Path params first, then query, then body, then dependencies is a common rule and none of it's needed.
What a 404 means here
A path that matches no route at all gives a 404 from the router, before any of your code runs. That is a other thing from a 404 you raise because a lookup found nothing, though the status is the same.
The gap shows up in the response body. The router's 404 is {"detail": "Not Found"}. Yours is what you passed to HTTPException. If you're debugging and see the plain one, the request never matched a route. Check the path, the method and the trailing slash before looking at your handler.
Encoding
Path segments are URL-coded, and FastAPI decodes them before you see them. A module titled Vectors & Norms reaches you as Vectors & Norms, not Vectors%20%26%20Norms.
That mostly just works. The case to be careful with is a value that can itself hold a slash. An coded %2F is decoded to / and can change how the path is read. For anything that might hold one, either use a :path param on purpose or move the value to the query string. Here The ambiguity doesn't arise.
Path Parameters: summary
A braces name in the path turns an arg, converted by its annotation. Path params are always needed. They're part of the URL.
Path() adds constraints and metadata that reach the schema. Enums give closed sets a real type and a good error. :path matches across slashes. Needs care if it reaches a filesystem.
And the sorting rule, once more: fixed segments before var ones.
Mistakes people make with Path Parameters
**Leaving an identifier as str.** A UUID or int annotation rejects malformed input at the router instead of passing an any string to a query.
No lower bound./modules/{id} with a plain int accepts -5. A thing downstream deals with a minus identifier. Path(ge=1) costs one arg.
**Building a filesystem path from a :path param.** It can hold ... Resolve it and confirm it's inside the folder you intended before opening anything. Check says it's a string, not that it's safe.
Expecting a plain param to match a slash. It stops at the next segment. :path is the opt-in.
Confusing the two kinds of 404. The router's plain {"detail": "Not Found"} means no route matched at all. Yours means the route matched and the lookup found nothing.
Putting a filter in the path./modules/maths/vectors implies a module can only be handled through one track. If it's narrowing a set rather than finding a thing, it belongs in the query string.
Where to go after Path Parameters
Query params, which are the opposite in almost each respect. Optional by default, unordered. The place most of an API's flexibility lives.
What good path design buys
A well-designed path is a promise about identity: this URL names this thing, and will keep naming it.
That is what makes bookmarking, caching, linking and logging work. A URL that means a thing other based on a query param, or that codes a filter as a segment, breaks all four quietly.
The test is simple. Read the path aloud without the query string. If it names one identifiable thing, or one named set, it's right. If you've to explain what it returns, a thing belonging in the query string has ended up in the path.
Versioning and stability
Paths are the most public part of an API. The hardest to change once anyone depends on them.
The common approach is a version prefix. /v1/modules. Which is honest and coarse. It lets you make breaking changes by publishing a new version, at the cost of keeping two.
The option is to avoid breaking changes: add fields rather than dropping them, add params rather than changing defaults. Deprecate before deleting. Most APIs need far fewer versions than they expect if they hold that line.
What is worth deciding early is where the version lives. Retrofitting a prefix touches each route, each client and each piece of docs. A router with prefix="/v1" costs nothing on day one.
Identifiers people can see
A last design note. Sequential int ids in a public URL leak data. How many modules exist, roughly when one was created. Whether the id next to yours exists.
For anything where that matters, a UUID or a short opaque id is worth the small inconvenience. And annotating it UUID gives you check for free. For an inner API it rarely matters. Ints are easier to read in logs.
The one-line summary
A path param finds a thing, is always needed, arrives as text and turns what you annotate it as. Annotate it narrowly, constrain it with Path(), declare fixed routes before var ones. Treat a :path value as untrusted the moment it touches a filesystem.
A closing thought
The path is the part of an API that outlives all else. Response shapes change, params come and go. A URL somebody bookmarked, logged, cached or hard-coded is forever.
That skew is worth remembering when designing one. A few minutes deciding whether a value finds a resource or merely filters a set is cheap now and unrecoverable later.
Two small conventions
Singular or plural. sets are plural. /modules, /tracks. And an item is that set plus an identifier. Mixing /module/7 and /modules in one API is the kind of inconsistency that costs a caller a request each time they guess wrong.
Lower case, hyphens if needed. Paths are case-sensitive in the standard and inconsistently handled . Sticking to lower case drops a class of bug that only appears on somebody else's server.
Neither is enforced and both are worth deciding once. The option is deciding per endpoint and getting it wrong one spot.
Questions people ask about Path Parameters
Are path parameters ever optional? No. A value in the path is always required, because the route would not match without it. Use a query parameter for optional input.
What happens if the value can't convert to the declared type? FastAPI returns a 422 response naming the parameter and the reason, and your function never runs.
How do I restrict a path parameter to a range? Use Path() with constraints such as ge=1 or le=100, for example item_id: int = Path(ge=1).
How do I accept a closed set of values? Declare the parameter as an Enum subclass of str. Anything outside the set is rejected with a 422 and the docs show a dropdown.
How do I capture a path that contains slashes? Use the path converter: /files/{file_path:path}. It tells Starlette to match the rest of the URL, slashes included.
Why does /users/me get treated as a user id? The variable route was declared first and matched "me". Declare /users/me before /users/{user_id}.
Where this fits
Path params are the smallest of the three input sources and the one with the least room for view. A segment of the URL, needed, converted by annotation.
That simplicity is why they're worth getting just right. There's nothing to configure and nothing to trade off. Only the choice of how narrowly to annotate. Whether the value belongs in the path at all. Both choices are made once per endpoint and last if the URL does.
Check yourself
0 of 4
Answer without scrolling back up.
Can a path parameter be optional?
A request without the segment is a request to a different path. If you want both shapes, declare two routes.
What is `loc` for a failed path parameter?
The first element names which part of the request failed - path, query, header or body - which is what lets a client point the user at the right thing.
Why annotate a path parameter as `Enum` rather than `str`?
A `str` accepts anything and documents nothing. The enum gives the caller the permitted values and gives a generated client a real type.
What does `{full_path:path}` do that a plain parameter does not?
A plain parameter stops at the next slash. Note that a `:path` value can contain `..`, so building a filesystem path from one without checking is a traversal vulnerability.
Cheat sheet
Path Parameters
Add a path parameter with a type annotation, send a request with a wrong type, and watch FastAPI extract, convert and validate in one step.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.