← Back to FastAPI
Lesson 1.5 · Getting Started

Query Parameters

Use the part of the URL after the ? - /books?skip=2&limit=2, /search?q=web&genre=web - for options, filters and paging. Make them optional or required, add limits, accept lists, and see what FastAPI silently ignores.

Beginner30 min

What you will be able to do

  • Explain what a query parameter is and how it differs from a path parameter
  • Make query parameters optional, required, or allowed to be missing
  • Read true/false values and lists from the query
  • Add rules with Query(): min_length, max_length, ge, le
  • Combine path and query parameters
  • Avoid the silent mistakes: unknown names, empty values, negative paging

The idea, in plain English

Look at a shopping site URL: /books?genre=python&page=2. The part after the ? is the query string. It holds options: which genre, which page, how to sort. Each option is a query parameter: a name, =, and a value, joined with &.

In FastAPI you do not write query parameters in the path. Any function argument that is not part of the path becomes a query parameter. Give it a default value and it is optional; give it no default and it is required.

In this lesson we build a small book list with paging and search, and test it with 28 requests. Along the way we find several things FastAPI does silently - ignoring unknown names, accepting empty values - that you need to know about. FastAPI 0.143.0.

Worked example: A book list with paging (skip, limit), a search with a required q and an optional genre, true/false flags, repeated tags - tested with 28 requests.

request flowReading the query stringstep 1 / 3

1 - The query string is split

Everything after ? is split at & into name=value pairs. The path (/search) is used to find the route; the query is not.

path
/search
q
"book"
genre
"web"
order
does not matter

GET /search?q=book&genre=web, step by step. Real results.

Words you will see in this lesson

A few words about query strings.

Small dictionary
Query stringThe part of the URL after ?: genre=python&page=2.
Query parameterOne name=value pair in the query string.
OptionalMay be left out; the default value is used.
RequiredMust be sent; leaving it out gives 422.
PaginationSplitting a long list into pages: skip some, then take a limit.
Query()Adds rules and docs to a query parameter.

An everyday example: ordering coffee

"A coffee" is the thing you want - like the path /coffee. "With milk, no sugar, large" are options - like ?milk=true&sugar=0&size=large. If you say nothing about sugar, the café uses its default. Some options are required: the café must know which drink.

Query parameters are those options. They change how the answer looks - filtered, sorted, paged - but not WHICH thing you are asking about.

Example 1 - a book list with options

Read each function’s arguments; that is where the rules are. list_books(skip: int = 0, limit: int = 3): two optional numbers for paging. search(q: str, genre: str | None = None): q has no default, so it is required; genre may be missing. flags: a bool. many: a list of tags. limited: rules with Query(). shop_books: a path parameter AND a query parameter together - FastAPI knows shop_id is in the path because it appears in "{shop_id}".

One detail: a list in the query needs Query() - list[str] = [] alone would be read as a body (Lesson 1.6). With Annotated[list[str], Query()] FastAPI knows to collect repeated ?tag= values.

Example 1 - query_params.py
from typing import Annotated from fastapi import FastAPI, Query app = FastAPI() BOOKS = [{"id": i, "title": f"Book {i}", "genre": g} for i, g in enumerate(["python", "web", "python", "data", "web", "python"], start=1)] @app.get("/books") def list_books(skip: int = 0, limit: int = 3): # a default -> optional return BOOKS[skip: skip + limit] @app.get("/search") def search(q: str, genre: str | None = None): # no default -> required; None -> optional found = [b for b in BOOKS if q.lower() in b["title"].lower()] if genre: found = [b for b in found if b["genre"] == genre] return {"q": q, "genre": genre, "count": len(found)} @app.get("/flags") def flags(in_stock: bool = False): # bool from text return {"in_stock": in_stock} @app.get("/many") def many(tag: Annotated[list[str], Query()] = []): # ?tag=a&tag=b -> a list return {"tags": tag} @app.get("/limited") def limited( q: Annotated[str, Query(min_length=3, max_length=20)], limit: Annotated[int, Query(ge=1, le=50)] = 10, ): return {"q": q, "limit": limit} @app.get("/shops/{shop_id}/books") def shop_books(shop_id: int, genre: str | None = None): # path + query together return {"shop_id": shop_id, "genre": genre}

Optional, required, or "may be missing"

Three patterns cover almost everything. With a default (limit: int = 3), the parameter is optional and the default is used when it is missing. With no default (q: str), it is required - /search without q answered 422 "Field required". With str | None = None, it is optional and your code receives None when it is missing - useful when "not given" means "do not filter".

Paging worked as expected: ?skip=2&limit=2 gave books 3 and 4. A wrong type (?limit=abc) gave 422, exactly like a path parameter.

Output - paging and search
/books -> 200 [{"id":1,...},{"id":2,...},{"id":3,...}] /books?skip=2 -> 200 [{"id":3,...},{"id":4,...},{"id":5,...}] /books?skip=2&limit=2 -> 200 [{"id":3,"title":"Book 3","genre":"python"},{"id":4,"title":"Book 4","genre":"data"}] /books?limit=abc -> 422 {"detail":[{"type":"int_parsing","loc":["query","limit"],"msg":"Input should be a valid integer, unable to parse string /search?q=book -> 200 {"q":"book","genre":null,"count":6} /search?q=book&genre=web -> 200 {"q":"book","genre":"web","count":2} /search -> 422 {"detail":[{"type":"missing","loc":["query","q"],"msg":"Field required","input":null}]} /shops/4/books?genre=web -> 200 {"shop_id":4,"genre":"web"}

True/false values and lists

A query value is always text, so how does "true" become True? For a bool, FastAPI accepted true, 1, yes and on as True, and false, 0, no, off, f and n as False - in any case ("False" and "NO" worked). "maybe" was refused. Watch out: ?in_stock with no value was also refused with 422 - in a query string, a name alone does not mean True.

For a list, the client repeats the name: ?tag=python&tag=web gave ["python", "web"]. A comma-separated value is NOT split: ?tag=python,web gave one tag, "python,web". With no tag at all, the default [] was used.

Output - bools and lists
/flags?in_stock=true -> 200 {"in_stock":true} /flags?in_stock=1 -> 200 {"in_stock":true} /flags?in_stock=yes -> 200 {"in_stock":true} /flags?in_stock=on -> 200 {"in_stock":true} /flags?in_stock=False -> 200 {"in_stock":false} /flags?in_stock=maybe -> 422 {"detail":[{"type":"bool_parsing","loc":["query","in_stock"],"msg":"Input should be a valid boolean, unable to interpret /flags?in_stock -> 422 {"detail":[{"type":"bool_parsing","loc":["query","in_stock"],"msg":"Input should be a valid boolean, unable to interpret /many?tag=python&tag=web -> 200 {"tags":["python","web"]} /many?tag=python,web -> 200 {"tags":["python,web"]} /many -> 200 {"tags":[]}

Rules with Query()

Query() works like Path() from Lesson 1.4: min_length and max_length for text, ge, gt, le and lt for numbers, plus a description for the docs. ?q=py was refused because it is shorter than 3 characters; ?limit=100 because it is above 50.

The rules also appear in /openapi.json and /docs - minLength, maxLength, minimum, maximum - so client developers can see them without reading your code.

Output - rules, and how they are documented
/limited?q=py -> 422 {"detail":[{"type":"string_too_short","loc":["query","q"],"msg":"String should have at least 3 characters","input":"py", /limited?q=python&limit=100 -> 422 {"detail":[{"type":"less_than_equal","loc":["query","limit"],"msg":"Input should be less than or equal to 50","input":"1 /limited?q=python -> 200 {"q":"python","limit":10} in openapi.json: q query required {'type': 'string', 'minLength': 3, 'maxLength': 20, 'title': 'Q'} limit query optional {'type': 'integer', 'maximum': 50, 'minimum': 1, 'default': 10, 'title': 'Limit'}

Five things FastAPI does silently

These caused no error at all - and each one can hide a bug.

Unknown names are ignored. ?colour=red on /books changed nothing. Worse, a typo is ignored too: ?limt=1 (instead of limit) returned the default 3 books, not 1. The client thinks it asked for 1.

Empty values are accepted. ?q= gave q = "" (an empty string), which matched all 6 books. If an empty search makes no sense, use Query(min_length=1).

A repeated name keeps only the last value - for a normal (non-list) parameter. ?q=book&q=web searched for "web".

Negative paging is accepted. ?skip=-2 returned an empty list. Python slicing with a negative start does something unexpected, and nothing warned us. Use Query(ge=0) for skip and Query(ge=1, le=100) for limit.

Output - the silent cases
/books?colour=red -> 200 [{"id":1,...},{"id":2,...},{"id":3,...}] (ignored) /books?limt=1 -> 200 ids=[1, 2, 3] (typo ignored: 3, not 1) /search?q= -> 200 {"q":"","genre":null,"count":6} (empty matches everything) /search?q=book&q=web -> 200 {"q":"web","genre":null,"count":0} (last value wins) /books?skip=-2 -> 200 ids=[] (negative skip: empty list)
Safer paging
@app.get("/books") def list_books( skip: Annotated[int, Query(ge=0)] = 0, # no negative skip limit: Annotated[int, Query(ge=1, le=100)] = 10, # at least 1, at most 100 ): return BOOKS[skip: skip + limit]

Query parameters at a glance

Optional

Has a default.

def list_books(limit: int = 10):
Required

No default.

def search(q: str):
May be missing

None when not sent.

genre: str | None = None
Rules

Like Path().

Annotated[str, Query(min_length=3, max_length=20)]
A list

Repeat the name: ?tag=a&tag=b

tag: Annotated[list[str], Query()] = []
A bool

true/1/yes/on or false/0/no/off

in_stock: bool = False
Safe paging

No negatives, a maximum.

skip: Annotated[int, Query(ge=0)] = 0

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

In the docs

“Open /docs for query_params.py. Which parameters are marked "required"? Try /limited with q="py".”

Sort order

“Add ?sort=asc|desc to /books using an Enum (Lesson 1.4). What happens with sort=up?”

Catch typos

“Think: how could a client notice that ?limt=1 was ignored? (Hint: return the skip and limit you used in the response.)”

Comma lists

“Change /many so that ?tag=python,web also gives two tags. Where would you split the text?”

What usually goes wrong

Expecting a typo to fail

?limt=1 was ignored and the default was used. Return the values you used, so clients can see it.

No limits on paging

skip=-2 gave an empty list; a huge limit could return everything. Add ge and le.

✗ def list_books(skip: int = 0, limit: int = 3):
✓ def list_books(skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 10):
Sending a list as commas

"python,web" is one value. Repeat the name, or split the text yourself.

✗ /many?tag=python,web
✓ /many?tag=python&tag=web
Treating ?flag as True

A name with no value gave 422 for a bool. Send ?in_stock=true.

A list without Query()

Without Query(), a list parameter is read from the request body. We tested it: ?tag=a&tag=b returned [] with no error, and /openapi.json listed it as a request body, not a query parameter.

✗ def many(tag: list[str] = []):
✓ def many(tag: Annotated[list[str], Query()] = []):

Key points

  • Any function argument that is not in the path is a query parameter.
  • A default makes it optional; no default makes it required; X | None = None allows it to be missing.
  • Bools accept true/1/yes/on and false/0/no/off; a name alone (?flag) is refused.
  • Lists need Query() and repeated names: ?tag=a&tag=b.
  • Query() adds rules (min_length, ge, le) that also appear in the docs.
  • Unknown names, typos and empty values are accepted silently - add rules and return what you used.

Quick check before you move on

How does FastAPI know shop_id is a path parameter and genre a query parameter?
shop_id appears in the path pattern "{shop_id}"; genre does not, so it comes from the query.
How do you make q required?
Give it no default: def search(q: str).
What did ?tag=python,web give?
One tag: "python,web".
What did ?limt=1 return, and why?
3 books: the misspelled name was ignored and the default limit was used.

Interview questions

How does FastAPI decide where a parameter comes from?

Names in the path pattern are path parameters; simple types (int, str, bool...) not in the path are query parameters; Pydantic models are the request body; Query(), Path(), Body(), Header() etc. make it explicit.

How would you implement pagination safely?

skip/limit (or page/size) query parameters with constraints - skip >= 0, 1 <= limit <= a maximum - return the values used and the total count, and consider cursor-based pagination for large or changing data.

What are the risks of unvalidated query parameters?

Silent misbehaviour (ignored typos, empty strings matching everything), unbounded result sizes, and injection if values reach queries or commands unchecked.

Quiz

  1. 1.

    Which values did FastAPI accept as True for a bool query parameter?

  2. 2.

    What happened with ?q=book&q=web for q: str?

  3. 3.

    How do you stop an empty search (?q=)?

  4. 4.

    Why use Query(ge=0) for skip?

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...