Everything after the question mark: optional by default, converted by annotation, and where most of an API's flexibility lives.
Overview
How FastAPI decides
Look at a handler's signature and FastAPI classifies each parameter:
If the name appears in the route path, it is a path parameter.
If it is a Pydantic model, it is the request body.
If it is a dependency, a Header, a Cookie, or one of the other explicit markers, it is that.
Otherwise it is a query parameter.
That last rule is the default, and it is 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
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
Output
Constraints and metadata
Query() carries the same arguments as Field, and they land in the documentation.
example_03.pyFastAPI
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
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
Output
A realistic listing endpoint
Search, filter, sort and paginate: the shape almost every collection endpoint ends up with.
example_06.pyFastAPI
Output
Required versus optional
The rule is the same one from the Pydantic track, and it is worth restating because it is the most common confusion here too.
A default makes it optional.limit: int = 10 may be omitted.
No default makes it required.q: str must be supplied, and a request without it gets a 422 whose loc is ("query", "q").
Optional[str] alone does not make it optional. It says the value may be null. To make it omissible you also need = None.
Most query parameters should be optional with a sensible default, because a query string is where flexibility lives — but a required one is legitimate. A search endpoint with no search term usually has nothing to do.
Constraints with Query()
Query() carries the same arguments as Pydantic's Field:
limit: int = Query(default=10, ge=1, le=100)
The upper bound is not decoration. Without it, ?limit=1000000 is a valid request that your database will attempt to satisfy. A cap is the cheapest denial-of-service protection available, and it becomes a documented limit rather than a surprise.
description matters more for query parameters than almost anywhere else, because they are what a caller experiments with. A parameter 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 parameter as a list and you get one:
tag: List[str] = Query(default=[])
The Query(default=[]) is needed rather than a bare = [], because without the explicit marker FastAPI would read a list annotation as a request body.
Items are converted individually, so List[int] given ?id=1&id=2 gives you [1, 2] and ?id=abc gives a 422 pointing at the offending item.
Comma-separated values — ?tags=a,b,c — are not handled automatically. They are one string containing commas. If your API takes that form, split it in a validator, and be aware you are choosing a convention that the repeated-key form already solves.
Booleans
This catches people, and the behaviour 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, where bool("false") is True because the string is non-empty. Pydantic reads the meaning of the word rather than the emptiness of the container, which is exactly 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 ordered in a way the caller did not ask for. And the docs show the choices.
That silent fallback is the real risk. ?sort=colour with a plain str parameter typically means somebody's code has a typo and their results have been subtly wrong for weeks.
The shape of a listing endpoint
Most collection endpoints converge on the same four concerns, and 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 parameter, and a default well below it.
Return the count alongside the items, so a client knows whether to keep paging.
Prefer explicit filters to a general query language.?track=maths is easy to document and hard to abuse; a parameter that accepts arbitrary filter expressions is neither.
Keep defaults sensible for a caller who reads nothing. A bare GET /modules should return something reasonable rather than an error.
Path or query?
The distinction that resolves most arguments: a path parameter identifies a resource; a query parameter describes a view of one or of a collection.
/modules/7 is a thing. /modules?track=maths is a filtered set. /modules/7?verbose=true is a thing, presented differently.
If removing the value would leave you addressing a different resource, it belongs in the path. If it would leave you addressing the same resource with different presentation or filtering, it belongs in the query string.
One practical consequence: query parameters are the right place for anything optional, because a URL with an optional path segment is really two routes.
Aliases for names Python cannot use
A query parameter is sometimes named something that is not a valid Python identifier, or is camelCase when your code is not.
The URL uses item-query; your function uses item_query. The schema documents the alias, because the alias is what the wire actually carries.
This is the same mechanism as Pydantic's field aliases, applied to parameters, and it is the clean way to consume an existing API's convention without contorting your own code.
Deprecating one
Query(deprecated=True) marks a parameter as deprecated in the documentation while continuing to accept it.
That is the polite way to retire a parameter: the docs show it as deprecated, existing clients keep working, and you can measure whether anyone is still sending it before removing it. Together with AliasChoices from the Pydantic track it makes renaming a live parameter a non-event.
Hiding one from the docs
Query(include_in_schema=False) accepts a parameter without documenting it.
Use it sparingly and for good reasons — an internal debugging flag, a parameter kept only for a legacy client. An undocumented parameter that consumers are expected to use is a trap, and the fact that it works is not discoverable.
Validation you cannot express as a constraint
Some rules span parameters: offset must be a multiple of limit, or from must precede to. A single parameter's constraints cannot say that, for the same reason a Pydantic field validator cannot see its siblings.
Two options. A dependency that takes both parameters and validates the pair — which is the idiomatic FastAPI answer, and gets its own tier. Or a model with a model_validator, if the parameters genuinely belong together.
What you should avoid is checking in the handler and raising a 400. It works, and it puts the rule somewhere the schema cannot see and the docs cannot show.
Pagination, concretely
Two conventions, 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, because the database still walks the skipped rows, and it can miss or duplicate items if the underlying data changes between pages.
Cursor pagination passes an opaque token pointing at the last item seen. It is 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 introduce at the start than to retrofit.
Either way: cap the limit, return the count, and document both.
Summary
Query parameters are the default classification, optional when they have a default, required when they do not. 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, because the alternative is a silent fallback nobody notices.
And always cap the limit.
Mistakes people make
An uncapped limit.?limit=1000000 is a valid request your database will try to satisfy. le=100 is the cheapest protection available.
A bare = [] for a list. Without Query(default=[]) the list annotation is read as a request body, and the parameter silently never arrives.
Using str for a sort key.?sort=colour then falls through to whatever your code does with an unknown value, and nobody is told. Literal makes it a 422.
Expecting comma-separated values to split.?tags=a,b is one string containing commas. Repeated keys are the convention that already works.
Assuming Optional makes it optional. It only permits null. The default is what makes a parameter omissible.
Validating a relationship in the handler. Two parameters that must agree belong in a dependency or a model, where the rule can be documented, rather than in an if that raises a 400 the schema knows nothing about.
Next
The third source of input, and the one with the most structure: the request body, where a Pydantic model does the work.
The parameter you did not add
A last observation. Most APIs accumulate query parameters faster than they remove them, and each one is a permanent commitment: somebody will use it, and removing it later breaks them.
So the useful discipline is at the point of adding. Is this a genuine view of the collection, or is it a special case one caller asked for? Could it be a separate endpoint with a clearer name? Will it still make sense combined with the six that already exist?
An endpoint with four well-chosen parameters is easy to document and hard to misuse. One with fifteen has combinations nobody has tested, and probably some that contradict each other.
Filtering, and where it stops
There is a gravitational pull towards making a listing endpoint do everything: more filters, then ranges, then combinations, then a small query language expressed in parameters.
It is worth resisting past a point, for two reasons. Every parameter multiplies the combinations you have not tested, and a filter language in a query string is a filter language you now maintain, document and secure.
Two better answers when the pull gets strong. A separate endpoint with a name that says what it does — /modules/recommended beats six parameters that together mean "recommended". Or a POST with a body, when the query genuinely is structured data; it loses cacheability and gains validation, documentation 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 behaviour most callers will ever see, because most callers send no parameters at all.
GET /modules with nothing else should return something useful: a sensible page size, a sensible ordering, and no filters. If the bare call returns an error, or ten thousand rows, or an arbitrary ordering that changes between requests, that is the first impression your API makes.
Choosing those defaults deliberately costs nothing and is worth more than any individual parameter you might add later.
A closing thought
Query parameters are where an API is most flexible and therefore where it most easily becomes incoherent.
Each one is easy to add, hard to remove, and interacts with every other. The discipline that keeps a listing endpoint healthy is not technical — constraints and Literal handle the mechanics — but editorial: deciding what this endpoint is *for*, and declining the parameters that belong to a different question.
One more on naming
Parameter names are part of the public interface and are read far more often than they are written.
Prefer full words to abbreviations — limit over lim, offset over off. Prefer the noun a caller would use over the one your database uses. And keep the same name for the same concept across every endpoint: an API where one route takes limit and another takes page_size makes every caller check.
alias exists for when the wire name and the Python name must differ. It is not 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
Most query parameters should be optional with a sensible default, because a query string is where flexibility lives — but a required one is legitimate. A search endpoint with no search term usually has nothing to do.
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.