Everything after the question mark: optional by default, converted by annotation, and where most of an API's flexibility lives.
Overview
How FastAPI decides
A query parameter is a named value placed after the question mark in a URL, like page=2 or sort=name. Clients use them to filter, sort, search, and page through results. They are optional by nature, because a URL works fine without them. That makes them the main place where an API gets its flexibility.
In FastAPI you declare a query parameter by adding a function argument that does not appear in the path. The framework notices that and reads it from the query string. Give it a default and it becomes optional. Leave the default off and it becomes required. The type annotation controls conversion, exactly as it does for path parameters.
This article begins with how FastAPI decides where each argument comes from, since the rule is short and worth memorizing. It then covers required versus optional parameters and what a default really means. From there it moves to constraints with the Query function, which lets you set limits on length, value ranges, and patterns.
You will also see how lists work, because a query string can repeat a name. Booleans get their own section, since clients write them in many ways and FastAPI accepts several. Closed sets with enums come next.
The article ends by putting these pieces together into the shape of a typical listing endpoint, with filters, sorting, and paging. That final example is the one you will probably copy into your own projects, so it is worth reading slowly.
Query parameters look simple, and the basics are. The trouble starts when an endpoint needs five optional filters, a sort order, and a page size, and each one needs sensible limits. Handling that cleanly is what separates a tidy API from a tangled one. This article gives you the habits to keep it tidy. You will also see how the generated docs show each parameter, so clients can discover every option without reading your code. Each section is short and ends with a habit you can apply straight away, so you finish with working knowledge and not only theory.
Look at a handler's signature and FastAPI classifies each param:
If the name appears in the route path, it's a path param.
If it's a Pydantic model, it's the request body.
If it's a dependency, a Header, a Cookie, or one of the other clear markers, it's that.
else it's a query param.
That last rule is the default. It's why a handler with a plain limit. Int = 10 works with no annotation ceremony at all.
Worth knowing
Any function argument that is not a path placeholder, a body model or a dependency is read from the query string.
A default makes a query parameter optional; no default makes it required. Optional[str] alone only says it may be null.
Query() takes the same constraints and metadata as Field, and they appear in the schema as documented limits.
A repeated key — ?tag=a&tag=b — becomes a list when the parameter is annotated as one. Items are converted individually.
Everything arrives as text, so booleans are read as words: true/yes/on/1 and false/no/off/0. Anything else is a 422.
Literal is better than str for a sort key or a mode: a clear error, and a set of choices in the docs.
Query Parameters: Everything After the Question Mark
Optional by default, converted by annotation, and where most of an API's flexibility lives.
Anything not in the path is a query parameter
A function argument that is not a path placeholder and is not a model is read from the query string.
example_01.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/modules")
def list_modules(track: str = "maths", limit: int = 10, verbose: bool = False):
return {"track": track, "limit": limit, "verbose": verbose,
"types": [type(v).__name__ for v in (track, limit, verbose)]}
client = TestClient(app)
print("defaults :", client.get("/modules").json())
print("supplied :", client.get("/modules?track=python&limit=3&verbose=yes").json())
Output
Required, optional and nullable
A default makes it optional. No default makes it required. Optional only says it may be null.
example_02.pyFastAPI
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
@app.get("/search")
def search(q: str, # required
track: Optional[str] = None, # optional, may be absent
limit: int = 10): # optional, has a default
return {"q": q, "track": track, "limit": limit}
client = TestClient(app)
print("minimum :", client.get("/search?q=vectors").json())
print("full :", client.get("/search?q=vectors&track=maths&limit=3").json())
r = client.get("/search")
print()
print("missing q:", r.status_code, r.json()["detail"][0]["loc"],
r.json()["detail"][0]["type"])
Output
Constraints and metadata
Query() carries the same arguments as Field, and they land in the documentation.
example_03.pyFastAPI
import json
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/modules")
def list_modules(
q: str = Query(default="", max_length=50, description="Free-text search."),
limit: int = Query(default=10, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
return {"q": q, "limit": limit, "offset": offset}
client = TestClient(app)
print("ok :", client.get("/modules?limit=25").json())
r = client.get("/modules?limit=500")
print("over:", r.status_code, r.json()["detail"][0]["msg"])
print()
for p in app.openapi()["paths"]["/modules"]["get"]["parameters"]:
print("%-7s %s" % (p["name"], json.dumps(p["schema"])))
Output
Repeated parameters become a list
?tag=a&tag=b is how a query string expresses more than one value. Annotate it as a list and you get one.
example_04.pyFastAPI
from typing import List
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/modules")
def list_modules(tag: List[str] = Query(default=[]),
id: List[int] = Query(default=[])):
return {"tag": tag, "id": id}
client = TestClient(app)
print("none :", client.get("/modules").json())
print("one :", client.get("/modules?tag=maths").json())
print("several :", client.get("/modules?tag=maths&tag=vectors&id=1&id=2").json())
print()
print("note the ids are ints, converted item by item")
Output
Booleans, and what counts as true
Query strings are text, so a flag arrives as a word. Pydantic reads the meaning rather than the emptiness.
example_05.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/flag")
def flag(on: bool = False):
return {"on": on}
client = TestClient(app)
for value in ["true", "True", "yes", "on", "1", "false", "no", "off", "0"]:
print("%-6s -> %s" % (value, client.get("/flag?on=" + value).json()["on"]))
r = client.get("/flag?on=maybe")
print()
print("maybe ->", r.status_code, r.json()["detail"][0]["type"])
print()
print('In plain Python bool("false") is', bool("false"), "- which is why this matters.")
Output
A realistic listing endpoint
Search, filter, sort and paginate: the shape almost every collection endpoint ends up with.
example_06.pyFastAPI
from typing import List, Literal, Optional
from fastapi import FastAPI, Query
app = FastAPI()
DB = [
{"id": 1, "title": "Vectors", "track": "maths", "minutes": 8},
{"id": 2, "title": "Norms", "track": "maths", "minutes": 6},
{"id": 3, "title": "Loops", "track": "python", "minutes": 12},
]
@app.get("/modules")
def list_modules(
q: Optional[str] = Query(default=None, max_length=50),
track: List[str] = Query(default=[]),
sort: Literal["title", "minutes"] = "title",
limit: int = Query(default=10, ge=1, le=100),
):
rows = DB
if q:
rows = [r for r in rows if q.lower() in r["title"].lower()]
if track:
rows = [r for r in rows if r["track"] in track]
rows = sorted(rows, key=lambda r: r[sort])[:limit]
return {"count": len(rows), "items": rows}
client = TestClient(app)
print(client.get("/modules").json())
print(client.get("/modules?track=maths&sort=minutes").json())
print(client.get("/modules?q=loop").json())
print()
print("bad sort:", client.get("/modules?sort=colour").status_code)
Output
Required versus optional
The rule is the same one from the Pydantic track. It's worth restating because it's the most common confusion here too.
A default makes it optional.limit: int = 10 may be omitted.
No default makes it needed.q: str must be supplied. A request without it gets a 422 whose loc is ("query", "q").
**Optional[str] alone doesn't make it optional.** It says the value may be null. To make it omissible you also need = None.
Most query params should be optional with a sensible default. A query string is where flexibility lives. But a needed one is legitimate. A search endpoint with no search term often has nothing to do.
Constraints with Query()
Query() carries the same args as Pydantic's Field:
limit: int = Query(default=10, ge=1, le=100)
The upper bound isn't decoration. Without it, ?limit=1000000 is a valid request that your DB will attempt to meet. A cap is the cheapest denial-of-service protection on hand. It turns a documented limit rather than a surprise.
description matters more for query params than almost anywhere else. They're what a caller tests with. A param called expand needs a sentence saying what it expands.
Lists
A query string expresses multiple values by repeating the key: ?tag=maths&tag=vectors. Annotate the param as a list and you get one:
tag: List[str] = Query(default=[])
The Query(default=[]) is needed rather than a bare = []. Without the clear marker FastAPI would read a list annotation as a request body.
Items are converted on their own, so List[int] given ?id=1&id=2 gives you [1, 2] and ?id=abc gives a 422 pointing at the offending item.
Comma-split values — ?tags=a,b,c — are not handled on its own. They're one string holding commas. If your API takes that form, split it in a checker. Be aware you're choosing a rule that the repeated-key form already solves.
Booleans
This catches people, and the action is right.
A query string is text. So A flag arrives as a word. ?on=true, ?on=yes, ?on=1 and ?on=on all give True. false, no, 0 and off give False. Anything else is a 422.
Compare with plain Python. Here bool("false") is True because the string is non-empty. Pydantic reads the meaning of the word rather than the emptiness of the holder. This Is just what a checkbox or a ?verbose=false needs.
Closed sets
For a sort key, a mode or a format, Literal beats str:
sort: Literal["title", "minutes"] = "title"
An unrecognised value is a 422 listing the options, instead of silently falling through to a default and returning data sorted in a way the caller didn't ask for. And the docs show the choices.
That silent fallback is the real risk. ?sort=colour with a plain str param often means somebody's code has a typo and their results have been subtly wrong for weeks.
The shape of a listing endpoint
Most set endpoints settle on the same four concerns. The fifth editor above puts them together. A search term, one or more filters, a sort key, and a limit.
A few habits worth carrying into your own.
Always cap the limit.le=100 on the param, and a default well below it.
Return the count alongside the items. So A client knows whether to keep paging.
Prefer clear filters to a general query language.?track=maths is easy to doc and hard to abuse. A param that accepts any filter expressions is neither.
Keep defaults sensible for a caller who reads nothing. A bare GET /modules should return a thing fair rather than an error.
Path or query?
The gap that resolves most args: a path param finds a resource. A query param shows a view of one or of a set.
/modules/7 is a thing. /modules?track=maths is a filtered set. /modules/7?verbose=true is a thing, presented differently.
If dropping the value would leave you handling a other resource, it belongs in the path. If it would leave you handling the same resource with other presentation or filtering, it belongs in the query string.
One hands-on result: query params are the right place for anything optional. A URL with an optional path segment is really two routes.
Aliases for names Python cannot use
A query param is at times named a thing that isn't a valid Python identifier, or is camelCase when your code isn't.
The URL uses item-query. Your function uses item_query. The schema docs the alias. The alias is what the wire really carries.
This is the same method as Pydantic's field aliases, applied to params. It's the clean way to consume an existing API's rule without contorting your own code.
Deprecating one
Query(deprecated=True) marks a param as deprecated in the docs while continuing to accept it.
That is the polite way to retire a param. The docs show it as deprecated, existing clients keep working. You can measure whether anyone is still sending it before dropping it. Together with AliasChoices from the Pydantic track it makes renaming a live param a non-event.
Hiding one from the docs
Query(include_in_schema=False) accepts a param without documenting it.
Use it sparingly and for good reasons. An inner debugging flag, a param kept only for a legacy client. An undocumented param that consumers are expected to use is a trap. that it works isn't discoverable.
Validation you cannot express as a constraint
Some rules span params: offset must be a multiple of limit, or from must precede to. A single param's constraints can't say that, for the same reason a Pydantic field checker can't see its siblings.
Two options. A dependency that takes both params and validates the pair. Which is the idiomatic FastAPI answer. Gets its own tier. Or a model with a model_validator, if the params truly belong together.
What you should avoid is checking in the handler and raising a 400. It works. It puts the rule one spot the schema can't see and the docs can't show.
Pagination, concretely
Two rules, and the trade between them is worth knowing.
Offset and limit is simple and universally understood. ?offset=40&limit=20. It degrades on large datasets. The DB still walks the skipped rows. It can miss or copy items if the underlying data changes between pages.
Cursor pagination passes an opaque token pointing at the last item seen. It's stable under concurrent writes and stays fast at any depth, at the cost of not being able to jump to page 40.
For most APIs offset is fine and honest. For a large or busy dataset, cursors are the right answer and are much easier to add at the start than to retrofit.
Either way: cap the limit, return the count, and doc both.
Query Parameters: summary
Query params are the default sorting, optional when they've a default, needed when they don't. Query() adds constraints, metadata and aliases, all of which reach the schema.
Lists come from repeated keys. Booleans read words, not emptiness. Literal beats str for anything with a fixed set of values. The option is a silent fallback no one notices.
And always cap the limit.
Mistakes people make with Query Parameters
**An uncapped limit.** ?limit=1000000 is a valid request your DB will try to meet. le=100 is the cheapest protection on hand.
**A bare = [] for a list.** Without Query(default=[]) the list annotation is read as a request body. The param silently never arrives.
**Using str for a sort key.** ?sort=colour then falls through to what your code does with an unknown value. No one is told. Literal makes it a 422.
Expecting comma-split values to split.?tags=a,b is one string holding commas. Repeated keys are the rule that already works.
**given Optional makes it optional.** It only permits null. The default is what makes a param omissible.
checking a link in the handler. Two params that must agree belong in a dependency or a model. Here The rule can be documented, rather than in an if that raises a 400 the schema knows nothing about.
Where to go after Query Parameters
The third source of input, and the one with the most structure. The request body. Here A Pydantic model does the work.
The parameter you did not add
A last finding. Most APIs build up query params faster than they drop them. Each one is a permanent commitment. Somebody will use it. Dropping it later breaks them.
So the handy discipline is at the point of adding. Is this a genuine view of the set, or is it a special case one caller asked for? Could it be a split endpoint with a clearer name? Will it still make sense mixed with the six that already exist?
An endpoint with four well-chosen params is easy to doc and hard to misuse. One with fifteen has mixes no one has tested. Probably some that contradict each other.
Filtering, and where it stops
There's a gravitational pull towards making a listing endpoint do all. More filters, then ranges, then mixes, then a small query language expressed in params.
It's worth resisting past a point, for two reasons. each param times the mixes you haven't tested. A filter language in a query string is a filter language you now maintain, doc and secure.
Two better answers when the pull gets strong. A split endpoint with a name that says what it does. /modules/recommended beats six params that together mean "recommended". Or a POST with a body, when the query truly is structured data. It loses cacheability and gains check, docs and a schema.
Neither is a failure. An endpoint that does one thing well is easier to use than one that can be persuaded to do anything.
Defaults are an interface decision
The defaults on a listing endpoint are the action most callers will ever see. Most callers send no params at all.
GET /modules with nothing else should return a thing handy. A sensible page size, a sensible sorting. No filters. If the bare call returns an error, or ten thousand rows, or an any sorting that changes between requests, that is the first impression your API makes.
Choosing those defaults on purpose costs nothing and is worth more than any single param you might add later.
A closing thought
Query params are where an API is most flexible and so where it most easily turns incoherent.
Each one is easy to add, hard to drop. Interacts with every other. The discipline that keeps a listing endpoint healthy isn't technical. Constraints and Literal handle the mechanics. But editorial. Deciding what this endpoint is *for*. Declining the params that belong to a other question.
Questions people ask about Query Parameters
How does FastAPI know a parameter is a query parameter? Any function parameter that is not in the path and is not a Pydantic model is treated as a query parameter.
How do I make a query parameter required? Give it no default value. A parameter with a default, or with None as the default, is optional.
How do I accept a list of values like ?tag=a&tag=b? Annotate with Query, for example tags: list[str] = Query(default=[]). Without Query, a list is read as a body.
Which values count as true for a bool parameter? FastAPI accepts true, 1, on, yes and their false counterparts, ignoring case. Anything else returns a 422.
How do I use a query name that isn't a valid Python identifier? Set an alias, for example item_query: str = Query(alias="item-query").
Should I use a path or a query parameter? Use the path for identifying one resource and query parameters for filtering, sorting, and paging a collection.
One more on naming
param names are part of the public interface and are read far more often than they're written.
Prefer full words to abbreviations. limit over lim, offset over off. Prefer the noun a caller would use over the one your DB uses. And keep the same name for the same concept across each endpoint. An API where one route takes limit and another takes page_size makes each caller check.
alias exists for when the wire name and the Python name must differ. It isn't a licence to have three names for the same idea.
Check yourself
0 of 4
Answer without scrolling back up.
How does FastAPI decide a parameter is a query parameter?
Query is the fallback classification, which is why `limit: int = 10` works with no ceremony. `Query()` is only needed to add constraints or to disambiguate a list.
Why does a list query parameter need `Query(default=[])` rather than `= []`?
The explicit marker tells FastAPI where the value comes from. Without it, the list annotation is classified as a body.
What does `?on=false` give a `bool` parameter?
Pydantic reads the word's meaning, not the container's emptiness - unlike `bool("false")` in plain Python, which is True. That is what makes query flags work.
Why use `Literal` for a `sort` parameter instead of `str`?
A plain `str` accepts `?sort=colour`, falls through to whatever your code does with an unknown key, and nobody is told. That is how results end up subtly wrong for weeks.
Cheat sheet
Query Parameters
Add query parameters with defaults and type hints, send a request, and watch FastAPI validate and document them from a single annotation.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.