Path Parameters

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on 2026-10-02. All code needs Python 3.10 or newer and was executed on Python 3.13 with TestClient (several routes also against a real Uvicorn server with curl). A path parameter is a variable segment of the URL path, such as the 42 in /books/42. It identifies which resource a request is about, which is why it is always required. Query parameters (Query Parameters) refine how the resource is returned, and the request body (Request Body & Models) carries the data being written.

Declaring and Converting Path Parameters

A path parameter is declared by putting a name in braces in the path template and giving the function a parameter with the same name. FastAPI (via Starlette’s router) extracts the text from the URL and Pydantic converts it to the declared type. The type hint is the whole contract: there is no separate parsing code (see Python Types & Annotated).

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/books/{book_id}")
def read_book(book_id: int) -> dict:
    return {"book_id": book_id, "type": type(book_id).__name__}


c = TestClient(app)
print(c.get("/books/42").json())
r = c.get("/books/abc")
print(r.status_code)
print(r.json())
print(c.get("/books/4.2").json()["detail"][0]["msg"])
print(c.get("/openapi.json").json()["paths"]["/books/{book_id}"]["get"]["parameters"])

The handler receives a Python int, not the string "42":

{"book_id": 42, "type": "int"}

A value that cannot be converted never reaches the function. FastAPI answers 422 (Starlette now labels that status Unprocessable Content; older releases said Unprocessable Entity) and says exactly where the problem is. The loc list starts with path, then the parameter name:

{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["path", "book_id"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}

4.2 fails the same way, because int in the default (lax) mode accepts text such as "42" but not a decimal. In OpenAPI the parameter is listed as {'name': 'book_id', 'in': 'path', 'required': True, 'schema': {'type': 'integer', 'title': 'Book Id'}}; Swagger UI therefore renders a required integer field, and generated clients get a typed argument.

Two distinct failures are easy to mix up. A path value that is malformed (/books/abc) is a validation error, 422. A well-formed value for which no record exists (/books/99999) is a business outcome: the handler raises HTTPException(status_code=404) itself, as in the larger example later on this page and in Status Codes & Error Handling. 404 from the router itself means that no route template matched the URL at all.

Which Types Work

Any type that Pydantic can build from a string works, because path values always arrive as text. The common ones:

Declared type Accepted text Rejected with

int

42, -7

int_parsing for abc, 4.2

float

3.5, 1e3 (becomes 1000.0)

float_parsing

bool

true, false, 1, 0, yes, no, on, off (lax mode)

bool_parsing for maybe

str

any text without /

(never fails; see the :path converter below)

UUID

0b0e8a0e-3b0a-4a6a-9c33-3a1d7a0f5c11

uuid_parsing

date

2026-10-02

date_from_datetime_parsing for zz and for impossible dates such as month 13

Enum subclass

one of the member values

enum

Literal["json", "xml"]

one of the literals

literal_error

from datetime import date
from typing import Literal
from uuid import UUID

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/authors/{author_id}")
def author(author_id: UUID) -> dict:
    return {"id": str(author_id), "type": type(author_id).__name__}


@app.get("/releases/{day}")
def release(day: date) -> dict:
    return {"day": day.isoformat()}


@app.get("/flags/{on}")
def flag(on: bool) -> dict:
    return {"on": on}


@app.get("/exports/{fmt}")
def export(fmt: Literal["json", "xml"]) -> dict:
    return {"fmt": fmt}


c = TestClient(app)
print(c.get("/authors/0b0e8a0e-3b0a-4a6a-9c33-3a1d7a0f5c11").json())
print(c.get("/authors/nope").json()["detail"][0]["msg"])
print(c.get("/releases/2026-10-02").json())
print(c.get("/releases/2026-13-02").json()["detail"][0]["msg"])
for text in ["true", "1", "yes", "on", "maybe"]:
    r = c.get(f"/flags/{text}")
    print(text, r.status_code, r.json())
print(c.get("/exports/json").json(), c.get("/exports/csv").json()["detail"][0]["msg"])

The real output, abridged:

{'id': '0b0e8a0e-3b0a-4a6a-9c33-3a1d7a0f5c11', 'type': 'UUID'}
Input should be a valid UUID, invalid character: found `n` at 1
{'day': '2026-10-02'}
Input should be a valid date or datetime, month value is outside expected range of 1-12
true 200 {'on': True}
1 200 {'on': True}
yes 200 {'on': True}
on 200 {'on': True}
maybe 422 {'detail': [{'type': 'bool_parsing', 'loc': ['path', 'on'], 'msg': 'Input should be a valid boolean, unable to interpret input', 'input': 'maybe'}]}
{'fmt': 'json'} Input should be 'json' or 'xml'

Pydantic also offers constrained identifier types. UUID4 from pydantic accepts only version-4 UUIDs, rejects other versions with UUID version 4 expected, and is documented in OpenAPI as {'type': 'string', 'format': 'uuid4'}. Which conversions are lax and which are strict is covered in Pydantic v2 Essentials.

A path parameter is always required and cannot have a default value (see the pitfalls). Structured data such as a Pydantic model belongs in the request body, not in the path.

Route Order Matters

Routes are tried in the order they were declared and the first match wins. A template with a variable segment matches any text, so a fixed path such as /books/featured must be declared before /books/{book_id}; otherwise "featured" is captured as the id.

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/books/{book_id}")          # declared first: captures "featured" too
def one(book_id: int) -> dict:
    return {"book": book_id}


@app.get("/books/featured")           # never reached
def featured() -> dict:
    return {"featured": True}


@app.get("/authors/featured")         # fixed path declared before the variable one: works
def author_featured() -> dict:
    return {"featured": True}


@app.get("/authors/{author_id}")
def author(author_id: int) -> dict:
    return {"author": author_id}


c = TestClient(app)
r = c.get("/books/featured")
print(r.status_code, r.json()["detail"][0]["type"])
print(c.get("/authors/featured").json())


@app.get("/dup")
def first() -> int:
    return 1


@app.get("/dup")
def second() -> int:
    return 2


print(c.get("/dup").json())

/books/featured answers 422 int_parsing (the router picked one and the conversion failed) while /authors/featured answers {'featured': True}. The same rule means that you cannot redefine a path operation: declaring GET /dup twice always runs the first function (the output is 1), with no error at startup. The same applies across routers; routers are consulted in the order of include_router calls.

A subtle variant: /books/{book_id} with book_id: str would happily swallow featured and return it as an id, so the bug moves from a visible 422 to wrong data. Declaring the fixed paths first, or giving them a different shape (/books/featured and /books/by-id/{book_id}), avoids both.

Predefined Values with Enum

When only a closed set of values is valid, declare an Enum. Inheriting from str (or, in Python 3.11 and later, enum.StrEnum) tells OpenAPI that the values are strings. FastAPI validates against the member values and passes the function the member.

from enum import Enum

from fastapi import FastAPI
from fastapi.testclient import TestClient


class Genre(str, Enum):
    fiction = "fiction"
    science = "science"
    history = "history"


class Format(int, Enum):
    paperback = 1
    hardcover = 2


app = FastAPI()


@app.get("/genres/{genre}")
def by_genre(genre: Genre) -> dict:
    if genre is Genre.science:             # compare with an enumeration member
        note = "compared with the member"
    elif genre.value == "history":         # or with the underlying value
        note = "compared with .value"
    else:
        note = "member name is " + genre.name
    return {"genre": genre, "note": note}  # members are serialized as their value


@app.get("/formats/{fmt}")
def fmt(fmt: Format) -> dict:
    return {"fmt": fmt, "name": fmt.name}


c = TestClient(app)
print(c.get("/genres/science").json())
print(c.get("/genres/history").json())
print(c.get("/genres/poetry").json())
print(c.get("/formats/2").json(), c.get("/formats/3").status_code)
schema = c.get("/openapi.json").json()
print(schema["components"]["schemas"]["Genre"])
print(schema["paths"]["/genres/{genre}"]["get"]["parameters"])
curl -s http://127.0.0.1:8000/genres/poetry
{
  "detail": [
    {
      "type": "enum",
      "loc": ["path", "genre"],
      "msg": "Input should be 'fiction', 'science' or 'history'",
      "input": "poetry",
      "ctx": {"expected": "'fiction', 'science' or 'history'"}
    }
  ]
}

The error message lists the valid values, so the client does not need documentation to recover. In OpenAPI the enum becomes a reusable component, {'type': 'string', 'enum': ['fiction', 'science', 'history'], 'title': 'Genre'}, and the parameter references it with $ref. Swagger UI shows a drop-down with the allowed values, and generated clients get an enum type. An int-based enum (Format) accepts the number 2 from the URL (the text is converted first) and rejects 3. Use Literal for a tiny inline set, and an Enum when the set is reused across routes, models and response schemas.

Paths Inside Path Parameters: the :path Converter

A normal path parameter stops at the next /. To capture the rest of the URL, including slashes, use Starlette’s path converter: put :path after the name in the template.

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/files/{file_path:path}")
def read_file(file_path: str) -> dict:
    return {"file_path": file_path}


@app.get("/covers/{name}")
def cover(name: str) -> dict:
    return {"name": name}


c = TestClient(app)
print(c.get("/files/covers/2026/dune.png").json())
print(c.get("/files//etc/hosts").json())
print(c.get("/covers/covers/dune.png").status_code)
print(list(c.get("/openapi.json").json()["paths"]))

/files/covers/2026/dune.png yields covers/2026/dune.png. To receive a path with a leading slash, repeat the slash in the URL: /files//etc/hosts yields /etc/hosts. The plain str route returns 404 for the same text with slashes, because the template has no more segments to give them to.

The OpenAPI schema lists the path as /files/{file_path}: the :path suffix is dropped because OpenAPI has no way to say that a parameter may contain slashes. The route itself works, but the interactive documentation therefore cannot describe it as a multi-segment value; exercise it with curl and check how your browser or client encodes slashes (an encoded %2F is decoded before routing and no longer matches a plain str template).

Starlette ships these converters: str (the default), path, int, float and uuid (Starlette: Routing). FastAPI’s type hint is the better tool for int, float and UUID, because the hint also produces a 422 explanation and the OpenAPI type. A converter such as \{x:int} only affects routing: /int/z falls through to 404 instead of 422, and the schema shows an ordinary {x}. The :path converter is the one that has no hint-only equivalent.

Treat a :path value as untrusted input. Never join it onto a directory with plain string concatenation: .. segments and absolute paths (the double-slash form above) can escape the intended directory. Resolve it and check that it stays below the base directory, or look files up by an opaque id instead.

Validation with Path

Path() plays for path parameters the role that Query() plays for query parameters: it adds validation rules and documentation metadata. Use it inside Annotated, so the parameter keeps its natural signature (see Annotated).

from typing import Annotated

from fastapi import FastAPI, Path
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/books/{book_id}")
def read_book(
    book_id: Annotated[
        int,
        Path(
            title="Book ID",
            description="Primary key of the book.",
            ge=1,
            le=10_000,
            examples=[42],
        ),
    ],
) -> dict:
    return {"book_id": book_id}


@app.get("/shelves/{shelf}/slots/{slot}")
def slot(
    shelf: Annotated[str, Path(min_length=2, max_length=4, pattern=r"^[A-Z]+$")],
    slot: Annotated[int, Path(gt=0, lt=100, multiple_of=5)],
    q: str,
) -> dict:
    return {"shelf": shelf, "slot": slot}


@app.get("/ratings/{score}")
def rating(score: Annotated[float, Path(gt=0, le=5)]) -> dict:
    return {"score": score}


c = TestClient(app)
print(c.get("/books/0").json()["detail"][0])
print(c.get("/books/10001").json()["detail"][0]["type"])
print(c.get("/books/5").json())
print(c.get("/shelves/ab/slots/7?q=x").json()["detail"])
print(c.get("/shelves/AB/slots/10?q=x").json())
print(c.get("/ratings/0").json()["detail"][0]["msg"], c.get("/ratings/5").status_code, c.get("/ratings/nan").status_code)
print(c.get("/openapi.json").json()["paths"]["/books/{book_id}"]["get"]["parameters"])

The constraint violations carry the failing rule in ctx, which clients can use to build messages:

{
  "type": "greater_than_equal",
  "loc": ["path", "book_id"],
  "msg": "Input should be greater than or equal to 1",
  "input": "0",
  "ctx": {"ge": 1}
}

Numeric constraints

Parameter Meaning Error type

gt

greater than

greater_than

ge

greater than or equal

greater_than_equal

lt

less than

less_than

le

less than or equal

less_than_equal

multiple_of

value must be a multiple of the number

multiple_of

They work for int and float; /ratings/0 fails gt=0, /ratings/5 passes le=5, and /ratings/nan is rejected with 422 because Pydantic refuses nan and infinities unless allow_inf_nan=True is set. Constraints on a Decimal can use max_digits and decimal_places.

String constraints

For str parameters use min_length, max_length and pattern (a regular expression; the older name regex is deprecated). In the example, ab violates the pattern (string_pattern_mismatch) and 7 is not a multiple of 5 in the same request; both errors are reported together in one 422.

Metadata and OpenAPI effect

The rules appear in the generated schema, so Swagger UI shows them and client generators can enforce them:

{
  "name": "book_id",
  "in": "path",
  "required": true,
  "schema": {
    "type": "integer",
    "maximum": 10000,
    "minimum": 1,
    "title": "Book ID",
    "description": "Primary key of the book.",
    "examples": [42]
  },
  "description": "Primary key of the book."
}

gt/lt become exclusiveMinimum/exclusiveMaximum (OpenAPI 3.1 and JSON Schema 2020-12 use numbers there, not booleans); multiple_of becomes multipleOf; pattern, min_length and max_length become pattern, minLength and maxLength.

The full list of parameters accepted by Path() in FastAPI 0.142 (checked by inspecting the function signature) is below. Most are shared with Query(), Header() and Cookie():

Parameters Purpose

default, default_factory

Accepted by the signature, but a path parameter cannot have a default; Path(default=…​) makes FastAPI raise Path parameters cannot have a default value at route declaration (default_factory is not checked and has no useful effect on a required path parameter).

title, description

Documentation shown in Swagger UI and ReDoc.

gt, ge, lt, le, multiple_of, allow_inf_nan, max_digits, decimal_places

Numeric and decimal constraints.

min_length, max_length, pattern

String constraints (regex is the deprecated spelling of pattern).

strict

Disable lax conversion; see the pitfall below, where it rejects every value for non-string types.

examples, openapi_examples

One or more example values (example is deprecated). openapi_examples is the richer OpenAPI form that Swagger UI lets the user pick from (added in 0.103.0).

deprecated

Marks the parameter as deprecated in OpenAPI ('deprecated': True on the parameter).

include_in_schema

Controls whether the parameter appears in the generated schema.

alias, alias_priority, validation_alias, serialization_alias

Pydantic aliasing; not useful for path parameters, because a template name must match the function parameter and cannot contain - (a template /alias/{book-id} never matches, so every request is 404).

discriminator, json_schema_extra, extra

Advanced Pydantic hooks, available for completeness.

Reusable aliases

Because the constraints live in an Annotated type, they can be named once and reused by every route that takes the same identifier. The Bookshelf API does this with BookId and AuthorId in the larger example below. The same technique turns into shared dependency aliases in Dependency Injection.

Parameter Order and the * Trick

Python does not allow a parameter without a default after one that has a default. Older FastAPI code put Path() in the default value position (book_id: int = Path(ge=1)), which collides with parameters that have no default, such as a required query parameter. Code of that era solved it by starting the signature with *, which turns every following parameter into keyword-only so that order stops mattering:

from typing import Annotated

from fastapi import FastAPI, Path
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/old/{book_id}")            # code you will meet in older guides
def old(*, book_id: int = Path(ge=1), q: str) -> dict:
    return {"book_id": book_id, "q": q}


@app.get("/new/{book_id}")            # Annotated: no trick needed
def new(q: str, book_id: Annotated[int, Path(ge=1)]) -> dict:
    return {"book_id": book_id, "q": q}


c = TestClient(app)
print(c.get("/old/1?q=a").json(), c.get("/new/1?q=a").json())
try:
    exec("def bad(book_id: int = 1, q: str): ...")
except SyntaxError as exc:
    print("SyntaxError:", exc.msg)

Both routes return \{'book_id': 1, 'q': 'a'}, and the last line prints SyntaxError: parameter without a default follows parameter with a default, the Python error that the worked around. With Annotated the default-value slot is free for real defaults, so is unnecessary; you may still use it on purpose to force keyword-only arguments. FastAPI does not care about parameter order: it recognizes path parameters by name (the name appears in the path template), query parameters by their simple type, and bodies by their model type.

Trailing Slashes and Redirects

/books and /books/ are different URLs to a router. FastAPI (through Starlette’s redirect_slashes option, on by default) bridges the gap with a redirect: when a request does not match but the same path with a slash added or removed does, the response is 307 Temporary Redirect to that URL.

from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/books")
def list_books() -> dict:
    return {"list": True}


@app.get("/authors/")
def list_authors() -> dict:
    return {"authors": True}


@app.post("/loans")
def create_loan() -> dict:
    return {"created": True}


c = TestClient(app)
r = c.get("/books/", follow_redirects=False)
print(r.status_code, r.headers["location"])
r = c.get("/authors", follow_redirects=False)
print(r.status_code, r.headers["location"])
r = c.get("/books/")
print(r.status_code, r.json(), r.history[0].status_code)
r = c.post("/loans/", follow_redirects=False)
print(r.status_code, r.headers["location"])
print(list(c.get("/openapi.json").json()["paths"]))

strict = TestClient(FastAPI(redirect_slashes=False))
strict.app.get("/books")(list_books)
print(strict.get("/books/").status_code, strict.get("/books").status_code)

# an APIRouter with prefix and "/" route: canonical form is WITH the slash
router = APIRouter(prefix="/shelves")


@router.get("/")
def shelves() -> dict:
    return {"shelves": []}


app2 = FastAPI()
app2.include_router(router)
c2 = TestClient(app2)
print(c2.get("/shelves", follow_redirects=False).headers["location"])
print(list(c2.get("/openapi.json").json()["paths"]))

The run printed (testserver is the host name that TestClient uses):

307 http://testserver/books
307 http://testserver/authors/
200 {'list': True} 307
307 http://testserver/loans
['/books', '/authors/', '/loans']
404 200
http://testserver/shelves/
['/shelves/']

What this shows:

  • GET /books/ is redirected to /books, and GET /authors to /authors/. The route you declared is the canonical form and is the only one in OpenAPI.

  • The redirect is 307, which preserves the method and body, so POST /loans/ is redirected as a POST to /loans. Clients that do not follow redirects (many fetch configurations and some HTTP libraries) must call the canonical URL. A redirect also costs a round trip on every call, so documents should show the canonical form.

  • Passing redirect_slashes=False to FastAPI(…​) makes a trailing-slash mismatch an ordinary 404. This is the stricter, more REST-like choice for a new API, at the price that sloppy clients break instead of being redirected.

  • Behind a reverse proxy the redirect’s Location is built from the request’s Host header and scheme. If the proxy does not forward them, clients can be sent to an internal http:// address. See Middleware, CORS & Proxies for forwarded headers and root_path.

  • A prefix router with a "/" route (APIRouter(prefix="/shelves")) is canonical with the slash; to expose /shelves without a slash, declare the route as "" (an empty path) instead of "/". Pick one convention for the whole API and apply it to every router.

RESTful URL Design for the Bookshelf API

Path parameters are the vocabulary of a REST API’s URLs. The conventions that keep them predictable (the REST concepts themselves are introduced in Introduction & Architecture; the same design rules apply on other stacks, see REST APIs for Spring Boot):

  • Nouns, not verbs. The URL names the resource and the HTTP method says the action: GET /books/42, not /getBook?id=42.

  • Plural collection, id for the member. /books is the collection, /books/{book_id} one member.

  • Nest only to express ownership, and not deeply. /authors/{author_id}/books is the books of an author; stop at one or two levels (a deeper chain is usually a sign that the inner resource deserves its own top-level URL).

  • Identifiers belong in the path, filters in the query. GET /books/genre/science is acceptable as a sub-collection, but GET /books?genre=science&sort=title is the normal form for filtering, sorting and paging; see Query Parameters.

  • Stable, opaque identifiers. Prefer ids that do not leak ordering or counts (UUID) for public resources, and decide once whether ids are integers or UUIDs so that the Path constraint can be a shared alias.

  • Methods map to CRUD. POST on the collection creates, GET reads, PUT replaces, PATCH updates part of a member, DELETE removes; see Introduction & Architecture for the status code table.

  • Fixed words are declared before variable segments, as shown in the route-order section.

APIRouter(prefix=…​) and include_router group routes by resource so that each file owns one URL subtree, and the tags argument groups them in Swagger UI. The larger example puts the pieces together:

from enum import Enum
from typing import Annotated

from fastapi import APIRouter, FastAPI, HTTPException, Path
from fastapi.testclient import TestClient

BookId = Annotated[int, Path(title="Book ID", description="Primary key of a book.", ge=1)]
AuthorId = Annotated[int, Path(title="Author ID", ge=1)]


class Genre(str, Enum):
    fiction = "fiction"
    science = "science"
    history = "history"


BOOKS = {
    1: {"id": 1, "title": "Dune", "author_id": 7, "genre": Genre.fiction},
    2: {"id": 2, "title": "Cosmos", "author_id": 8, "genre": Genre.science},
}

books = APIRouter(prefix="/books", tags=["books"])
authors = APIRouter(prefix="/authors", tags=["authors"])


@books.get("/")
def list_books() -> list[dict]:
    return list(BOOKS.values())


@books.get("/featured")          # fixed path: declared BEFORE /{book_id}
def featured_book() -> dict:
    return BOOKS[1]


@books.get("/genre/{genre}")
def books_by_genre(genre: Genre) -> list[dict]:
    return [b for b in BOOKS.values() if b["genre"] is genre]


@books.get("/{book_id}")
def read_book(book_id: BookId) -> dict:
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail="Book not found")
    return BOOKS[book_id]


@authors.get("/{author_id}/books")
def books_of_author(author_id: AuthorId) -> list[dict]:
    return [b for b in BOOKS.values() if b["author_id"] == author_id]


@authors.get("/{author_id}/books/{book_id}")
def book_of_author(author_id: AuthorId, book_id: BookId) -> dict:
    book = BOOKS.get(book_id)
    if book is None or book["author_id"] != author_id:
        raise HTTPException(status_code=404, detail="Book not found for this author")
    return book


app = FastAPI(title="Bookshelf API")
app.include_router(books)
app.include_router(authors)

c = TestClient(app)
for url in ["/books/featured", "/books/2", "/books/99", "/books/0", "/books/abc",
            "/books/genre/science", "/books/genre/poetry", "/authors/7/books", "/authors/7/books/2"]:
    r = c.get(url)
    print(url, r.status_code, r.json())

Saved as main.py, run it with fastapi dev and call it (outputs are from a real Uvicorn run of this module):

curl -i http://127.0.0.1:8000/books/2
curl -i http://127.0.0.1:8000/books/99
curl -i http://127.0.0.1:8000/books/abc
curl -i http://127.0.0.1:8000/authors/7/books/2
{"id": 2, "title": "Cosmos", "author_id": 8, "genre": "science"}

/books/99 answers 404 with \{"detail": "Book not found"}; /books/abc answers 422 Unprocessable Content with the int_parsing detail shown earlier; /books/0 answers 422 with greater_than_equal (the BookId alias applies everywhere it is used, including the nested route); and /authors/7/books/2 answers 404 with \{"detail": "Book not found for this author"}, because book 2 belongs to author 8. The generated OpenAPI paths are /books/, /books/featured, /books/genre/{genre}, /books/{book_id}, /authors/{author_id}/books and /authors/{author_id}/books/{book_id}; the constraint and the description of BookId appear on every one of them (minimum: 1, title: "Book ID").

The nested route shows the typical pattern for ownership checks: both ids are validated by the path, then the handler verifies that the book belongs to the author and answers 404 rather than leaking that the book exists elsewhere.

Pitfalls

  • Variable before fixed. /books/{book_id} declared above /books/featured captures featured. With book_id: int the symptom is a puzzling 422; with str it is silently wrong data.

  • Path with a default. Declaring book_id: Annotated[int, Path(ge=1)] = 1 raises AssertionError: Path parameters cannot have default values when the route is declared (at import time), because a path parameter is required by construction. A default is meaningful only on query, header and cookie parameters.

  • Name mismatch. The function parameter must have exactly the name used in the template. A parameter whose name is not in the template is treated as a query parameter: with the template /m/{id} and a parameter book_id: int, GET /m/1 answers 422 with "loc": ["query", "book_id"] and "type": "missing".

  • strict=True on non-string types. Path values are always text, so Annotated[int, Path(strict=True)] rejects every request with int_type. Leave strictness off for path parameters (or use a str-based type with a pattern).

  • Slashes in str parameters. A plain str parameter never contains /. An id like a/b sent as a%2Fb is decoded by the server before routing and yields 404; use :path deliberately, or use ids that cannot contain slashes.

  • Treating the :path value as a filesystem path. See the path-traversal note above.

  • Trailing slash inconsistency. Mixed conventions produce redirects, and 307 plus relative Location handling behind proxies can send clients to internal addresses; choose one form and set redirect_slashes deliberately.

  • Assuming OpenAPI knows about :path or converters. They are routing-only; the schema reports a plain string.

  • Using Path(…​) with Ellipsis. In older code Path(…​) marked the parameter required. Path parameters are always required and Pydantic v2 reads …​ as "no default" anyway, so a bare Path() is the current spelling.

  • Raising 404 for malformed ids. Let validation answer 422 for syntax errors and reserve 404 for well-formed ids that match nothing.

  • Sensitive data in the URL. Paths end up in access logs, proxies and browser history. Do not put tokens, e-mail addresses or other personal data in a path parameter; send them in a header or body.

Where Older Guides Are Outdated, and What Changed Recently

  • Path(…​) as a default value and the trick.* Guides from before FastAPI 0.95.0 (2023-03-18) write item_id: int = Path(…​, ge=1) and begin the signature with . Annotated is supported and recommended since 0.95.0, needs no , and drops the …​.

  • Pydantic v1 names. In v1-era material, numeric validation was often written with conint and string validation with constr(regex=…​); the current spelling is Annotated[int, Path(ge=1)] or Field, and Path(pattern=…​) replaces Path(regex=…​). v1 support was removed in FastAPI 0.126.0 (2025-12-20) and the pydantic.v1 fallback in 0.128.0 (2025-12-27). The 422 error payloads changed with v2 as well: the type is now int_parsing or greater_than_equal instead of type_error.integer or value_error.number.not_ge.

  • Examples. The single example= argument is deprecated in favour of examples=[…​], and OpenAPI 3.1 support arrived in 0.99.0 (2023-06-30); openapi_examples= appeared in 0.103.0 (2023-08-26).

  • Optional and the typing imports. Current style is int | None and built-in generics; Python 3.10 is the minimum since 3.9 was dropped in 0.129.0.

  • Trailing slashes in tutorials. Book- and blog-style code routinely declares both /books and /books/ or relies on the redirect without saying so. The behaviour described above is Starlette’s, applies to routers with prefix, and can be turned off with redirect_slashes=False.

  • Starlette 1.x. FastAPI supports Starlette 1.0 and later since 0.133.0 (2026-02-24); path templates and converters behave as before.

  • Status text. The 422 reason phrase is Unprocessable Content in current releases; clients should rely on the numeric code, not the phrase.