FastAPI’s pagination isn’t just about slicing data; it’s about managing the state of your entire dataset across requests.
Let’s see it in action. Imagine a simple API endpoint returning a list of users:
from fastapi import FastAPI, Query
from typing import List, Optional
from pydantic import BaseModel
app = FastAPI()
# Dummy data
users_db = [{"id": i, "name": f"User {i}"} for i in range(1, 101)]
class User(BaseModel):
id: int
name: str
@app.get("/users/offset", response_model=List[User])
async def get_users_offset(
skip: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=100)
):
return users_db[skip : skip + limit]
@app.get("/users/cursor", response_model=List[User])
async def get_users_cursor(
cursor: Optional[str] = Query(None),
limit: int = Query(10, ge=1, le=100)
):
if cursor:
try:
current_cursor_id = int(cursor)
except ValueError:
return [] # Invalid cursor
# Find the index of the cursor
try:
start_index = next(i for i, user in enumerate(users_db) if user["id"] == current_cursor_id) + 1
except StopIteration:
return [] # Cursor not found
else:
start_index = 0
end_index = start_index + limit
results = users_db[start_index:end_index]
# Determine the next cursor for the last item in the results
next_cursor = str(results[-1]["id"]) if results else None
# In a real API, you'd return the next_cursor in a header or response body
# For this example, we'll just show how it's generated.
# print(f"Next cursor: {next_cursor}")
return results
This code defines two endpoints: /users/offset for offset-based pagination and /users/cursor for cursor-based.
Offset-based pagination is straightforward. You tell the server where to start (skip) and how many items to return (limit). A request for GET /users/offset?skip=20&limit=5 would fetch users with IDs 21 through 25. The problem arises when data changes between requests. If a user is deleted or added before your skip point, your entire result set shifts. You might get duplicates or miss entire pages.
Cursor-based pagination, on the other hand, uses a unique identifier (the "cursor") from a previous result to tell the server where to resume. Instead of an abstract position like "skip 20," you’re saying "give me the next 5 items after the item with ID 20." If data is added or deleted before the cursor, it doesn’t affect subsequent requests because the cursor points to a specific item, not a numerical position.
To implement cursor-based pagination, you need a stable, sortable identifier for your items. Typically, this is a primary key like an auto-incrementing ID or a timestamp. The server finds the item matching the provided cursor, then starts fetching subsequent items from after that item. The cursor for the next request is then derived from the last item returned in the current response.
The most surprising true thing about cursor-based pagination is that it’s fundamentally about ordering, not just counting. The cursor value is not an arbitrary token; it’s a direct reference to a specific data point within a sorted sequence. Without a consistent, reliable sort order, cursor-based pagination breaks down entirely. This is why you often see it paired with ORDER BY clauses in SQL queries, ensuring that the sequence of items is predictable.
The critical difference between offset and cursor is how they handle data modifications. If you request GET /users/offset?skip=10&limit=5 and then later GET /users/offset?skip=15&limit=5, and in between those requests, an item was inserted at the beginning of the list, your second request might actually return the same items as your first request, just shifted by one. Cursor-based pagination avoids this. If you received an item with ID 10 and your next request is GET /users/cursor?cursor=10&limit=5, it will always fetch the 5 items after item 10, regardless of what happened before it.
The next concept you’ll encounter is how to efficiently represent and pass these cursors, especially when dealing with complex sorting criteria or when the cursor itself needs to be opaque.