Query parameters are the ?key=value&other=thing bits after the URL path. In FastAPI we read them by declaring function arguments that aren’t in the path string. That’s it — no request.query.get() ceremony.
The basic rule
If we declare a function param whose name doesn’t appear in the route path, FastAPI treats it as a query param.
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/")
async def list_items(skip: int = 0, limit: int = 10):
return {"skip": skip, "limit": limit, "items": [...]}
Hit /items/?skip=20&limit=50 and FastAPI parses both into ints. Defaults make them optional — call /items/ and we get skip=0, limit=10.
Required vs optional
A query param is optional if it has a default value. It’s required if it doesn’t.
@app.get("/search/")
async def search(q: str): # required
return {"q": q}
@app.get("/search/v2/")
async def search_v2(q: str = ""): # optional, defaults to empty
return {"q": q}
@app.get("/search/v3/")
async def search_v3(q: str | None = None): # optional, may be None
return {"q": q}
str | None = None (or Optional[str] = None) is the pattern when “missing” is genuinely different from “empty string.”
q: str → required (no default)q: str = "foo" → optional with defaultq: str | None = None → optional, nullableType coercion
Booleans are forgiving:
@app.get("/items/")
async def list_items(in_stock: bool = False):
return {"in_stock": in_stock}
?in_stock=true, ?in_stock=1, ?in_stock=yes, ?in_stock=on all parse to True. Their false counterparts go to False. Anything else gets a 422.
Validation with Query()
For length, regex, or numeric constraints, use the Query() helper:
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/products/")
async def search_products(
q: str = Query(
..., # ... means required
min_length=3,
max_length=50,
title="Search query",
description="Free-text search across product names",
),
page: int = Query(1, ge=1, le=1000),
sort: str = Query("created", pattern="^(created|price|name)$"),
):
return {"q": q, "page": page, "sort": sort}
min_length,max_length— string length bounds.pattern— regex match.ge,gt,le,lt— numeric bounds.title,description— show up in/docs.
List query parameters
Some APIs need to accept multiple values for the same key: ?tag=python&tag=fastapi. We type the param as a list:
@app.get("/articles/")
async def list_articles(tag: list[str] = Query(default=[])):
return {"tags": tag}
A request like /articles/?tag=python&tag=fastapi&tag=backend gives us tag = ["python", "fastapi", "backend"].
Without Query(default=[]), FastAPI would think list[str] is a request body, not a query param. The explicit Query() is what tells it “this is a query param that can repeat.”
Alias for keys with weird characters
OpenAPI / URL conventions often use kebab-case but Python uses snake_case. The alias keeps both happy:
@app.get("/items/")
async def list_items(item_query: str | None = Query(default=None, alias="item-query")):
return {"item_query": item_query}
The URL sees ?item-query=hello, the function gets item_query="hello".
Deprecation
Mark a query param deprecated and Swagger shows it crossed out:
@app.get("/items/")
async def list_items(q: str | None = Query(default=None, deprecated=True)):
return {"q": q}
Combining path + query
Path params and query params coexist naturally:
@app.get("/users/{user_id}/orders/")
async def user_orders(
user_id: int,
status: str | None = None,
limit: int = 20,
):
return {"user_id": user_id, "status": status, "limit": limit}
user_id is in the path string so it’s a path param. status and limit aren’t, so they’re query params. FastAPI figures it out from the route declaration alone.
Interview tip
The “is this a path param or query param?” question comes up a lot. The rule: if the parameter name appears in the URL pattern between {}, it’s a path param. Otherwise (assuming it’s a simple type, not a Pydantic model) it’s a query param.