Keyset (Cursor) Pagination
This example demonstrates keyset pagination using CursorParams and CursorPage for efficient handling of large datasets.
Why Keyset Pagination?
Offset pagination gets slower as the offset grows:
-- Offset: database must scan 100,000 rows before returning 20
SELECT * FROM events ORDER BY id LIMIT 20 OFFSET 100000;
-- Keyset: uses index, constant performance regardless of position
SELECT * FROM events WHERE id > 100000 ORDER BY id LIMIT 20;
Use keyset pagination for:
Large datasets (>10k rows)
Real-time feeds (new rows inserted frequently)
Infinite scroll UIs
CursorParams and CursorPage
from pypaginate import CursorParams, CursorPage
# First page (no cursor)
params = CursorParams(limit=20)
# Next page (using cursor from previous response)
params = CursorParams(limit=20, after="eyJpZCI6IDEwMH0=")
# Previous page
params = CursorParams(limit=20, before="eyJpZCI6IDgxfQ==")
CursorPage has no total or page – those are offset-only concepts:
# CursorPage fields:
page.items # list[T]
page.limit # int
page.has_next # bool
page.has_previous # bool
page.next_cursor # str | None
page.previous_cursor # str | None
SQLAlchemy Cursor Pagination
Requires pypaginate[sqlalchemy]:
pip install pypaginate[sqlalchemy]
import asyncio
from sqlalchemy import String, select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from pypaginate import CursorParams, paginate
from pypaginate.adapters.sqlalchemy import SQLAlchemyCursorBackend
# -- Models ---------------------------------------------------------------
class Base(DeclarativeBase):
pass
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
def __repr__(self) -> str:
return f"Event(id={self.id}, name={self.name})"
# -- Database setup -------------------------------------------------------
DATABASE_URL = "sqlite+aiosqlite:///./keyset.db"
engine = create_async_engine(DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_session() as session:
result = await session.execute(select(Event).limit(1))
if not result.scalar():
for batch in range(10):
events = [
Event(name=f"Event {batch * 1000 + i}")
for i in range(1000)
]
session.add_all(events)
await session.commit()
print("Created 10,000 events")
# -- Cursor pagination ----------------------------------------------------
async def paginate_forward():
"""Walk forward through events using cursor pagination."""
async with async_session() as session:
# Query MUST have ORDER BY (keyset pagination requirement)
query = select(Event).order_by(Event.id)
backend = SQLAlchemyCursorBackend(session)
params = CursorParams(limit=100)
page_num = 1
while True:
page = await paginate(query, params, backend=backend)
print(f"Page {page_num}: {len(page)} items", end="")
if page.items:
print(f" [{page.items[0]} ... {page.items[-1]}]")
else:
print()
if not page.has_next:
print("Reached end of data")
break
# Use next_cursor to get the next page
params = CursorParams(limit=100, after=page.next_cursor)
page_num += 1
if page_num > 5:
print("(Stopping after 5 pages for demo)")
break
async def bidirectional():
"""Navigate forward and backward."""
async with async_session() as session:
query = select(Event).order_by(Event.id)
backend = SQLAlchemyCursorBackend(session)
# Go forward 3 pages
params = CursorParams(limit=10)
cursors = []
print("=== Forward ===")
for i in range(3):
page = await paginate(query, params, backend=backend)
print(f"Page {i + 1}: {page.items[0]} to {page.items[-1]}")
cursors.append(page.previous_cursor)
if page.has_next:
params = CursorParams(limit=10, after=page.next_cursor)
# Go back one page
print("\n=== Backward ===")
params = CursorParams(limit=10, before=cursors[-1])
page = await paginate(query, params, backend=backend)
print(f"Back to: {page.items[0]} to {page.items[-1]}")
async def main():
await setup()
print("\n=== Cursor Pagination Demo ===\n")
await paginate_forward()
print()
await bidirectional()
if __name__ == "__main__":
asyncio.run(main())
Sync Cursor Pagination
For synchronous sessions:
from sqlalchemy import select
from sqlalchemy.orm import Session
from pypaginate.adapters.sqlalchemy import SyncSQLAlchemyCursorBackend
def list_events(session: Session):
query = select(Event).order_by(Event.id)
backend = SyncSQLAlchemyCursorBackend(session)
# Note: sync cursor uses .fetch_page() directly (no paginate() dispatch)
items, next_cursor, prev_cursor = backend.fetch_page(
query,
limit=20,
after=None,
)
print(f"Items: {len(items)}, next: {next_cursor}")
FastAPI with CursorDep
from fastapi import Depends, FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pypaginate import CursorPage, paginate
from pypaginate.adapters.fastapi import CursorDep
from pypaginate.adapters.sqlalchemy import SQLAlchemyCursorBackend
app = FastAPI()
@app.get("/events")
async def list_events(
params: CursorDep,
session: AsyncSession = Depends(get_session),
) -> CursorPage[dict]:
query = select(Event).order_by(Event.id)
backend = SQLAlchemyCursorBackend(session)
return await paginate(query, params, backend=backend)
Request: GET /events?limit=20&after=eyJpZCI6IDEwMH0=
CursorDep parses limit, after, and before from query parameters automatically.
When to Use Keyset vs Offset
Scenario |
Recommendation |
|---|---|
Small dataset (<10k rows) |
Offset – simpler, supports random page access |
Large dataset (>10k rows) |
Keyset – consistent performance |
Random page access needed |
Offset – keyset only supports next/previous |
Sequential navigation |
Keyset – ideal for this |
Real-time data (insertions) |
Keyset – no skipped/duplicate rows |
Analytics/reporting |
Offset – page numbers useful for UIs |
Best Practices
Always include ORDER BY – keyset pagination requires it
Use indexed columns for the sort key
End with a unique column (e.g.,
id) as the final sort key for deterministic orderingCursors are opaque – clients should not parse or construct them
Set reasonable limits – max 100-500 per page
Next Steps
Basic Pagination – Offset pagination
FastAPI Example – Full app with all features