SQLAlchemy Integration
The pypaginate.adapters.sqlalchemy package provides async and sync backends for
offset pagination, cursor/keyset pagination, filtering, sorting, and search –
all driven by SQLAlchemy 2.0+ Select statements.
Installation
SQLAlchemy support is an optional dependency:
uv add pypaginate[sqlalchemy] # SQLAlchemy
uv add pypaginate[all] # Everything
Exports at a Glance
from pypaginate.adapters.sqlalchemy import (
# Offset pagination
SQLAlchemyBackend, # async -- PaginationBackend[T]
SyncSQLAlchemyBackend, # sync -- SyncPaginationBackend[T]
# Cursor/keyset pagination
SQLAlchemyCursorBackend, # async -- CursorBackend[T]
SyncSQLAlchemyCursorBackend, # sync
# Query transformation
SQLAlchemyFilterBackend, # FilterSpec → WHERE clauses
SQLAlchemySortBackend, # SortSpec → ORDER BY clauses
SQLAlchemySearchBackend, # SearchSpec → ILIKE clauses
)
Offset Pagination
SQLAlchemyBackend (async)
Implements the PaginationBackend[T] protocol using SELECT COUNT(*) and
OFFSET/LIMIT.
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pypaginate import OffsetParams, paginate
from pypaginate.adapters.sqlalchemy import SQLAlchemyBackend
async def list_users(session: AsyncSession):
query = select(User).order_by(User.id)
backend = SQLAlchemyBackend(session)
return await paginate(query, OffsetParams(page=1, limit=20), backend=backend)
SyncSQLAlchemyBackend
Same API, but synchronous – implements SyncPaginationBackend[T].
from sqlalchemy.orm import Session
from pypaginate import OffsetParams, paginate
from pypaginate.adapters.sqlalchemy import SyncSQLAlchemyBackend
def list_users(session: Session):
query = select(User).order_by(User.id)
backend = SyncSQLAlchemyBackend(session)
return paginate(query, OffsetParams(page=1, limit=20), backend=backend)
Constructor Parameters
Both SQLAlchemyBackend and SyncSQLAlchemyBackend accept the same keyword
arguments:
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
required |
SQLAlchemy session |
|
|
|
Custom count query (see below) |
|
|
|
Deduplicate rows via |
Custom Count Query
For queries with expensive JOINs, provide an optimized count query that avoids the joins:
from sqlalchemy import func, select
# Expensive main query with joins and eager loading
query = (
select(Order)
.join(Order.customer)
.join(Order.items)
.where(Order.status == "completed")
.options(selectinload(Order.items))
)
# Cheap count -- same WHERE, no joins
count_query = (
select(func.count(Order.id))
.where(Order.status == "completed")
)
backend = SQLAlchemyBackend(session, count_query=count_query)
page = await paginate(query, params, backend=backend)
Without count_query, the backend wraps your main query in
SELECT COUNT(*) FROM (your_query) AS subquery, which can be slow for complex
queries.
Deduplication
When JOINs produce duplicate parent rows (e.g., one Author joined to many Books),
pass unique=True to call result.unique().scalars() instead of
result.scalars():
query = (
select(Author)
.join(Author.books)
.where(Book.genre == "fiction")
)
backend = SQLAlchemyBackend(session, unique=True)
page = await paginate(query, params, backend=backend)
# Authors are deduplicated
Cursor/Keyset Pagination
SQLAlchemyCursorBackend (async)
Uses a built-in cursor implementation for efficient
keyset pagination. Implements the CursorBackend[T] protocol.
Requirement: The query must have an ORDER BY clause.
from pypaginate import CursorParams, paginate
from pypaginate.adapters.sqlalchemy import SQLAlchemyCursorBackend
async def scroll_users(session: AsyncSession):
query = select(User).order_by(User.id)
backend = SQLAlchemyCursorBackend(session)
# First page
first = await paginate(
query,
CursorParams(limit=20),
backend=backend,
)
# Next page using cursor
if first.next_cursor:
second = await paginate(
query,
CursorParams(limit=20, after=first.next_cursor),
backend=backend,
)
SyncSQLAlchemyCursorBackend
Synchronous equivalent:
from sqlalchemy.orm import Session
from pypaginate.adapters.sqlalchemy import SyncSQLAlchemyCursorBackend
def scroll_users(session: Session):
query = select(User).order_by(User.id)
backend = SyncSQLAlchemyCursorBackend(session)
# same fetch_page interface, called synchronously
CursorPage Fields
The returned CursorPage[T] has:
Field |
Type |
Description |
|---|---|---|
|
|
Items for this page |
|
|
Requested page size |
|
|
Whether a next page exists |
|
|
Whether a previous page exists |
|
|
Bookmark for the next page |
|
|
Bookmark for the previous page |
Filter Backend
SQLAlchemyFilterBackend translates FilterSpec objects into SQLAlchemy WHERE
clauses. Implements the FilterBackend protocol.
Supported Operators
Operator |
SQL |
Example |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
AND/OR Logic
Each FilterSpec has a logic field (defaults to FilterLogic.AND). Specs with
FilterLogic.OR are combined with OR, everything else with AND:
from pypaginate import FilterSpec, FilterLogic
filters = [
FilterSpec(field="status", value="active"),
FilterSpec(field="role", value="admin", logic=FilterLogic.OR),
FilterSpec(field="role", value="moderator", logic=FilterLogic.OR),
]
# WHERE status = 'active' AND (role = 'admin' OR role = 'moderator')
Usage
from pypaginate.adapters.sqlalchemy import SQLAlchemyFilterBackend
filter_backend = SQLAlchemyFilterBackend()
query = filter_backend.apply_filters(query, filter_specs)
Sort Backend
SQLAlchemySortBackend translates SortSpec objects into SQLAlchemy ORDER BY
clauses. Implements the SortBackend protocol.
Each SortSpec supports:
Field |
Type |
Default |
Description |
|---|---|---|---|
|
|
required |
Column name |
|
|
|
|
|
|
|
|
from pypaginate import SortSpec, SortDirection, NullsPosition
from pypaginate.adapters.sqlalchemy import SQLAlchemySortBackend
specs = [
SortSpec(field="created_at", direction=SortDirection.DESC),
SortSpec(field="name", nulls=NullsPosition.LAST),
]
query = SQLAlchemySortBackend.apply_sorting(query, specs)
Search Backend
SQLAlchemySearchBackend translates a SearchSpec into ILIKE WHERE clauses.
Implements the SearchBackend protocol.
Search behavior:
The query text is normalized and tokenized (whitespace-split)
Each token must match at least one field (OR across fields)
All tokens must match (AND across tokens)
Match mode is controlled by
SearchFieldMode(PREFIX, CONTAINS, EXACT)
from pypaginate import SearchSpec
from pypaginate.adapters.sqlalchemy import SQLAlchemySearchBackend
spec = SearchSpec(query="alice smith", fields=("name", "email"))
query = SQLAlchemySearchBackend.apply_search(query, spec)
# WHERE (name ILIKE '%alice%' OR email ILIKE '%alice%')
# AND (name ILIKE '%smith%' OR email ILIKE '%smith%')
Pipeline Wiring
The recommended pattern for FastAPI + SQLAlchemy is to wire an AsyncPipeline
that composes all backends:
from pypaginate.engine.paginator import AsyncPaginator
from pypaginate.engine.pipeline import AsyncPipeline
from pypaginate.adapters.sqlalchemy import (
SQLAlchemyBackend,
SQLAlchemyFilterBackend,
SQLAlchemySortBackend,
SQLAlchemySearchBackend,
)
async def get_pipeline(
session: AsyncSession = Depends(get_session),
) -> AsyncPipeline:
backend = SQLAlchemyBackend(session)
return AsyncPipeline(
AsyncPaginator(backend),
filter_backend=SQLAlchemyFilterBackend(),
sort_backend=SQLAlchemySortBackend(),
search_backend=SQLAlchemySearchBackend(),
)
Then in your endpoint:
from pypaginate.adapters.fastapi import (
FilterDep, FilterField, OffsetDep, SearchDep, SortDep,
)
class ProductFilters(FilterDep):
category: str | None = FilterField(None, operator="eq")
min_price: float | None = FilterField(None, field="price", operator="gte")
@app.get("/products")
async def list_products(
params: OffsetDep,
filters: Annotated[ProductFilters, Query()],
sort: SortDep,
search: SearchDep,
pipeline: AsyncPipeline = Depends(get_pipeline),
) -> OffsetPage[ProductSchema]:
query = select(Product).order_by(Product.id)
return await pipeline.execute(
query, params, filters=filters, sorting=sort, search=search,
)
The pipeline auto-converts FilterDep.to_specs(), SortDep.to_specs(), and
SearchDep.to_spec() internally.
Performance Tips
Always include ORDER BY for consistent pagination across pages
Provide
count_queryfor queries with expensive JOINs or subqueriesUse
unique=Trueonly when JOINs produce duplicate parent rowsUse cursor pagination for large datasets (avoids
OFFSET Ndegradation)Index sort columns to avoid full table scans on ORDER BY
Limit eager loading – use
load_only()andselectinload()to minimize data transfer
Error Handling
from pypaginate import (
ConfigurationError, # missing entity, bad field name
FilterError, # unsupported operator, bad BETWEEN value
ValidationError, # bad page/limit values
)
ConfigurationErroris raised when column resolution fails (field not found on entity)FilterErroris raised for unsupported operators or malformed filter valuesValidationErroris raised for invalid pagination parameters
See Also
FastAPI Integration – dependency types and declarative filters
API Reference – see the
adapterssection in the generated API docs