Paginated Response Schemas
Designing Paginated Response Schemas — FastAPI Backend Development. Learn to structure paginated API responses with Pydantic schemas, including metadata, links, and typing, with hands-on steps and troubleshooting.
Focus: designing paginated response schemas
Your API just returned a giant JSON array with 10,000 items, and the mobile client froze. The database groaned, the network choked, and your users abandoned the page. This is the classic pain of unbounded list endpoints: no way to control response size, no clue how many pages exist, no way to jump to page 5. In this lesson, you'll learn designing paginated response schemas — a structured, typed approach using Pydantic that turns chaos into a clean, predictable API. By the end, you'll be able to return consistent pagination metadata, links, and items, exactly like production-grade APIs do.
The Problem This Lesson Solves
Unpaginated endpoints return every record at once. That's fine for a toy app, but in real backends you'll hit three walls fast:
- Performance: Each request loads more data than needed, slowing your database and API response time.
- Usability: Clients can't navigate through data—they get everything or nothing.
- Predictability: Without a standard shape, every client must parse a different, ad-hoc response format.
For example, imagine an endpoint that returns all users:
@app.get("/users")
def list_users():
return db.query(User).all()
This returns a bare array. It works, but it's a dead end. Now imagine you need to add pagination later: every client that relied on the array format breaks. Designing paginated response schemas upfront solves this by defining a contract that stays stable even as the underlying data grows.
Core Concept / Mental Model
Think of a paginated response as an envelope that carries three things: the items for the current page, metadata about the pagination state, and links to navigate pages. The envelope doesn't change—only the contents do. This is like a postal envelope: the envelope has a fixed layout (sender, recipient, stamp), and the letter inside changes.
In FastAPI, the envelope is a Pydantic response model. It wraps your list of items in a predictable structure. Here's the mental model in words:
PaginatedResponse
├── items: list[Item] # the actual data for this page
├── total: int # total number of items across all pages
├── page: int # current page number (1-based)
├── size: int # number of items per page
├── pages: int # total number of pages
└── links: {} # optional self/next/prev URLs
This structure is versionable, documentable, and type-safe. The items list is generic over the item type, meaning you can reuse the same envelope for users, posts, orders, anything.
The key definitions:
- Page – a slice of the full dataset, identified by a number (usually starting at 1).
- Page size – the maximum number of items per page.
- Total – the total number of items across all pages; client can calculate if more pages exist.
- Metadata – extra info like total pages, current page, and size.
- Links – optional navigation URLs (self, next, prev) to make client code simpler.
This layout is industry-standard, used by GitHub, Stripe, and many others. It's not the only option (we'll compare later), but it's the sweet spot for most FastAPI apps.
How It Works Step by Step
Designing paginated response schemas in FastAPI breaks down into five repeatable steps:
- Define the item schema — a Pydantic model that represents a single item (e.g.,
UserOut). - Create a generic pagination envelope — a Pydantic Generic model that holds
items,total,page,size,pages, and optionallinks. - Use the envelope in your endpoint — set
response_model=Page[UserOut]and return a properly constructed envelope. - Implement the pagination logic — query your database with
limitandoffset, and calculate total and pages. - Optionally build links — generate absolute URLs for
self,next, andprev.
The cause-and-effect: your endpoint receives page and size query parameters → it queries only that slice → it wraps the slice and computed totals into the envelope → FastAPI validates and serializes the envelope → the client gets a predictable, self-describing response.
Here's a minimal but complete FastAPI app that does pagination without a database, using an in-memory list:
from typing import Generic, List, Optional, TypeVar
from fastapi import FastAPI, Query
from pydantic import BaseModel, Field
app = FastAPI()
# 1. Item schema
class Item(BaseModel):
id: int
name: str
# 2. Generic pagination envelope
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: List[T]
total: int = Field(description="Total number of items")
page: int = Field(ge=1, description="Current page (1-based)")
size: int = Field(ge=1, description="Items per page")
pages: int = Field(description="Total number of pages")
# 3. Fake data
ALL_ITEMS = [Item(id=i, name=f"Item {i}") for i in range(1, 101)]
@app.get("/items", response_model=Page[Item])
def list_items(
page: int = Query(1, ge=1, description="Page number"),
size: int = Query(10, ge=1, le=100, description="Items per page"),
):
total = len(ALL_ITEMS)
pages = (total + size - 1) // size # ceil division
start = (page - 1) * size
items = ALL_ITEMS[start:start + size]
return Page[Item](
items=items,
total=total,
page=page,
size=size,
pages=pages,
)
Run this with uvicorn main:app --reload and visit http://localhost:8000/items?page=2&size=5. The response will be:
{
"items": [
{"id": 6, "name": "Item 6"},
{"id": 7, "name": "Item 7"},
{"id": 8, "name": "Item 8"},
{"id": 9, "name": "Item 9"},
{"id": 10, "name": "Item 10"}
],
"total": 100,
"page": 2,
"size": 5,
"pages": 20
}
Notice how the envelope gives the client everything it needs to render a pagination UI without extra calls.
To expand this envelope with links, add an optional links field:
from pydantic import BaseModel
class PageLinks(BaseModel):
self: str
next: Optional[str] = None
prev: Optional[str] = None
class Page(BaseModel, Generic[T]):
# ... same fields ...
links: Optional[PageLinks] = None
In your endpoint, build the links using Request:
from fastapi import Request
def make_links(request: Request, page: int, size: int, pages: int) -> PageLinks:
base = str(request.base_url).rstrip("/")
def url(p: int) -> str:
return f"{base}/items?page={p}&size={size}"
return PageLinks(
self=url(page),
next=url(page + 1) if page < pages else None,
prev=url(page - 1) if page > 1 else None,
)
Now your response includes navigation links, which makes client-side pagination trivial—no need to construct URLs manually.
Hands-On Walkthrough
Let's build a real example with SQLAlchemy. This will solidify the pattern end-to-end.
First, install dependencies if you haven't:
pip install fastapi uvicorn sqlalchemy
Create a models.py file with a User model:
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
Now in main.py, wire up the database and the paginated endpoint:
from typing import List, Generic, TypeVar, Optional
from fastapi import FastAPI, Depends, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy import create_engine
from models import Base, User
DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(DATABASE_URL)
Base.metadata.create_all(bind=engine)
SessionLocal = sessionmaker(bind=engine)
app = FastAPI()
# Pydantic schemas
class UserOut(BaseModel):
id: int
name: str
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: List[T]
total: int
page: int
size: int
pages: int
# Dependency to get a DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/users", response_model=Page[UserOut])
def list_users(
page: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
total = db.query(User).count()
pages = (total + size - 1) // size
users = db.query(User).offset((page - 1) * size).limit(size).all()
return Page[UserOut](
items=[UserOut(id=u.id, name=u.name) for u in users],
total=total,
page=page,
size=size,
pages=pages,
)
Seed some test data (run once in a script or at startup) and then hit the endpoint:
with SessionLocal() as db:
for i in range(50):
db.add(User(name=f"User {i}"))
db.commit()
When you call GET /users?page=2&size=10, you'll get a response like:
{
"items": [
{"id": 11, "name": "User 11"},
...
],
"total": 50,
"page": 2,
"size": 10,
"pages": 5
}
Expected output confirms the envelope is consistent regardless of which page you request.
Pro tip: Always validate
pageandsizewithQuery(ge=1)and alecap forsizeto prevent abuse likesize=100000. FastAPI will automatically return a 422 with clear validation errors.
Compare Options / When to Choose What
There are several pagination strategies. Here's how they stack up:
| Strategy | Description | Pros | Cons |
|---|---|---|---|
| Offset-based (page/size) | Slice by offset = (page-1)*size |
Simple, allows random page jumps | Slow on large offsets; inconsistent if data changes |
| Cursor-based | Use a unique key (e.g., id > last_seen_id) |
Fast, stable with inserts | No random access; client must track cursor |
| Keyset (seek) | Similar to cursor but based on sort key | High performance on large datasets | Complex to implement generically |
When to choose what:
- Offset-based — perfect for admin panels, small to medium datasets, and when users expect to click page numbers.
- Cursor-based — ideal for infinite scroll feeds (social media, activity logs) where consistency matters more than jumping to a specific page.
For most FastAPI CRUD APIs, the offset-based envelope we've built is the best starting point. If you later hit performance limits, you can switch to cursor-based but keep the same response envelope by adding a cursor field instead of page.
Variations you might encounter:
- Nested metadata object: Some APIs put pagination info under a
metakey, like{"data": [...], "meta": {"page": 1, "total": 100}}. This keeps items separate from metadata, but requires clients to dig deeper. - Link headers: Use the HTTP
Linkheader (RFC 8288) for pagination instead of embedding URLs in the body. Good for API purity, but harder for clients to consume. - Generic vs. concrete: You can define a concrete
UserPageschema, but the genericPage[T]is cleaner and DRY.
Troubleshooting & Edge Cases
- Empty result set: When
total = 0,pagesshould be 0. Your code(total + size - 1) // sizegives 0, which is fine. But you might wantpagesto be at least 1 for the current page? Decide: if page 1 exists,pagescan be 1 even with zero items. Clarify in docs. - Page beyond range: If
pageis greater thanpages, what should you return? Options: return an empty items list withpagesandtotal, or raise 404. Choose one and document it. Typically return an empty list—it's more forgiving. - FastAPI serialization errors: If you forget to set
response_model, FastAPI will return the raw dict—no validation. Always annotate your endpoint withresponse_model=Page[Item]. - SQLAlchemy lazy loading: When you return ORM objects directly, Pydantic might try to access relationships that aren't loaded, causing errors. Solution: convert to Pydantic schemas explicitly, as shown above.
- Count query performance:
db.query(User).count()can be slow on huge tables. Consider caching the total or using an approximate count for very large datasets. - Integer division: In Python 3,
//is floor division. For ceil division, use the formula(total + size - 1) // size. Don't usemath.ceilunless you convert to float first—it's easier to get wrong.
Common mistakes:
- Forgetting to validate
pageandsizeparameters, allowing negative or zero values. - Returning a bare list instead of the envelope when pagination is requested, breaking the client contract.
- Hardcoding
sizeinstead of letting the client control it. - Not calculating
pagescorrectly, especially when total is not a multiple of size.
What You Learned & What's Next
You've mastered the core of designing paginated response schemas. Now you can:
- Explain the envelope pattern and why it matters for API consistency.
- Build a generic Pydantic
Page[T]model and use it in any endpoint. - Implement offset-based pagination with SQLAlchemy, complete with total and pages.
- Decide when to use offset vs. cursor pagination.
- Troubleshoot common edge cases like empty results and page out of range.
Every key point from the lesson is now part of your toolkit: understanding the envelope, applying it in a hands-on exercise, and connecting it to the broader FastAPI track.
Next up, you'll move to the next lesson in the track: Filtering and Sorting Query Parameters. That lesson will build on this envelope, adding query parameters like q, sort_by, and order to make your list endpoints truly production-ready. You'll see how the same envelope pattern extends seamlessly.
Keep this pagination schema in your back pocket—you'll reuse it in nearly every resource in your API. Happy coding!
Practice recap
Try extending the paginated endpoint from the lesson by adding a total_pages link (self/next/prev) using Request to build absolute URLs. Then test with different page and size values, including edge cases like page=0 or size=0, and confirm FastAPI returns 422 validation errors. This will reinforce the envelope pattern and error handling.
Common mistakes
- Returning a bare list instead of the envelope when pagination is implemented, breaking the client's expected format.
- Not validating
pageandsizewithge=1andle(max) — allowingsize=0or huge values that degrade performance. - Using
math.ceilincorrectly forpagescalculation, leading to off-by-one errors; use(total + size - 1) // size. - Forgetting to set
response_modelon the endpoint, so FastAPI doesn't validate the paginated structure. - Returning ORM objects directly without converting to Pydantic schemas, causing lazy-loading errors in serialization.
Variations
- Cursor-based pagination: replace
page/sizewith acursor(e.g.,id>last_seen) for large, frequently updated datasets. - Nested metadata envelope: wrap items in
dataand put pagination info in ametaobject, separating data from metadata. - Use HTTP
Linkheaders (RFC 8288) for pagination instead of embedding URLs in the body for a more RESTful style.
Real-world use cases
- A REST API for a blog returning paginated posts with metadata so the frontend can render page numbers.
- An e-commerce backend listing products in pages of 24, letting the client jump to any page.
- An admin dashboard displaying user logs in a paginated table with total counts and page navigation.
Key takeaways
- Paginated responses should be wrapped in a stable envelope containing items, total, page, size, and pages.
- Use a generic Pydantic model
Page[T]to reuse pagination across different resource types. - Always validate
pageandsizequery parameters with constraints to prevent abuse. - Compute
pageswith integer ceiling division:(total + size - 1) // size. - Convert ORM objects to Pydantic schemas before returning to avoid lazy-loading issues.
- Offset-based pagination is best for small/medium datasets; switch to cursor-based for huge, dynamic ones.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.