Cursor/Keyset Pagination
Keyset pagination (cursor-based) uses opaque cursor tokens instead of page numbers, providing constant-time performance regardless of how deep into the result set you go.
Tip
When to Use Keyset pagination is best for large datasets (100k+ rows), infinite scroll UIs, and real-time data where consistent performance matters.
Overview
Instead of skipping rows with OFFSET, keyset pagination uses the last seen row’s values to seek directly to the next page:
-- Offset (slow on deep pages):
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 100000;
-- Keyset (constant time):
SELECT * FROM users WHERE id > 100000 ORDER BY id LIMIT 20;
CursorParams
from pypaginate import CursorParams
# First page (no cursor)
params = CursorParams(limit=20)
# Next page
params = CursorParams(limit=20, after="cursor_token_from_response")
# Previous page
params = CursorParams(limit=20, before="cursor_token_from_response")
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Items per page (1-1000) |
|
|
|
Forward cursor |
|
|
|
Backward cursor |
after and before are mutually exclusive – you cannot specify both.
CursorPage
page.items # list[T] -- items for this page
page.limit # int -- items per page
page.has_next # bool -- True if next page exists
page.has_previous # bool -- True if previous page exists
page.next_cursor # str | None -- pass as `after` for next page
page.previous_cursor # str | None -- pass as `before` for previous page
No total or page number – these are offset-only concepts.
SQLAlchemy Usage (Async)
Keyset pagination requires an ordered query:
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pypaginate import paginate, CursorParams
from pypaginate.adapters.sqlalchemy import SQLAlchemyCursorBackend
async def list_users(
session: AsyncSession,
cursor: str | None = None,
limit: int = 20,
):
backend = SQLAlchemyCursorBackend(session)
# Query MUST have ORDER BY (keyset pagination requirement)
stmt = select(User).order_by(User.created_at.desc(), User.id.desc())
params = CursorParams(limit=limit, after=cursor)
page = await paginate(stmt, params, backend=backend)
return {
"items": page.items,
"next_cursor": page.next_cursor,
"previous_cursor": page.previous_cursor,
"has_next": page.has_next,
"has_previous": page.has_previous,
}
SQLAlchemy Usage (Sync)
from sqlalchemy import select
from sqlalchemy.orm import Session
from pypaginate import CursorParams
from pypaginate.adapters.sqlalchemy import SyncSQLAlchemyCursorBackend
def list_users(session: Session, cursor: str | None = None, limit: int = 20):
backend = SyncSQLAlchemyCursorBackend(session)
stmt = select(User).order_by(User.created_at.desc(), User.id.desc())
params = CursorParams(limit=limit, after=cursor)
# Sync cursor uses the backend directly (not paginate())
items, next_cursor, prev_cursor = backend.fetch_page(
stmt,
limit=params.limit,
after=params.after,
before=params.before,
)
How Cursors Work
Cursors are opaque tokens that encode the position of the last item in the sort order. Under the hood, pypaginate uses a built-in cursor codec to serialize and deserialize bookmark positions.
# Response from first page:
page.next_cursor # "eyJpZCI6MTAwfQ=="
# Client sends this back for the next page:
params = CursorParams(limit=20, after="eyJpZCI6MTAwfQ==")
Note
Cursor Opacity Treat cursors as opaque strings. Never parse, construct, or modify them manually.
Ordering Requirements
Keyset pagination requires:
Consistent ordering – the same ORDER BY clause for all pages
Unique tail column – include a unique column (like
id) to break ties
# Bad: non-unique ordering (ambiguous position)
stmt = select(User).order_by(User.name)
# Good: unique tiebreaker
stmt = select(User).order_by(User.name, User.id)
# Best for time-series: timestamp + unique ID
stmt = select(User).order_by(User.created_at.desc(), User.id.desc())
Traversal Patterns
Forward Only (Infinite Scroll)
# First page
page = await paginate(stmt, CursorParams(limit=20), backend=backend)
# Next page
if page.has_next:
page = await paginate(
stmt,
CursorParams(limit=20, after=page.next_cursor),
backend=backend,
)
Bidirectional
# Go forward
next_page = await paginate(
stmt,
CursorParams(limit=20, after=page.next_cursor),
backend=backend,
)
# Go back
prev_page = await paginate(
stmt,
CursorParams(limit=20, before=page.previous_cursor),
backend=backend,
)
Performance Benefits
Rows |
Page Depth |
Offset Time |
Keyset Time |
|---|---|---|---|
1M |
1 |
~10ms |
~10ms |
1M |
100 |
~50ms |
~10ms |
1M |
10,000 |
~500ms |
~10ms |
1M |
100,000 |
~5s |
~10ms |
Keyset pagination uses an index seek instead of counting and skipping rows.
Handling Data Changes
Keyset pagination handles concurrent inserts/deletes better than offset:
Offset problem: If a row is deleted on page 3 while viewing page 4, one item is skipped.
Keyset solution: The cursor points to a specific position, so insertions/deletions before the cursor don’t affect the next page.
Limitations
No random page access – cannot jump to “page 50”
No total count – by design (counting defeats the purpose)
Requires stable ordering – changing ORDER BY invalidates existing cursors
Requires SQLAlchemy – the built-in cursor implementation handles bookmark serialization
Indexed Columns
Ensure ORDER BY columns are indexed for best performance:
-- Composite index for time-series pagination
CREATE INDEX idx_users_created_id
ON users (created_at DESC, id DESC);
from sqlalchemy import Index
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
created_at = Column(DateTime)
__table_args__ = (
Index("idx_users_created_id", created_at.desc(), id.desc()),
)
Choosing Offset vs Cursor
Feature |
Offset |
Cursor |
|---|---|---|
Page numbers in UI |
Yes |
No |
Random page access |
Yes |
No |
Deep page performance |
O(n) |
O(1) |
Total count |
Yes |
No |
Concurrent-safe |
Partial |
Yes |
Infinite scroll |
Awkward |
Natural |
Next Steps
Offset Pagination – When you need page numbers
In-Memory Pagination – For Python collections