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)
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.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.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 |