Module 3: Request and Response
Query Parameters in FastAPI
Capsule overview
In the previous capsule you worked with path parameters — values that are part of the URL, like /books/3. Now you're going to learn the other fundamental mechanism for sending data in a GET request: query parameters. They're the values that go after the ? in the URL: /books?genre=fiction&available=true. Where path parameters identify one specific resource, query parameters filter, search, and paginate collections of resources.
FastAPI detects query parameters automatically: if a parameter of your function isn't declared in the route, FastAPI treats it as a query parameter. You don't need special decorators or extra configuration — just add parameters to your function with default values. This mechanism lets you turn your GET /books endpoint (which returns everything) into a flexible endpoint that filters by genre, searches by title, and paginates results.
By the end of this capsule you'll have a GET /books endpoint with combinable filters: genre, availability, year range, text search, and pagination. All of it using nothing but function parameters with type hints — no Query(), no Pydantic (that comes in the next capsule).
Base data: the book collection
You're going to work with the same books API from Module 2, but with one extra field: available. Create (or update) app/main.py:
from fastapi import FastAPI
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical realism", "available": True},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel", "available": True},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental novel", "available": False},
{"id": 4, "title": "The House of the Spirits", "author": "Isabel Allende", "year": 1982, "genre": "Magical realism", "available": True},
{"id": 5, "title": "Fictions", "author": "Jorge Luis Borges", "year": 1944, "genre": "Short stories", "available": False},
{"id": 6, "title": "Pedro Páramo", "author": "Juan Rulfo", "year": 1955, "genre": "Magical realism", "available": True},
{"id": 7, "title": "The Time of the Hero", "author": "Mario Vargas Llosa", "year": 1963, "genre": "Novel", "available": True},
{"id": 8, "title": "The Tunnel", "author": "Ernesto Sabato", "year": 1948, "genre": "Novel", "available": False},
]
Eight books with varied data: three genres, a mix of available True/False, and years from 1605 to 1982. The examples in the rest of the capsule use this same list.
What are query parameters?
When you hit a URL like this one:
GET /books?genre=Novel&available=true
The part after the ? is the query parameters. The format is key=value, separated by &. In HTTP terms:
- Path →
/books— identifies the resource (the collection) - Query string →
?genre=Novel&available=true— changes which data gets returned
Query parameters are ideal for filtering, searching, paginating, and sorting.
How does FastAPI detect them?
If a parameter of your function appears in the route, it's a path parameter. If it doesn't appear, it's a query parameter.
@app.get("/books/{book_id}")
def get_book(book_id: int):
# book_id is in the route → path parameter
...
@app.get("/books")
def list_books(genre: str):
# genre is NOT in the route → query parameter
...
There are no special decorators. FastAPI works it out from the parameter name and the route.
A required query parameter
The simplest case: a parameter with no default value.
@app.get("/books")
def list_books(genre: str):
results = [b for b in books if b["genre"] == genre]
return results
curl "http://127.0.0.1:8000/books?genre=Novel"
# → [Don Quixote, The Time of the Hero, The Tunnel]
What happens if you don't send the parameter?
curl http://127.0.0.1:8000/books
# → Error 422: {"detail": [{"type": "missing", "loc": ["query", "genre"], "msg": "Field required"}]}
Since genre has no default value, FastAPI treats it as required. For a listing endpoint, that's rarely what you want — normally /books with no parameters should return every book.
Optional query parameters with default values
To make a query parameter optional, give it a default value:
@app.get("/books")
def list_books(genre: str = None):
if genre:
return [b for b in books if b["genre"] == genre]
return books
Now both requests work:
curl http://127.0.0.1:8000/books
# → All 8 books
curl "http://127.0.0.1:8000/books?genre=Magical%20realism"
# → [One Hundred Years of Solitude, The House of the Spirits, Pedro Páramo]
The %20 in the URL encodes the space. FastAPI automatically decodes Magical%20realism into "Magical realism".
Optional from typing
Python has a more explicit way to declare optional parameters:
from typing import Optional
@app.get("/books")
def list_books(genre: Optional[str] = None):
if genre:
return [b for b in books if b["genre"] == genre]
return books
Optional[str] is equivalent to str | None — it tells Python (and FastAPI) that the value can be a str or None. The behavior is identical to genre: str = None, but it's more explicit. Both forms are valid; Optional is more popular in projects with strict type checking.
Progressive filters: from simple to complete
Let's build the filters step by step. The pattern is: start with the complete list and narrow it down according to whatever filters the client sends.
Filtering by genre and availability
from typing import Optional
@app.get("/books")
def list_books(genre: Optional[str] = None, available: Optional[bool] = None):
results = books
if genre:
results = [b for b in results if b["genre"] == genre]
if available is not None:
results = [b for b in results if b["available"] == available]
return results
curl "http://127.0.0.1:8000/books?available=true"
# → 5 available books (ids: 1, 2, 4, 6, 7)
curl "http://127.0.0.1:8000/books?available=false"
# → 3 unavailable books (ids: 3, 5, 8)
curl "http://127.0.0.1:8000/books?genre=Novel&available=true"
# → 2 available novels (ids: 2, 7)
One important detail: we use if available is not None instead of if available. Why? Because False is a valid value we want to process. If you used if available, then when the client sends ?available=false, Python would evaluate if False → never enter the block → never filter. Always use is not None for parameters that can be False or 0.
Adding a year-range filter
Add min_year and max_year to the same endpoint:
@app.get("/books")
def list_books(
genre: Optional[str] = None,
available: Optional[bool] = None,
min_year: Optional[int] = None,
max_year: Optional[int] = None,
):
results = books
if genre:
results = [b for b in results if b["genre"] == genre]
if available is not None:
results = [b for b in results if b["available"] == available]
if min_year is not None:
results = [b for b in results if b["year"] >= min_year]
if max_year is not None:
results = [b for b in results if b["year"] <= max_year]
return results
curl "http://127.0.0.1:8000/books?min_year=1960&max_year=1970"
# → [One Hundred Years of Solitude (1967), Hopscotch (1963), The Time of the Hero (1963)]
curl "http://127.0.0.1:8000/books?genre=Magical%20realism&min_year=1960"
# → [One Hundred Years of Solitude (1967), The House of the Spirits (1982)]
Each filter narrows the previous set. If none is sent, everything comes back.
Pagination with skip and limit
Once your collection grows, returning it whole isn't viable. Pagination with skip and limit is the standard pattern.
@app.get("/books")
def list_books(skip: int = 0, limit: int = 10):
return books[skip : skip + limit]
skip defines how many records to jump over. limit defines how many to return. The defaults (0 and 10) mean: "start from the beginning, return at most 10."
curl "http://127.0.0.1:8000/books?skip=0&limit=3"
# → [One Hundred Years of Solitude, Don Quixote, Hopscotch]
curl "http://127.0.0.1:8000/books?skip=3&limit=3"
# → [The House of the Spirits, Fictions, Pedro Páramo]
curl "http://127.0.0.1:8000/books?skip=6&limit=3"
# → [The Time of the Hero, The Tunnel] (only 2 left)
Python handles out-of-range slicing without errors — books[100:110] returns [].
Pagination with metadata
In real APIs it's useful to return pagination info alongside the results:
@app.get("/books")
def list_books(skip: int = 0, limit: int = 10):
paginated = books[skip : skip + limit]
return {
"total": len(books),
"skip": skip,
"limit": limit,
"count": len(paginated),
"data": paginated,
}
curl "http://127.0.0.1:8000/books?skip=0&limit=3"
# → {"total": 8, "skip": 0, "limit": 3, "count": 3, "data": [the first 3 books]}
The client knows there are 8 in total, it received 3, and it can work out that 5 are still pending.
Text search
Another classic use case: searching resources by free text.
@app.get("/books")
def list_books(search: Optional[str] = None):
if search:
term = search.lower()
results = [
b for b in books
if term in b["title"].lower() or term in b["author"].lower()
]
return results
return books
.lower() on both sides makes the search case-insensitive.
curl "http://127.0.0.1:8000/books?search=the"
# → [The House of the Spirits, The Time of the Hero, The Tunnel]
curl "http://127.0.0.1:8000/books?search=borges"
# → [Fictions] (matched by author)
Boolean query parameters
FastAPI converts strings to bool automatically. These values become True:
true, True, 1, yes, on
And these become False:
false, False, 0, no, off
All of these requests are equivalent and return the 5 available books:
curl "http://127.0.0.1:8000/books?available=true"
curl "http://127.0.0.1:8000/books?available=1"
curl "http://127.0.0.1:8000/books?available=yes"
What happens with an invalid value?
curl "http://127.0.0.1:8000/books?available=maybe"
Expected output:
{
"detail": [
{"type": "bool_parsing", "loc": ["query", "available"], "msg": "Input should be a valid boolean, unable to interpret input", "input": "maybe"}
]
}
Error 422. FastAPI validates the type automatically.
Comparison: required vs optional
| Aspect | Required (genre: str) | Optional (genre: str = None) |
|---|---|---|
| Without the parameter | Error 422 | Returns all results |
| Use case | A parameter the operation can't run without | A filter or modifier |
In /docs | Shows up as "required" | Shows up as "optional" |
| Default value | Has none | None, string, int, bool |
| Typical example | GET /convert?from=USD&to=EUR | GET /books?genre=Novel |
Required — when the endpoint makes no sense without the parameter:
@app.get("/convert")
def convert_currency(amount: float, from_currency: str, to_currency: str):
...
Optional — when the endpoint works with or without the parameter:
@app.get("/books")
def list_books(genre: Optional[str] = None):
...
The general rule for listings: filters should be optional. A GET /books with no parameters should return books.
Combining all the filters
Using the same books list from the start of the capsule and the same imports, here's the final endpoint that pulls everything together:
@app.get("/books")
def list_books(
genre: Optional[str] = None,
available: Optional[bool] = None,
min_year: Optional[int] = None,
max_year: Optional[int] = None,
search: Optional[str] = None,
skip: int = 0,
limit: int = 10,
):
results = books
if genre:
results = [b for b in results if b["genre"] == genre]
if available is not None:
results = [b for b in results if b["available"] == available]
if min_year is not None:
results = [b for b in results if b["year"] >= min_year]
if max_year is not None:
results = [b for b in results if b["year"] <= max_year]
if search:
term = search.lower()
results = [
b for b in results
if term in b["title"].lower() or term in b["author"].lower()
]
total_filtered = len(results)
results = results[skip : skip + limit]
return {
"total": total_filtered,
"skip": skip,
"limit": limit,
"count": len(results),
"data": results,
}
Order matters: filter first, paginate last. That way total reflects the real count of filtered results.
curl http://127.0.0.1:8000/books
# → {"total": 8, "skip": 0, "limit": 10, "count": 8, "data": [...]}
curl "http://127.0.0.1:8000/books?genre=Novel&available=true"
# → {"total": 2, ..., "data": [Don Quixote, The Time of the Hero]}
curl "http://127.0.0.1:8000/books?genre=Magical%20realism&min_year=1960&skip=0&limit=2"
# → {"total": 2, ..., "data": [One Hundred Years of Solitude, The House of the Spirits]}
curl "http://127.0.0.1:8000/books?search=cortázar"
# → {"total": 1, ..., "data": [Hopscotch]}
Open http://127.0.0.1:8000/docs and you'll see every parameter listed with its type and default value.
Connection to the project
The query parameters from this capsule turn static endpoints into dynamic ones. In Module 6 (the CRUD API), you'll apply this same pattern: GET /tasks?status=completed&search=deploy&skip=0&limit=20. Sequential filtering scales — in production, with a database, each if becomes a SQL WHERE clause, but the logic is the same.
Troubleshooting
Problem 1: The genre filter returns no results
Cause: The query parameter value doesn't match the data exactly. Strings are case-sensitive.
Solution:
# ❌ The client sends "novel" but the data says "Novel"
# → []
# ✅ Compare case-insensitively
if genre:
results = [b for b in results if b["genre"].lower() == genre.lower()]
Problem 2: if available doesn't filter when it's False
Cause: Python evaluates if False as falsy, so the block never runs.
Solution:
# ❌ Doesn't work for available=false
if available:
results = [b for b in results if b["available"] == available]
# ✅ Check explicitly against None
if available is not None:
results = [b for b in results if b["available"] == available]
Problem 3: The filters don't combine — each one overwrites the previous
Cause: The filters are applied to books (the original list) instead of results.
Solution:
# ❌ Each filter is applied to the original list
if genre:
results = [b for b in books if b["genre"] == genre]
if available is not None:
results = [b for b in books if b["available"] == available]
# ✅ Each filter narrows the previous result
results = books
if genre:
results = [b for b in results if b["genre"] == genre]
if available is not None:
results = [b for b in results if b["available"] == available]
Problem 4: Pagination doesn't reflect the filters
Cause: The slice is applied before filtering, or on the original list.
Solution:
# ❌ Paginates before filtering
results = books[skip : skip + limit]
if genre:
results = [b for b in results if b["genre"] == genre]
# ✅ Filter first, paginate last
results = books
if genre:
results = [b for b in results if b["genre"] == genre]
total_filtered = len(results)
results = results[skip : skip + limit]
Problem 5: A negative skip gives unexpected results
Cause: Python allows slicing with negative numbers (books[-3:]), producing confusing results.
Solution: Validate it by hand (in the next capsule you'll learn Query(ge=0) for exactly this):
skip = max(skip, 0)
limit = max(min(limit, 100), 1)
return results[skip : skip + limit]
Exercises
Exercise 1: Filter movies by director (Easy)
Build a movies API with at least 6 movies (id, title, director, year, genre). Implement GET /movies with an optional director query parameter that filters by director.
See solution
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
movies = [
{"id": 1, "title": "Pan's Labyrinth", "director": "Guillermo del Toro", "year": 2006, "genre": "Fantasy"},
{"id": 2, "title": "Roma", "director": "Alfonso Cuarón", "year": 2018, "genre": "Drama"},
{"id": 3, "title": "The Shape of Water", "director": "Guillermo del Toro", "year": 2017, "genre": "Fantasy"},
{"id": 4, "title": "Amores Perros", "director": "Alejandro González Iñárritu", "year": 2000, "genre": "Drama"},
{"id": 5, "title": "Y Tu Mamá También", "director": "Alfonso Cuarón", "year": 2001, "genre": "Drama"},
{"id": 6, "title": "Birdman", "director": "Alejandro González Iñárritu", "year": 2014, "genre": "Comedy-drama"},
]
@app.get("/movies")
def list_movies(director: Optional[str] = None):
results = movies
if director:
results = [m for m in results if m["director"] == director]
return results
curl "http://127.0.0.1:8000/movies?director=Guillermo%20del%20Toro"
# → [Pan's Labyrinth, The Shape of Water]
Exercise 2: Pagination with metadata (Easy)
Using the books list from this capsule, implement GET /books with pagination (skip and limit) that returns an object with total, skip, limit, count, and data. Test it with ?skip=2&limit=3.
See solution
Use the same books list and the same pattern from the "Pagination with metadata" section:
@app.get("/books")
def list_books(skip: int = 0, limit: int = 10):
paginated = books[skip : skip + limit]
return {"total": len(books), "skip": skip, "limit": limit, "count": len(paginated), "data": paginated}
curl "http://127.0.0.1:8000/books?skip=2&limit=3"
# → {"total": 8, "skip": 2, "limit": 3, "count": 3, "data": [Hopscotch, The House..., Fictions]}
Exercise 3: Products with multiple filters (Medium)
Build a products API (id, name, category, price, in_stock) with 8 products across 3 categories. Implement GET /products with search (case-insensitive on the name), category, in_stock (boolean), min_price, and max_price.
See solution
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
products = [
{"id": 1, "name": "Laptop Pro 15", "category": "Electronics", "price": 1299.99, "in_stock": True},
{"id": 2, "name": "Wireless Mouse", "category": "Electronics", "price": 29.99, "in_stock": True},
{"id": 3, "name": "Mechanical Keyboard", "category": "Electronics", "price": 89.99, "in_stock": False},
{"id": 4, "name": "Python Crash Course", "category": "Books", "price": 35.00, "in_stock": True},
{"id": 5, "name": "Clean Code", "category": "Books", "price": 40.00, "in_stock": True},
{"id": 6, "name": "Developer T-Shirt", "category": "Clothing", "price": 25.00, "in_stock": False},
{"id": 7, "name": "Python Hoodie", "category": "Clothing", "price": 55.00, "in_stock": True},
{"id": 8, "name": "Monitor 4K", "category": "Electronics", "price": 499.99, "in_stock": True},
]
@app.get("/products")
def list_products(
search: Optional[str] = None,
category: Optional[str] = None,
in_stock: Optional[bool] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
):
results = products
if search:
results = [p for p in results if search.lower() in p["name"].lower()]
if category:
results = [p for p in results if p["category"] == category]
if in_stock is not None:
results = [p for p in results if p["in_stock"] == in_stock]
if min_price is not None:
results = [p for p in results if p["price"] >= min_price]
if max_price is not None:
results = [p for p in results if p["price"] <= max_price]
return results
curl "http://127.0.0.1:8000/products?category=Electronics&in_stock=true"
# → [Laptop Pro 15, Wireless Mouse, Monitor 4K]
curl "http://127.0.0.1:8000/products?search=python"
# → [Python Crash Course, Python Hoodie]
curl "http://127.0.0.1:8000/products?min_price=30&max_price=100"
# → [Mechanical Keyboard, Python Crash Course, Clean Code, Python Hoodie]
Exercise 4: A complete API with filters and pagination (Hard)
Build a students API (id, name, email, grade, active) with 10 students across grades "A", "B", "C". Implement GET /students with: grade (filter), active (boolean), search (name or email), skip, and limit (pagination with metadata).
See solution
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
students = [
{"id": 1, "name": "Ana García", "email": "ana@school.com", "grade": "A", "active": True},
{"id": 2, "name": "Carlos López", "email": "carlos@school.com", "grade": "B", "active": True},
{"id": 3, "name": "María Rodríguez", "email": "maria@school.com", "grade": "A", "active": False},
{"id": 4, "name": "Pedro Martínez", "email": "pedro@school.com", "grade": "C", "active": True},
{"id": 5, "name": "Laura Fernández", "email": "laura@school.com", "grade": "B", "active": True},
{"id": 6, "name": "Diego Morales", "email": "diego@school.com", "grade": "A", "active": True},
{"id": 7, "name": "Sofía Ruiz", "email": "sofia@school.com", "grade": "C", "active": False},
{"id": 8, "name": "Andrés Torres", "email": "andres@school.com", "grade": "B", "active": True},
{"id": 9, "name": "Valentina Cruz", "email": "valentina@school.com", "grade": "A", "active": True},
{"id": 10, "name": "Javier Díaz", "email": "javier@school.com", "grade": "C", "active": False},
]
@app.get("/students")
def list_students(
grade: Optional[str] = None,
active: Optional[bool] = None,
search: Optional[str] = None,
skip: int = 0,
limit: int = 10,
):
results = students
if grade:
results = [s for s in results if s["grade"] == grade]
if active is not None:
results = [s for s in results if s["active"] == active]
if search:
term = search.lower()
results = [s for s in results if term in s["name"].lower() or term in s["email"].lower()]
total = len(results)
results = results[skip : skip + limit]
return {"total": total, "skip": skip, "limit": limit, "count": len(results), "data": results}
curl "http://127.0.0.1:8000/students?grade=A&active=true"
# → {"total": 3, ..., "data": [Ana, Diego, Valentina]}
curl "http://127.0.0.1:8000/students?active=true&skip=0&limit=3"
# → {"total": 7, "skip": 0, "limit": 3, "count": 3, "data": [Ana, Carlos, Pedro]}
Exercise 5: Debugging query parameter bugs (Hard)
The following code has 3 bugs. Find them, explain what breaks, and fix them.
from fastapi import FastAPI
app = FastAPI()
tasks = [
{"id": 1, "title": "Study Python", "completed": False, "priority": 3},
{"id": 2, "title": "Exercise", "completed": True, "priority": 1},
{"id": 3, "title": "Read documentation", "completed": False, "priority": 2},
{"id": 4, "title": "Practice FastAPI", "completed": True, "priority": 3},
{"id": 5, "title": "Review code", "completed": False, "priority": 1},
]
@app.get("/tasks")
def list_tasks(completed: bool = None, min_priority: int = None, search: str = None):
results = tasks
if completed:
results = [t for t in results if t["completed"] == completed]
if min_priority:
results = [t for t in results if t["priority"] >= min_priority]
if search:
results = [t for t in tasks if search.lower() in t["title"].lower()]
return results
See solution
Bug 1: if completed doesn't work for completed=false. Python evaluates if False → no filtering. Fix: if completed is not None:
Bug 2: if min_priority doesn't work for min_priority=0. Python evaluates if 0 → no filtering. Fix: if min_priority is not None:
Bug 3: The search filter is applied to tasks (the original) instead of results, throwing away the previous filters. Fix: [t for t in results if ...]
Corrected code:
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
# ... (same tasks list as in the exercise) ...
@app.get("/tasks")
def list_tasks(
completed: Optional[bool] = None,
min_priority: Optional[int] = None,
search: Optional[str] = None,
):
results = tasks
if completed is not None:
results = [t for t in results if t["completed"] == completed]
if min_priority is not None:
results = [t for t in results if t["priority"] >= min_priority]
if search:
results = [t for t in results if search.lower() in t["title"].lower()]
return results
curl "http://127.0.0.1:8000/tasks?completed=false"
# → [Study Python, Read documentation, Review code]
curl "http://127.0.0.1:8000/tasks?completed=false&search=python"
# → [Study Python]
Summary
- Query parameters are the
?key=valuepairs in the URL — FastAPI detects them when a function parameter isn't in the route - Required vs optional: no default value → required (error 422). With
= None→ optional Optional[str]fromtypingis equivalent tostr | None— more explicit for type checking- Sequential filtering: start with the complete list, and each
ifnarrowsresults. Never filter on the original list is not Noneis mandatory forboolandint—if availablebreaks when the value isFalseor0- Pagination with
skipandlimituses slicing:results[skip : skip + limit] - Search with
.lower()on both sides is case-insensitive - Boolean conversion: FastAPI accepts
true/false,1/0,yes/no,on/off - Order of operations: filter first, paginate last — that way
totalreflects the filtered set
Next capsule: Query() and Path() — You'll learn to add validation, documentation, and constraints to your parameters using FastAPI's Query() and Path() functions.
Additional resources
- FastAPI - Query Parameters - Official documentation on query parameters
- FastAPI - Query Parameters and String Validations - Validations with Query() (a preview of the next capsule)
- MDN - Query String - The specification for query strings in URLs
- Python - typing.Optional - Documentation for Optional in Python
- REST API Design - Filtering - Filtering, sorting, and pagination patterns in REST APIs
- FastAPI - Body + Path + Query Parameters - How to combine path params, query params, and body