FastAPI Pagination and Filtering
An endpoint that returns ten thousand records wastes bandwidth, slows the server, and freezes the app that displays the data. Pagination splits a large list into small pages. Filtering narrows the list to the records a user wants. Think of a library catalog: nobody carries every book to the desk. The librarian hands over one shelf at a time.
Skip and Limit Pagination
The simplest style uses two query parameters. skip says how many records to jump over. limit says how many records to return.
from fastapi import FastAPI, Query
app = FastAPI()
products = [{"id": i, "name": f"Product {i}"} for i in range(1, 101)]
@app.get("/products")
def list_products(
skip: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=50),
):
return products[skip : skip + limit]
100 products in total
skip=0, limit=10 ──▶ products 1 to 10
skip=10, limit=10 ──▶ products 11 to 20
skip=20, limit=10 ──▶ products 21 to 30The rule le=50 caps the page size. A client cannot request one million records in a single call.
Page Number Pagination
Many frontends think in pages rather than offsets. Convert the page number into a skip value inside the route.
@app.get("/products/paged")
def paged_products(page: int = Query(1, ge=1), size: int = Query(10, ge=1, le=50)):
start = (page - 1) * size
return products[start : start + size]Page 3 with a size of 10 starts at position 20 and returns products 21 to 30.
Returning Page Details
A bare list leaves the client guessing whether more pages exist. A wrapper object solves that.
@app.get("/products/meta")
def products_with_meta(page: int = 1, size: int = 10):
total = len(products)
start = (page - 1) * size
return {
"total": total,
"page": page,
"size": size,
"pages": (total + size - 1) // size,
"items": products[start : start + size],
}
{
"total": 100,
"page": 2,
"size": 10,
"pages": 10,
"items": [ ...10 products... ]
}Filtering with Query Parameters
Optional parameters act as filters. A filter applies only when the client sends it.
items = [
{"name": "Laptop", "category": "tech", "price": 900},
{"name": "Phone", "category": "tech", "price": 600},
{"name": "Desk", "category": "home", "price": 250},
]
@app.get("/items")
def filter_items(category: str | None = None, max_price: float | None = None):
result = items
if category:
result = [i for i in result if i["category"] == category]
if max_price is not None:
result = [i for i in result if i["price"] <= max_price]
return result
GET /items ──▶ all 3 items
GET /items?category=tech ──▶ Laptop, Phone
GET /items?category=tech&max_price=700 ──▶ PhoneSorting Results
A sort_by parameter lets clients choose the order. Restrict the allowed values to keep the API predictable.
from typing import Literal
@app.get("/items/sorted")
def sorted_items(sort_by: Literal["name", "price"] = "name", desc: bool = False):
return sorted(items, key=lambda i: i[sort_by], reverse=desc)Any value outside the allowed list, such as sort_by=color, produces a 422 error automatically.
Pagination with a Database
Real applications push the work to the database. SQLAlchemy offers offset and limit for exactly this purpose.
stmt = select(Product).offset(skip).limit(limit)
rows = db.scalars(stmt).all()The database returns only the requested slice, so memory use stays low even for millions of rows.
Cursor Pagination in Brief
Offset pagination slows down on huge tables and shifts when new records arrive. Cursor pagination remembers the last record seen and asks for items after it.
GET /products?after_id=40&limit=10 ──▶ products with id 41 to 50Social feeds and activity logs favor this style because pages stay stable while data changes.
Best Practices
- Set a sensible default page size and a hard maximum.
- Sort results consistently so pages never overlap.
- Return the total count when clients display page numbers.
- Validate every filter value with type hints or
Literal.
Putting It All Together
A single route can combine every idea from this topic. The client filters by category, sorts by price, and asks for page two.
GET /items/search?category=tech&sort_by=price&page=2&size=5The route applies the filter first, sorts the filtered list, and slices out the requested page last. That order matters. Slicing before filtering returns pages with missing items, because the filter removes rows after the page already closed.
Document each parameter with a description so the interactive docs explain the options. Developers who consume the API understand the allowed sort fields and size limits without asking anyone.
Key Points
- Pagination splits large lists into small, fast pages.
skipandlimitform the simplest pagination style.- Optional query parameters work as filters.
- Cap the page size with
leto protect the server. - Databases handle slicing with
offsetandlimit.
