Pagination Schemas — Standardising List and Page Responses

Every API that returns lists needs pagination — returning all records at once is impractical and dangerous for large datasets. Consistent pagination schemas make your API predictable: every list endpoint returns the same envelope structure with total count, page info, and the items array. Using Pydantic generics, you can define this envelope once as Page[T] and reuse it for every resource — Page[PostResponse], Page[UserResponse], Page[CommentResponse] — with no duplication. FastAPI’s response_model works seamlessly with generic Pydantic models.

Generic Page Response

from pydantic import BaseModel
from typing import TypeVar, Generic

T = TypeVar("T")

class Page(BaseModel, Generic[T]):
    """Generic paginated response wrapper."""
    items:      list[T]
    total:      int         # total matching records (for UI page count)
    page:       int         # current page number
    page_size:  int         # items per page
    pages:      int         # total number of pages

    @classmethod
    def create(cls, items: list, total: int, page: int, page_size: int) -> "Page[T]":
        return cls(
            items     = items,
            total     = total,
            page      = page,
            page_size = page_size,
            pages     = (total + page_size - 1) // page_size,  # ceiling division
        )

# ── Usage in route handlers ────────────────────────────────────────────────────
from fastapi import FastAPI, Depends, Query
from typing import Annotated

app = FastAPI()

@app.get("/posts", response_model=Page[PostResponse])
def list_posts(
    page:      Annotated[int, Query(ge=1)] = 1,
    page_size: Annotated[int, Query(ge=1, le=100)] = 10,
    db = Depends(get_db)
):
    offset = (page - 1) * page_size
    total  = db.query(Post).filter(Post.status == "published").count()
    posts  = (
        db.query(Post)
        .options(joinedload(Post.author))
        .filter(Post.status == "published")
        .order_by(Post.created_at.desc())
        .offset(offset)
        .limit(page_size)
        .all()
    )
    return Page.create(items=posts, total=total, page=page, page_size=page_size)
Note: FastAPI’s response_model=Page[PostResponse] works with generic Pydantic models in Python 3.9+. FastAPI resolves the generic type at runtime and uses it for both response validation and OpenAPI schema generation — the Swagger UI will show the correct schema for items as an array of PostResponse objects. Always specify the concrete type in response_model rather than the unparameterised Page to get correct documentation.
Tip: Consider returning a next_cursor field for large datasets instead of (or in addition to) page/offset. Cursor-based pagination is more stable — inserting or deleting rows between page requests does not cause duplicates or skipped items as it does with OFFSET pagination. For the blog application, a cursor based on created_at and id works well: next_cursor = f"{last_item.created_at.isoformat()},{last_item.id}". The client passes this as a query parameter to get the next page.
Warning: Running a separate COUNT(*) query for every paginated list request adds database overhead — two round-trips per request. For very high-traffic endpoints, consider caching the count (with a short TTL), or using an approximate count from PostgreSQL’s pg_class.reltuples. For most applications, the COUNT query is fast enough (milliseconds on indexed queries) and the accuracy is worth the overhead.

Cursor-Based Pagination

from pydantic import BaseModel
from typing import Generic, TypeVar

T = TypeVar("T")

class CursorPage(BaseModel, Generic[T]):
    items:       list[T]
    next_cursor: str | None = None   # None means no more pages
    has_more:    bool

@app.get("/posts/feed", response_model=CursorPage[PostResponse])
def get_feed(
    cursor:    str | None = None,   # "{created_at},{id}" from previous response
    page_size: Annotated[int, Query(ge=1, le=50)] = 20,
    db = Depends(get_db)
):
    query = db.query(Post).filter(Post.status == "published")

    if cursor:
        # Parse cursor: "2025-08-06T14:30:00,42"
        cursor_date, cursor_id = cursor.rsplit(",", 1)
        from datetime import datetime
        query = query.filter(
            (Post.created_at < datetime.fromisoformat(cursor_date)) |
            ((Post.created_at == datetime.fromisoformat(cursor_date)) &
             (Post.id < int(cursor_id)))
        )

    posts = (
        query
        .order_by(Post.created_at.desc(), Post.id.desc())
        .limit(page_size + 1)   # fetch one extra to check has_more
        .all()
    )

    has_more = len(posts) > page_size
    items    = posts[:page_size]
    next_cursor = None
    if has_more and items:
        last = items[-1]
        next_cursor = f"{last.created_at.isoformat()},{last.id}"

    return CursorPage(items=items, next_cursor=next_cursor, has_more=has_more)

Common Mistakes

Mistake 1 — Not including total in the response (broken pagination UI)

❌ Wrong — client cannot compute page count:

return {"items": posts, "page": page}   # no total → client cannot show "Page 2 of 15"

✅ Correct — always include total:

return Page.create(items=posts, total=total, page=page, page_size=page_size)

Mistake 2 — Inconsistent pagination envelope across endpoints

❌ Wrong — each endpoint has a different wrapper format:

GET /posts → {"data": [...], "count": 100}
GET /users → {"results": [...], "total_count": 50}   # different keys!

✅ Correct — one generic Page[T] schema used everywhere.

Mistake 3 — Fetching more data than the page size for the count

❌ Wrong — fetching all rows just to count:

all_posts = db.query(Post).all()   # loads 1M rows!
total = len(all_posts)
items = all_posts[(page-1)*size : page*size]

✅ Correct — separate COUNT query and LIMIT query:

total = db.query(Post).count()   # COUNT(*) — fast
items = db.query(Post).offset(offset).limit(size).all()   # ✓

Quick Reference

Pattern Code
Generic page schema class Page(BaseModel, Generic[T]):
Use in response_model response_model=Page[PostResponse]
Create page response Page.create(items, total, page, page_size)
Ceiling division for pages (total + size - 1) // size
Offset from page offset = (page - 1) * page_size
Count query db.query(Model).filter(...).count()
Cursor pagination Fetch page_size+1, check has_more, return cursor

🧠 Test Yourself

A Page[PostResponse] response has total=95 and page_size=10. How many pages are there, and how many items appear on the last page?