Skip to main content

SQLAlchemy

Pydantic-free pagination for SQLAlchemy 2.0. The adapter translates pypaginate's flat specs into SQLAlchemy expressions and paginates a select() against a sync or async session. It supports both offset pages and keyset (cursor) pages, and the cursors are byte-compatible with every other adapter — see cross-language parity.

Install

pip install "pypaginate[sqlalchemy]"

This pulls in SQLAlchemy[asyncio]>=2.0. The native engine ships inside the pypaginate wheel — no Rust toolchain needed.

Public API

from pypaginate.adapters.sqlalchemy import (
build_filter, # flat FilterSpec list -> WHERE expression (or None)
build_filter_group, # nested And/Or FilterGroup -> WHERE expression
build_order_by, # SortSpec list -> ORDER BY clauses
SQLAlchemyBackend, # async offset backend
SyncSQLAlchemyBackend, # sync offset backend
SQLAlchemyCursorBackend, # async keyset/cursor backend
SyncSQLAlchemyCursorBackend, # sync keyset/cursor backend
resolve_column, # field name -> mapped column attribute
# low-level keyset helpers (used by the cursor backends)
OrderColumn, build_keyset_condition, extract_order_columns,
)

The adapter resolves each spec's field to a mapped attribute with getattr, so FilterSpec(field="age", ...) maps to Model.age. An unknown field raises ConfigurationError.

Building WHERE and ORDER BY

build_filter, build_filter_group, and build_order_by are pure translators — you decide how to attach them to your select(). build_filter returns None when the filter list is empty, so guard before calling .where().

from sqlalchemy import select
from pypaginate import FilterSpec, SortSpec, And
from pypaginate.adapters.sqlalchemy import build_filter, build_filter_group, build_order_by

# Flat list — each spec carries its own AND/OR `logic`:
where = build_filter(User, [
FilterSpec(field="age", operator="gte", value=18),
FilterSpec(field="status", operator="eq", value="active"),
])

# Nested boolean group:
where = build_filter_group(User, And(
FilterSpec(field="age", operator="gte", value=18),
FilterSpec(field="name", operator="contains", value="a"),
))

order = build_order_by(User, [SortSpec(field="age", direction="desc", nulls="last")])

stmt = select(User)
if where is not None:
stmt = stmt.where(where)
stmt = stmt.order_by(*order)

All 20 filter operators are supported (eq, ne, gt, gte, lt, lte, in, not_in, contains, starts_with, ends_with, like, ilike, between, is_null, is_not_null, regex, empty, not_empty, exists). empty renders col IS NULL OR col = '', not_empty the converse, and exists renders TRUE. regex uses Column.regexp_match (backend-dependent).

Offset pagination

The backend counts matching rows with SELECT COUNT(*) and fetches the window with OFFSET/LIMIT, returning an OffsetPage. Filtering and sorting are applied by you on the query before it is handed in.

from pypaginate import OffsetParams
from pypaginate.adapters.sqlalchemy import SyncSQLAlchemyBackend

backend = SyncSQLAlchemyBackend(session) # a sqlalchemy.orm.Session
page = backend.paginate(stmt, OffsetParams(page=1, limit=20))

page.total # int — matched rows
page.pages # int — total pages
page.has_next # bool
list(page) # the page's ORM rows (OffsetPage is iterable + indexable)

The async backend is identical with await:

from pypaginate.adapters.sqlalchemy import SQLAlchemyBackend

backend = SQLAlchemyBackend(async_session) # AsyncSession
page = await backend.paginate(stmt, OffsetParams(page=1, limit=20))

Both backends also expose count(query) and fetch(query, offset, limit) if you need the pieces separately.

Keyset (cursor) pagination

The cursor backend over-fetches limit + 1 rows for a keyset-ordered query and returns a CursorPage with next_cursor / previous_cursor. The query must carry an ORDER BY that uniquely orders rows — append a trailing primary key.

from sqlalchemy import select
from pypaginate import CursorParams
from pypaginate.adapters.sqlalchemy import SyncSQLAlchemyCursorBackend

stmt = select(Post).order_by(Post.created_at.desc(), Post.id.desc())
backend = SyncSQLAlchemyCursorBackend(session)

first = backend.fetch_page(stmt, CursorParams(limit=20))
next_page = backend.fetch_page(stmt, CursorParams(limit=20, after=first.next_cursor))
prev_page = backend.fetch_page(stmt, CursorParams(limit=20, before=next_page.previous_cursor))

CursorParams accepts at most one of after / before; passing both raises a ValidationError. The backend decodes the cursor, applies the lexicographic keyset WHERE (e.g. (a > v1) OR (a = v1 AND b < v2)), re-orders, over-fetches, trims, and re-encodes — all derived from the query's own ORDER BY. The async backend (SQLAlchemyCursorBackend) has the same fetch_page signature with await.