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

Sponsored

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:

  1. Define the item schema — a Pydantic model that represents a single item (e.g., UserOut).
  2. Create a generic pagination envelope — a Pydantic Generic model that holds items, total, page, size, pages, and optional links.
  3. Use the envelope in your endpoint — set response_model=Page[UserOut] and return a properly constructed envelope.
  4. Implement the pagination logic — query your database with limit and offset, and calculate total and pages.
  5. Optionally build links — generate absolute URLs for self, next, and prev.

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 page and size with Query(ge=1) and a le cap for size to prevent abuse like size=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 meta key, like {"data": [...], "meta": {"page": 1, "total": 100}}. This keeps items separate from metadata, but requires clients to dig deeper.
  • Link headers: Use the HTTP Link header (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 UserPage schema, but the generic Page[T] is cleaner and DRY.

Troubleshooting & Edge Cases

  • Empty result set: When total = 0, pages should be 0. Your code (total + size - 1) // size gives 0, which is fine. But you might want pages to be at least 1 for the current page? Decide: if page 1 exists, pages can be 1 even with zero items. Clarify in docs.
  • Page beyond range: If page is greater than pages, what should you return? Options: return an empty items list with pages and total, 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 with response_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 use math.ceil unless you convert to float first—it's easier to get wrong.

Common mistakes:

  • Forgetting to validate page and size parameters, allowing negative or zero values.
  • Returning a bare list instead of the envelope when pagination is requested, breaking the client contract.
  • Hardcoding size instead of letting the client control it.
  • Not calculating pages correctly, 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 page and size with ge=1 and le (max) — allowing size=0 or huge values that degrade performance.
  • Using math.ceil incorrectly for pages calculation, leading to off-by-one errors; use (total + size - 1) // size.
  • Forgetting to set response_model on 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

  1. Cursor-based pagination: replace page/size with a cursor (e.g., id>last_seen) for large, frequently updated datasets.
  2. Nested metadata envelope: wrap items in data and put pagination info in a meta object, separating data from metadata.
  3. Use HTTP Link headers (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 page and size query parameters with constraints to prevent abuse.
  • Compute pages with 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.