Query parameters are the key-value pairs after the ? in a URL — /posts?status=published&page=2&limit=10. Unlike path parameters, they are optional by default (when a default value is provided) and can appear in any order. FastAPI function parameters that are not path parameters and not Pydantic models are automatically treated as query parameters. They work exactly like path parameters in terms of type validation and Query() constraints — the only difference is where in the URL they appear.
Declaring Query Parameters
from fastapi import FastAPI, Query
from typing import Annotated, Literal
app = FastAPI()
# ── Simple query parameters ───────────────────────────────────────────────────
@app.get("/posts")
def list_posts(
page: int = 1, # optional, default 1
limit: int = 10, # optional, default 10
search: str = "", # optional, default empty string
published_only: bool = True, # optional, default True
):
return {
"page": page,
"limit": limit,
"search": search,
"published_only": published_only,
}
# GET /posts → page=1, limit=10, search="", published_only=True
# GET /posts?page=2&limit=20 → page=2, limit=20
# GET /posts?search=fastapi&published_only=false → search="fastapi", published_only=False
# GET /posts?page=abc → 422 (abc not int)
true, 1, yes, on → True; false, 0, no, off → False. This means ?published_only=true, ?published_only=True, ?published_only=1, and ?published_only=yes all work. The same case-insensitive parsing applies to Literal string values — ?order=ASC will not match Literal["asc"] though, since string Literals are case-sensitive.list[str] or list[int] with Query(). The client sends multiple values as repeated parameters: ?tags=python&tags=fastapi&tags=api — FastAPI collects them into a list. Without the Query() wrapper, FastAPI will only see the last value. With it: tags: Annotated[list[str], Query()] = [] gives you ["python", "fastapi", "api"].Query() Constraints and Documentation
from fastapi import FastAPI, Query
from typing import Annotated, Literal
app = FastAPI()
@app.get("/posts")
def list_posts(
page: Annotated[int, Query(
ge = 1,
description = "Page number (1-indexed)",
example = 1,
)] = 1,
limit: Annotated[int, Query(
ge = 1,
le = 100,
description = "Number of results per page (max 100)",
example = 10,
)] = 10,
search: Annotated[str | None, Query(
max_length = 200,
description = "Search term to filter posts by title or body",
)] = None,
sort_by: Annotated[
Literal["created_at", "view_count", "title"],
Query(description="Field to sort results by")
] = "created_at",
order: Annotated[
Literal["asc", "desc"],
Query(description="Sort direction")
] = "desc",
tags: Annotated[list[str], Query(
description = "Filter by tags (can specify multiple)",
example = ["python", "fastapi"],
)] = [],
):
return {
"page": page, "limit": limit, "search": search,
"sort_by": sort_by, "order": order, "tags": tags
}
# GET /posts?page=2&limit=20&sort_by=view_count&order=desc&tags=python&tags=fastapi
Optional Query Parameters with None
from fastapi import FastAPI
from datetime import date
app = FastAPI()
@app.get("/posts")
def list_posts(
author_id: int | None = None, # filter by author if provided
from_date: date | None = None, # filter by date range if provided
to_date: date | None = None,
status: str | None = None,
):
# Build query dynamically based on what was provided
query = db.query(Post)
if author_id is not None:
query = query.filter(Post.author_id == author_id)
if from_date:
query = query.filter(Post.created_at >= from_date)
if to_date:
query = query.filter(Post.created_at <= to_date)
if status:
query = query.filter(Post.status == status)
return query.all()
# GET /posts?author_id=5 → only posts by author 5
# GET /posts?from_date=2025-01-01&to_date=2025-12-31 → date range
# GET /posts → all posts
Common Mistakes
Mistake 1 — List query parameter without Query()
❌ Wrong — FastAPI only sees the last value:
def list_posts(tags: list[str] = []):
# GET /posts?tags=python&tags=fastapi → tags=["fastapi"] (only last!)
✅ Correct — wrap with Query():
def list_posts(tags: Annotated[list[str], Query()] = []):
# GET /posts?tags=python&tags=fastapi → tags=["python", "fastapi"] ✓
Mistake 2 — Not limiting page size (DoS risk)
❌ Wrong — client can request millions of rows:
def list_posts(limit: int = 10): # GET /posts?limit=1000000 → loads 1M rows!
✅ Correct — add maximum constraint:
def list_posts(limit: Annotated[int, Query(ge=1, le=100)] = 10): # ✓ max 100
Mistake 3 — Unvalidated sort_by field (SQL injection risk)
❌ Wrong — user-controlled column name in ORDER BY:
def list_posts(sort_by: str = "created_at"):
return db.execute(f"SELECT * FROM posts ORDER BY {sort_by}") # injection!
✅ Correct — use Literal to whitelist allowed values:
def list_posts(sort_by: Literal["created_at", "view_count", "title"] = "created_at"):
# sort_by is guaranteed to be one of the three allowed values ✓
Quick Reference
| Pattern | Code |
|---|---|
| Optional with default | page: int = 1 |
| Optional with None | author_id: int | None = None |
| Required query param | search: str (no default) |
| With constraints | limit: Annotated[int, Query(ge=1, le=100)] = 10 |
| List values | tags: Annotated[list[str], Query()] = [] |
| Enum-like | order: Literal["asc", "desc"] = "desc" |
| Date param | from_date: date | None = None |