Query 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 uses Python 3.10+ syntax and Annotated, and every example was executed with TestClient on Python 3.13; the printed results shown are the real ones. The running scenario is the Bookshelf API (books, authors and reviews). Query parameters are the part of a URL after the ? (/books?genre=sf&limit=10); they are the natural place for filtering, searching, sorting and paging a collection. Path parameters have their own page, Path Parameters, request bodies are in Request Body & Models, and the Annotated mechanics used throughout are explained in Python Types & Annotated.

Where FastAPI Looks for Each Parameter

FastAPI decides where every function parameter comes from by looking at two things only: the name (is it in the path template?) and the type or marker (Query(), Header(), a Pydantic model, …​). There is no separate "routing table" to maintain; the signature is the declaration.

flowchart TD P["A function parameter
name: type = default"] --> M{"Explicit marker in
Annotated or as default?"} M -- "Path()" --> PATH["path parameter"] M -- "Query()" --> QUERY["query string"] M -- "Header()" --> HEAD["HTTP header
(underscores become hyphens)"] M -- "Cookie()" --> COOK["Cookie header"] M -- "Body()" --> BODY["JSON request body"] M -- "Form()" --> FORM["form field
(urlencoded or multipart)"] M -- "File()" --> FILE["multipart file"] M -- "none" --> T{"Name appears in
the path template?"} T -- "yes" --> PATH T -- "no" --> K{"Type"} K -- "Pydantic model,
dataclass" --> BODY K -- "int, str, bool, float,
date, UUID, Enum, ..." --> QUERY K -- "list, set, dict
without a marker" --> BODY
Figure 1. How FastAPI classifies a handler parameter

The rules in words:

  • A parameter whose name appears in the path template (/books/{book_id}) is a path parameter.

  • A parameter of a singular type (int, str, float, bool, date, UUID, an Enum, …​) that is not in the path is a query parameter.

  • A parameter whose type is a Pydantic model (or a dataclass) is read from the JSON request body. A bare list[str], set or dict annotation with no marker is also treated as body, which is why a list query parameter needs an explicit Query() (see List Parameters).

  • The markers Path(), Query(), Header(), Cookie(), Body(), Form() and File() override the guess and carry validation and documentation options. Header() and Cookie() are covered in Headers & Cookies, Form() and File() in Forms & File Uploads.

The following handler uses every source at once, and the generated OpenAPI document confirms where each one is read:

from typing import Annotated

from fastapi import Cookie, FastAPI, File, Form, Header, Path, Query, UploadFile
from pydantic import BaseModel

app = FastAPI()


class Review(BaseModel):
    rating: int
    text: str


@app.put("/books/{book_id}/reviews")
def put_review(
    book_id: int,                                 # name in the path template -> path
    review: Review,                               # Pydantic model -> JSON body
    notify: bool = False,                         # simple type, not in the path -> query
    tag: Annotated[list[str], Query()] = [],      # list needs Query() explicitly -> query
    x_client: Annotated[str | None, Header()] = None,    # -> header
    session: Annotated[str | None, Cookie()] = None,     # -> cookie
):
    return {"book_id": book_id, "review": review, "notify": notify, "tag": tag,
            "x_client": x_client, "session": session}


@app.post("/books/{book_id}/cover")
def upload_cover(
    book_id: Annotated[int, Path()],
    caption: Annotated[str, Form()],              # -> form field
    cover: Annotated[UploadFile, File()],         # -> multipart file
):
    return {"book_id": book_id, "caption": caption, "filename": cover.filename}

Running it with a TestClient (a cookie session=abc, a header X-Client: cli) prints:

{'book_id': 7, 'review': {'rating': 5, 'text': 'Great'}, 'notify': True, 'tag': ['a', 'b'], 'x_client': 'cli', 'session': 'abc'}
{'book_id': 7, 'caption': 'Front', 'filename': 'c.png'}
[('book_id', 'path'), ('notify', 'query'), ('tag', 'query'), ('x-client', 'header'), ('session', 'cookie')]
['application/json']
['multipart/form-data']

The last three lines come from /openapi.json: the in value of each parameter, the content type of the review body (application/json) and of the cover upload (multipart/form-data). Note that a Form() or File() parameter switches the whole body to form encoding, so a single operation cannot mix them with a JSON body model.

Defaults, Optional and Required Parameters

Any function parameter that is neither in the path template nor a model is a query parameter, and its default value decides whether it is required:

from fastapi import FastAPI

app = FastAPI()


@app.get("/books")
def list_books(lang: str, skip: int = 0, limit: int = 3, in_stock: bool = False, genre: str | None = None):
    return {"lang": lang, "skip": skip, "limit": limit, "in_stock": in_stock, "genre": genre}
Declaration Meaning Missing from the URL

lang: str

required

422 (type: missing)

skip: int = 0

optional, default 0

the default is used

genre: str | None = None

optional, may be None

None

genre: str | None

required, although the type admits None

422

Because query values arrive as text, FastAPI converts them to the declared type with Pydantic’s lax mode: skip=5 becomes the integer 5. A value that cannot be converted produces a 422 before your function runs. With a TestClient:

GET /books?lang=en            -> {'lang': 'en', 'skip': 0, 'limit': 3, 'in_stock': False, 'genre': None}
GET /books                    -> 422 {'detail': [{'type': 'missing', 'loc': ['query', 'lang'], 'msg': 'Field required', 'input': None}]}
GET /books?lang=en&skip=abc   -> 422 {'detail': [{'type': 'int_parsing', 'loc': ['query', 'skip'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'abc'}]}
GET /books?lang=en&skip=1.5   -> 422 (type int_parsing)
GET /books?lang=en&genre=     -> 200, genre is the empty string ""
GET /books?lang=en&skip=1&skip=2 -> 200, skip is 2 (the last repeated value wins for a singular parameter)

The same requests with a command-line client:

curl 'http://127.0.0.1:8000/books?lang=en&skip=5&limit=10&genre=sf'
http ':8000/books' lang==en skip==5 limit==10 genre==sf     # HTTPie: == adds a query parameter

Query names are case sensitive (?Q=a does not satisfy a parameter called q), and a name that FastAPI does not know is ignored unless you forbid it with a query parameter model.

bool Conversion

A bool query parameter accepts several spellings, so a browser, a script and a human can all call the same endpoint:

Result Accepted text (case-insensitive)

True

true, 1, on, yes

False

false, 0, off, no

Anything else, such as maybe, is rejected with Input should be a valid boolean, unable to interpret input. All nine spellings were checked against the handler above.

Required Is Not the Same as Nullable

Whether a parameter is required depends only on the default; whether None is a valid value depends only on the type. The combinations (with Query() min length 3) behave as follows when the parameter is absent:

from typing import Annotated

from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/r1")
def r1(q: Annotated[str, Query(min_length=3)]): ...                  # required, not nullable
@app.get("/r2")
def r2(q: Annotated[str | None, Query(min_length=3)]): ...           # required, may be None
@app.get("/r3")
def r3(q: Annotated[str, Query(min_length=3)] = "fixedquery"): ...   # optional, default
@app.get("/r4")
def r4(q: Annotated[str | None, Query(min_length=3)] = None): ...    # optional, None
r1 missing -> 422 missing   required: True   {'type': 'string', 'minLength': 3, 'title': 'Q'}
r2 missing -> 422 missing   required: True   {'anyOf': [{'type': 'string', 'minLength': 3}, {'type': 'null'}], 'title': 'Q'}
r3 missing -> 200 {'q': 'fixedquery'}   required: False   {'type': 'string', 'minLength': 3, 'default': 'fixedquery', 'title': 'Q'}
r4 missing -> 200 {'q': None}           required: False   {'anyOf': [{'type': 'string', 'minLength': 3}, {'type': 'null'}], 'title': 'Q'}

r2 is the surprising one: a client must send q, but the value may be null. Over a query string a client cannot send a real null, so in practice a required nullable query parameter is rarely useful; prefer r4. Even when a value is present, the constraints still apply: GET /r2?q= answers 422 with string_too_short because the empty string is shorter than 3 characters.

String Validation with Query

Query() goes inside Annotated, next to the type, and carries validation and documentation options for one parameter. Annotated is the recommended style since FastAPI 0.95.0; the default-value style (q: str | None = Query(default=None)) still works and is what you will meet in older code.

from typing import Annotated

from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/search")
def search(
    q: Annotated[str | None, Query(min_length=3, max_length=50, pattern="^[a-zA-Z0-9 ]+$")] = None,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
    rating: Annotated[float, Query(gt=0, lt=5.01, multiple_of=0.5)] = 4.0,
    sort: Annotated[str, Query(pattern="^(title|published)$")] = "title",
):
    return {"q": q, "limit": limit, "rating": rating, "sort": sort}

Each rejected request answers 422; the type and ctx of the first error are:

Request Error type ctx

?q=ab

string_too_short

{'min_length': 3}

?q= plus 51 a characters

string_too_long

{'max_length': 50}

?q=a!b

string_pattern_mismatch

{'pattern': '^[a-zA-Z0-9 ]$'}+

?limit=0

greater_than_equal

{'ge': 1}

?limit=101

less_than_equal

{'le': 100}

?rating=4.2

multiple_of

{'multiple_of': 0.5}

?sort=price

string_pattern_mismatch

{'pattern': '^(title|published)$'}

The same constraints become JSON Schema keywords in /openapi.json, so Swagger UI documents (and client generators enforce) them:

{"anyOf": [{"type": "string", "minLength": 3, "maxLength": 50, "pattern": "^[a-zA-Z0-9 ]+$"}, {"type": "null"}], "title": "Q"}
{"type": "integer", "maximum": 100, "minimum": 1, "default": 20, "title": "Limit"}
{"type": "number", "multipleOf": 0.5, "exclusiveMaximum": 5.01, "exclusiveMinimum": 0, "default": 4.0, "title": "Rating"}

All the Options of Query()

The signature of fastapi.Query in 0.142.2 accepts the following (also listed in the Request Parameters reference):

Option Effect

default, default_factory

The default value (omit it to make the parameter required), or a callable producing one.

alias

The name in the URL when it is not a valid Python identifier (see Aliases).

validation_alias, serialization_alias, alias_priority

Pydantic’s finer-grained alias controls (AliasPath, AliasChoices); alias is enough for query strings.

title, description

Shown in Swagger UI and ReDoc.

min_length, max_length

Length of a string; for a list, the minimum and maximum number of items (minItems, maxItems).

pattern

Regular expression the string must match (Rust regex syntax in Pydantic v2).

gt, ge, lt, le, multiple_of

Numeric bounds and step.

max_digits, decimal_places

For Decimal values.

allow_inf_nan

Whether float accepts inf and nan.

strict

Disable lax conversion for this parameter. A query value always arrives as text, so strict=True on an int rejects every request (?n=5 answers 422, int_type); it is useful for body fields, rarely for query strings.

discriminator

The field naming the variant of a tagged union.

examples, openapi_examples

Example values for the docs; openapi_examples (since 0.103.0) are named, selectable examples in Swagger UI.

deprecated

Mark the parameter deprecated in the docs (see Deprecating Parameters).

include_in_schema

Hide the parameter from OpenAPI (see Hiding Parameters).

json_schema_extra

Extra raw JSON Schema keys merged into the parameter’s schema.

regex, example, extra

Deprecated: use pattern, examples and json_schema_extra.

pattern, Not regex

The parameter was called regex in code written for Pydantic v1. It was deprecated in FastAPI 0.100.0 along with the move to Pydantic v2; it still exists but emits a warning:

FastAPIDeprecationWarning: `regex` has been deprecated, please use `pattern` instead

A pattern is a search, not a full match: pattern="ab" accepts xaby. Write ^ and $ yourself for "exactly this shape". The Rust regex engine behind Pydantic v2 has no look-around or backreferences; if you need them, validate with AfterValidator and Python’s re module instead.

Required Parameters and the Ellipsis

Older guides write q: str = Query(…​), with the …​ literal (Ellipsis) meaning "required". That spelling still works in the default-value style (GET /old without q answers 422, with q=abcd it answers 200). The current documentation simply declares no default instead:

q: Annotated[str, Query(min_length=3)]            # required
q: Annotated[str, Query(min_length=3)] = ...      # do NOT combine Annotated with `= ...`

Executed on FastAPI 0.142.2, the second form is not equivalent: a request that omits q crashes with an internal server error (the Ellipsis object itself is validated as the value and then cannot be serialized into the error response) instead of returning 422. If you migrate old code to Annotated, delete the …​ default.

List Parameters

A query parameter that can be repeated (?author_id=1&author_id=2) is declared as a list, and must use Query(); without it FastAPI reads list[…​] as a request body.

from typing import Annotated

from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/books")
def books(
    genre: Annotated[list[str] | None, Query(max_length=3)] = None,
    author_id: Annotated[list[int], Query(description="Repeat to filter by several authors")] = [],
    tags: Annotated[list[str], Query()] = ["new", "popular"],
    anything: Annotated[list, Query()] = [],
):
    return {"genre": genre, "author_id": author_id, "tags": tags, "anything": anything}
curl 'http://127.0.0.1:8000/books?genre=sf&genre=fantasy&author_id=1&author_id=2'
{"genre": ["sf", "fantasy"], "author_id": [1, 2], "tags": ["new", "popular"], "anything": []}

Observed behaviour:

  • Each repeated name adds one item; items are converted to the item type. ?author_id=1&author_id=x answers 422 and the error location is ['query', 'author_id', 1], pointing at the second item.

  • max_length=3 on a list limits the number of items (maxItems in the schema); a fourth genre gives too_long.

  • list without an item type accepts anything and does not validate or document the items (?anything=1&anything=x gives ['1', 'x'], both strings).

  • A comma-separated value is not split: ?genre=sf,fantasy yields the single item 'sf,fantasy'. To accept a comma-separated list, take a str and split it yourself, or add an AfterValidator.

  • A mutable default such as [] is safe here: a handler that appends to it returned ['x'] on two consecutive requests, so the object is not shared the way a plain Python default would be. The schema shows default: []. Use default_factory if you prefer to be explicit.

  • The schema is type: array with items. Swagger UI renders an input that lets you add one value per item.

Aliases

A query name such as book-title or item-query is not a valid Python identifier. alias keeps the public name independent of the variable name:

from typing import Annotated

from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/books")
def books(
    q: Annotated[
        str | None,
        Query(
            alias="book-title",
            title="Title search",
            description="Case-insensitive substring of the book title",
            min_length=2,
            examples=["dune"],
        ),
    ] = None,
    fmt: Annotated[str | None, Query(deprecated=True, description="Use Accept instead")] = None,
    debug: Annotated[bool, Query(include_in_schema=False)] = False,
):
    return {"q": q, "fmt": fmt, "debug": debug}
GET /books?book-title=dune   -> {'q': 'dune', 'fmt': None, 'debug': False}
GET /books?q=dune            -> {'q': None, 'fmt': None, 'debug': False}      (the Python name no longer works)
GET /books?book-title=d      -> 422, error location ['query', 'book-title']   (errors use the alias)

Once an alias is set, only the alias is accepted in the URL, and error messages and the OpenAPI document use it too.

Title, Description and Examples

title and description are shown in the interactive docs. In the generated schema the title appears in the parameter’s schema and the description both on the parameter and in its schema:

{"name": "book-title", "in": "query", "required": false, "description": "Case-insensitive substring of the book title"}
{"anyOf": [{"type": "string", "minLength": 2}, {"type": "null"}], "title": "Title search", "description": "Case-insensitive substring of the book title", "examples": ["dune"]}

Use examples (a list) rather than the deprecated example; the OpenAPI 3.1 document FastAPI emits since 0.99.0 uses JSON Schema’s examples. openapi_examples offers named examples with a summary, shown as a drop-down in Swagger UI. See OpenAPI & Interactive Docs for how this metadata is rendered.

Deprecating Parameters

deprecated=True keeps the parameter working but marks it as deprecated in the documentation so that clients know to stop using it. In the example above, the generated OpenAPI shows:

{"name": "fmt", "in": "query", "required": false, "description": "Use Accept instead", "deprecated": true}

Swagger UI marks the parameter as deprecated. Nothing is enforced at runtime; if you want to log or reject its use, check the value in the handler.

Hiding Parameters with include_in_schema

include_in_schema=False removes a parameter from /openapi.json and from Swagger UI and ReDoc while it keeps working. It is meant for internal switches such as debug; the earlier example returned debug=True for ?debug=true, yet debug is missing from the documented parameters (only book-title and fmt were listed). Hidden is not secret: anyone who knows the name can send it, so never use it as an access control.

Custom Validation with AfterValidator

When constraints are not enough, attach a function that runs after Pydantic has converted the value. It receives the typed value, may raise ValueError to reject it and returns the (possibly changed) value. Because it is part of the Annotated metadata, it can be named once and reused for any parameter, including list items:

import re
from typing import Annotated

from fastapi import FastAPI, Query
from pydantic import AfterValidator

app = FastAPI()


def check_isbn(value: str) -> str:
    digits = value.replace("-", "")
    if not re.fullmatch(r"\d{13}", digits):
        raise ValueError("ISBN must have 13 digits")
    if sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits)) % 10 != 0:
        raise ValueError("Invalid ISBN check digit")
    return digits  # the handler receives the normalised value


Isbn = Annotated[str, AfterValidator(check_isbn)]


@app.get("/books/by-isbn")
def by_isbn(isbn: Annotated[Isbn, Query(description="ISBN-13, hyphens allowed")]):
    return {"isbn": isbn}


@app.get("/books/compare")
def compare(isbn: Annotated[list[Isbn], Query(min_length=2, max_length=5)]):
    return {"isbns": isbn}
GET /books/by-isbn?isbn=978-0-306-40615-7  -> {'isbn': '9780306406157'}
GET /books/by-isbn?isbn=978-0-306-40615-8  -> 422 {'type': 'value_error', 'loc': ['query', 'isbn'], 'msg': 'Value error, Invalid ISBN check digit', ...}
GET /books/by-isbn?isbn=12                 -> 422 'Value error, ISBN must have 13 digits'
GET /books/compare?isbn=9780306406157&isbn=978-1-86197-876-9 -> {'isbns': ['9780306406157', '9781861978769']}

Points to remember:

  • The error msg is prefixed with Value error, `, and `type is value_error.

  • AfterValidator is Pydantic v2’s replacement for the v1 @validator decorator in this role. Pydantic also provides BeforeValidator (runs before conversion, receives raw text), PlainValidator and WrapValidator; see the Pydantic validators guide and Pydantic v2 Essentials.

  • Raise ValueError or AssertionError; raising HTTPException inside a validator is not the intended mechanism. Validation failures become the standard 422 response.

Query Parameter Models

Since FastAPI 0.115.0 a group of query parameters can be declared as one Pydantic model, marked with Annotated[Model, Query()]. This is the tidy way to share the same filters across endpoints and to keep the validation next to the data:

from typing import Annotated, Literal

from fastapi import FastAPI, Query
from pydantic import BaseModel, Field

app = FastAPI()


class BookFilter(BaseModel):
    model_config = {"extra": "forbid"}

    limit: int = Field(20, gt=0, le=100)
    offset: int = Field(0, ge=0)
    order_by: Literal["created_at", "updated_at"] = "created_at"
    genres: list[str] = []


@app.get("/books")
def list_books(filter_query: Annotated[BookFilter, Query()]):
    return filter_query
curl 'http://127.0.0.1:8000/books?limit=5&offset=10&order_by=updated_at&genres=sf&genres=fantasy'
{"limit": 5, "offset": 10, "order_by": "updated_at", "genres": ["sf", "fantasy"]}

The model’s fields become individual query parameters in OpenAPI (limit, offset, order_by, genres, all optional), not a single object. The handler receives a validated BookFilter instance.

Forbidding Unknown Parameters with extra="forbid"

By default unknown query names are ignored. model_config = {"extra": "forbid"} turns them into errors, which is useful to catch client typos (?limt=5) instead of silently using the default:

GET /books?limit=5&color=red
-> 422 {'detail': [{'type': 'extra_forbidden', 'loc': ['query', 'color'], 'msg': 'Extra inputs are not permitted', 'input': 'red'}]}

GET /books?limit=500&order_by=title
-> 422 [(['query', 'limit'], 'less_than_equal'), (['query', 'order_by'], 'literal_error')]

All errors are reported together. Be aware that forbid rejects every unknown query name for that model, including ones added by proxies, trackers (utm_source) or caches. Keep it for APIs consumed by code you control.

One Query Model per Operation

Testing two query models in one handler (page: Annotated[Page, Query()] and sorting: Annotated[Sorting, Query()]) shows that FastAPI does not read their fields; it answers 422 saying that page and sorting are required (type: missing, location ['query', 'page']). Used through dependencies, each model with extra="forbid" also rejected the other model’s names, and OpenAPI listed page and sorting as the parameters. The documentation shows a single query model per endpoint, so design it that way: put all filters into one model, or combine models with inheritance:

class Page(BaseModel):
    skip: int = Field(0, ge=0)
    limit: int = Field(2, ge=1, le=100)


class Sorting(BaseModel):
    sort: Literal["title", "year"] = "title"
    descending: bool = False


class BookQuery(Page, Sorting):
    model_config = {"extra": "forbid"}


@app.get("/books")
def list_books(q: Annotated[BookQuery, Query()]):
    return q

GET /books?sort=year&limit=3 returns {"sort": "year", "descending": false, "skip": 0, "limit": 3}, and GET /books?x=1 answers 422.

Pagination and Sorting

Every list endpoint of the Bookshelf API (books, authors, reviews) needs the same paging and sorting parameters, so they belong in one reusable place. Two approaches work; both keep skip, limit, sort and descending as ordinary query parameters, so the URL stays readable (/books?sort=year&descending=true&skip=1&limit=2).

As a class dependency. A class whose __init__ takes the parameters is injected with Depends(); the parameters are documented and validated in every endpoint that uses it (dependencies in depth: see Dependency Injection):

from typing import Annotated, Literal

from fastapi import Depends, FastAPI, Query

app = FastAPI()

BOOKS = [
    {"id": 1, "title": "Dune", "year": 1965},
    {"id": 2, "title": "Neuromancer", "year": 1984},
    {"id": 3, "title": "Emma", "year": 1815},
    {"id": 4, "title": "Hyperion", "year": 1989},
]


class Pagination:
    """Query parameters shared by every list endpoint."""

    def __init__(
        self,
        skip: Annotated[int, Query(ge=0)] = 0,
        limit: Annotated[int, Query(ge=1, le=100)] = 2,
        sort: Annotated[Literal["title", "year"], Query()] = "title",
        descending: bool = False,
    ):
        self.skip, self.limit, self.sort, self.descending = skip, limit, sort, descending


PaginationDep = Annotated[Pagination, Depends()]


@app.get("/books")
def list_books(p: PaginationDep):
    ordered = sorted(BOOKS, key=lambda b: b[p.sort], reverse=p.descending)
    return {"total": len(ordered), "items": ordered[p.skip : p.skip + p.limit]}


@app.get("/authors")
def list_authors(p: PaginationDep):
    return {"skip": p.skip, "limit": p.limit}
curl 'http://127.0.0.1:8000/books?sort=year&descending=true&skip=1&limit=2'
{"total": 4, "items": [{"id": 2, "title": "Neuromancer", "year": 1984}, {"id": 1, "title": "Dune", "year": 1965}]}

An invalid request reports every problem at once: GET /books?limit=0&sort=price answers 422 with a greater_than_equal error on limit and a literal_error on sort. GET /authors?limit=7 returns {"skip": 0, "limit": 7} and the OpenAPI document for /authors lists skip, limit, sort and descending.

As a query model. The single-model form from the previous section does the same job with validation expressed in Pydantic Field constraints, and a model can be passed to a dependency as well:

def page_params(page: Annotated[Page, Query()]) -> Page:
    return page


@app.get("/a")
def a(page: Annotated[Page, Depends(page_params)]):
    return page

GET /a?skip=3 returns {"skip": 3, "limit": 2} and the documented parameters are skip and limit.

Design notes for paging and sorting:

  • Offset paging (skip/limit, or offset/limit) is simple and fine for small and medium tables. Always cap limit with le= so that one request cannot ask for the whole table.

  • Keyset (cursor) paging (?after_id=120&limit=20) stays fast and stable on large tables and while rows are inserted; use it for feeds. The cursor is just another query parameter.

  • Return the total or a next link in the response so clients know when to stop; the response shapes are covered in Response Models & Return Types.

  • Restrict sort to a Literal or an Enum of known columns. Never interpolate a client-supplied column name into SQL; mapping it from a closed set to a real column is what makes sorting safe (see SQL Databases with SQLModel).

  • RESTful URL design keeps the collection in the path (/books, /authors/42/books) and filters, sorting and paging in the query string; the path identifies the resource, the query refines the view of it.

Query Parameters in URL Design

A few conventions keep an API predictable for clients:

Need Convention

Filter a collection

GET /books?genre=sf&author_id=3: one parameter per field, a repeated name for "any of".

Free-text search

GET /books?q=dune, with min_length so that a single letter does not scan the table.

Paging

limit plus skip or offset, or a cursor; always bounded.

Sorting

sort=title and a direction (descending=true, or sort=-title handled by a validator).

Sparse fields or expansion

fields=title,year or include=author: parse the string in an AfterValidator.

Anything that changes state

A body, not a query string: URLs end up in logs, referrers and browser history.

Never put secrets (tokens, passwords) in a query string for the same reason; use a header (Headers & Cookies). Very long query strings are limited by servers and proxies, which is another reason to send large filters in a body (POST /books/search).

Effect on OpenAPI and the Interactive Docs

Everything declared on Query() is data for the schema. In Swagger UI each query parameter appears with its type, default, constraints, description and deprecation mark, a text box (or an add-item control for lists) and an Execute button that builds the URL for you. The effects, taken from the runs above:

Declaration OpenAPI

lang: str

required: true, in: query, schema: {type: string}

skip: int = 0

required: false, schema: {type: integer, default: 0}

Query(min_length=3, max_length=50, pattern=…​)

minLength, maxLength, pattern

Query(ge=1, le=100)

minimum, maximum (gt/lt become exclusiveMinimum/exclusiveMaximum)

list[int] with Query()

type: array, items: {type: integer}; max_length becomes maxItems

alias="book-title"

name: book-title

deprecated=True

deprecated: true

include_in_schema=False

the parameter is absent

AfterValidator

not representable: the schema keeps only the base type, so document the rule in description

The generated clients and tests (for example schema-based fuzzers) see the constraints, but not the logic inside a custom validator. More on the document itself in OpenAPI & Interactive Docs.

Pitfalls

  • List without Query(). tags: list[str] = [] is read as a JSON body (the OpenAPI operation gains a requestBody), so GET /books?tags=a does nothing. Write Annotated[list[str], Query()].

  • Annotated[…​] = …​. Do not combine Annotated with the Ellipsis default; it breaks required parameters on 0.142.2 (see Required Parameters and the Ellipsis). Omit the default instead.

  • Mixing Query into the default as well as Annotated. Put Query() in only one place: inside Annotated is the recommended one.

  • Required but nullable (q: str | None with no default) is a required parameter, not an optional one.

  • regex=. Deprecated since FastAPI 0.100.0; use pattern= and anchor it.

  • Lax conversion. For an int, ?n=5.0, ?n=+5, ?n=%205%20 (padded with spaces) and ?n=5_0 are all accepted (the last becomes 50), while 1.5 and 1e2 are rejected. Add an AfterValidator or a pattern on a str when exactness matters.

  • Two query models in one operation are not supported (see One Query Model per Operation).

  • extra="forbid" also rejects tracking parameters appended by browsers, proxies and campaigns.

  • Comma-separated lists are not split; repeat the parameter or split in a validator.

  • Hidden is not private. include_in_schema=False only changes the documentation.

  • Mutable defaults in plain Python. FastAPI protects [] defaults for parameters, but the habit is dangerous everywhere else; prefer None plus a conditional or default_factory.

  • Unbounded limit. Always set le= on page sizes.

  • Secrets in URLs. They end up in access logs and browser history.

Where Older Guides Are Outdated, and What Changed Recently

Older guides and code Today

q: str = Query(None, max_length=50) and Query(…​) as the default value

q: Annotated[str | None, Query(max_length=50)] = None. Annotated has been supported and recommended since 0.95.0; the default-value style still works.

Optional[str]

str | None (Python 3.10+); Optional only names a nullable type and does not make a parameter optional.

Query(regex="…​")

Query(pattern="…​"); regex deprecated in FastAPI 0.100.0 with Pydantic v2.

Query(example=…​)

examples=[…​], or openapi_examples (0.103.0).

Required with …​

No default value; Annotated plus = …​ is broken on 0.142.2.

Many separate parameters repeated in every endpoint

One Pydantic model with Annotated[Model, Query()], available since 0.115.0 (with extra="forbid").

Pydantic v1 @validator and constr(regex=…​)

AfterValidator and Field(pattern=…​) / StringConstraints (Pydantic v2).

request.args.get("q") style access, as in Flask

The typed signature; reading request.query_params directly is possible but skips validation and documentation (see Request Object & Content Type).

Details of Pydantic v1 to v2 names are in Pydantic v2 Essentials and the dated list of changes in What’s Changed & Migration.