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.
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
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, anEnum, …) 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],setordictannotation with no marker is also treated as body, which is why a list query parameter needs an explicitQuery()(see List Parameters). -
The markers
Path(),Query(),Header(),Cookie(),Body(),Form()andFile()override the guess and carry validation and documentation options.Header()andCookie()are covered in Headers & Cookies,Form()andFile()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}
Source: FastAPI: Query Parameters
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}
Source: FastAPI: Query Parameters
| Declaration | Meaning | Missing from the URL |
|---|---|---|
|
required |
|
|
optional, default |
the default is used |
|
optional, may be |
|
|
required, although the type admits |
|
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) |
|---|---|
|
|
|
|
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|
|
The default value (omit it to make the parameter required), or a callable producing one. |
|
The name in the URL when it is not a valid Python identifier (see Aliases). |
|
Pydantic’s finer-grained alias controls ( |
|
Shown in Swagger UI and ReDoc. |
|
Length of a string; for a list, the minimum and maximum number of items ( |
|
Regular expression the string must match (Rust regex syntax in Pydantic v2). |
|
Numeric bounds and step. |
|
For |
|
Whether |
|
Disable lax conversion for this parameter. A query value always arrives as text, so |
|
The field naming the variant of a tagged union. |
|
Example values for the docs; |
|
Mark the parameter deprecated in the docs (see Deprecating Parameters). |
|
Hide the parameter from OpenAPI (see Hiding Parameters). |
|
Extra raw JSON Schema keys merged into the parameter’s schema. |
|
Deprecated: use |
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=xanswers422and the error location is['query', 'author_id', 1], pointing at the second item. -
max_length=3on a list limits the number of items (maxItemsin the schema); a fourthgenregivestoo_long. -
listwithout an item type accepts anything and does not validate or document the items (?anything=1&anything=xgives['1', 'x'], both strings). -
A comma-separated value is not split:
?genre=sf,fantasyyields the single item'sf,fantasy'. To accept a comma-separated list, take astrand split it yourself, or add anAfterValidator. -
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 showsdefault: []. Usedefault_factoryif you prefer to be explicit. -
The schema is
type: arraywithitems. 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
msgis prefixed withValue error, `, and `typeisvalue_error. -
AfterValidatoris Pydantic v2’s replacement for the v1@validatordecorator in this role. Pydantic also providesBeforeValidator(runs before conversion, receives raw text),PlainValidatorandWrapValidator; see the Pydantic validators guide and Pydantic v2 Essentials. -
Raise
ValueErrororAssertionError; raisingHTTPExceptioninside a validator is not the intended mechanism. Validation failures become the standard422response.
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
Source: FastAPI: Query Parameter Models
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
Source: FastAPI: Query Parameter Models
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}
Source: FastAPI: Classes as Dependencies
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
Source: FastAPI: Query Parameter Models
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, oroffset/limit) is simple and fine for small and medium tables. Always caplimitwithle=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
nextlink in the response so clients know when to stop; the response shapes are covered in Response Models & Return Types. -
Restrict
sortto aLiteralor anEnumof 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 |
|
Free-text search |
|
Paging |
|
Sorting |
|
Sparse fields or expansion |
|
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
the parameter is absent |
|
not representable: the schema keeps only the base type, so document the rule in |
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 arequestBody), soGET /books?tags=adoes nothing. WriteAnnotated[list[str], Query()]. -
Annotated[…] = …. Do not combineAnnotatedwith the Ellipsis default; it breaks required parameters on 0.142.2 (see Required Parameters and the Ellipsis). Omit the default instead. -
Mixing
Queryinto the default as well asAnnotated. PutQuery()in only one place: insideAnnotatedis the recommended one. -
Required but nullable (
q: str | Nonewith no default) is a required parameter, not an optional one. -
regex=. Deprecated since FastAPI 0.100.0; usepattern=and anchor it. -
Lax conversion. For an
int,?n=5.0,?n=+5,?n=%205%20(padded with spaces) and?n=5_0are all accepted (the last becomes50), while1.5and1e2are rejected. Add anAfterValidatoror apatternon astrwhen 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=Falseonly changes the documentation. -
Mutable defaults in plain Python. FastAPI protects
[]defaults for parameters, but the habit is dangerous everywhere else; preferNoneplus a conditional ordefault_factory. -
Unbounded
limit. Always setle=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 |
|---|---|
|
|
|
|
|
|
|
|
Required with |
No default value; |
Many separate parameters repeated in every endpoint |
One Pydantic model with |
Pydantic v1 |
|
|
The typed signature; reading |
Details of Pydantic v1 to v2 names are in Pydantic v2 Essentials and the dated list of changes in What’s Changed & Migration.