Pagination¶
Pagination is a strategy attached to a repository class. A strategy is an immutable configuration object acting as a descriptor: accessed on a repository instance it returns a paginator bound to it, fully typed with the repository's model:
from sqlargon import SQLAlchemyRepository
from sqlargon.pagination import PageNumberPagination
class UserRepository(SQLAlchemyRepository[User]):
paginate = PageNumberPagination(default_page_size=25)
page = await UserRepository().filter(User.not_deleted).paginate(page=2)
# NumberedPage[User] -- page.items is Sequence[User]
Most repositories use exactly one strategy, matching the shape of their API:
| Strategy | Call | Returns |
|---|---|---|
PageNumberPagination |
paginate(page=3, page_size=25) |
NumberedPage[T] |
TotalPageNumberPagination |
paginate(page=3, page_size=25) |
TotalNumberedPage[T] |
LimitOffsetPagination |
paginate(offset=200, limit=100) |
OffsetPage[T] |
TotalLimitOffsetPagination |
paginate(offset=200, limit=100) |
TotalOffsetPage[T] |
CursorPagination |
paginate(cursor=token, page_size=100) |
CursorPage[T] |
As a rule of thumb: page numbers for classic UIs, offset/limit for background processing and internal APIs, cursors for large or fast-moving datasets.
Pages¶
Pages are frozen dataclasses, generic over the item type. All of them carry
items and an items_on_page property; the non-cursor pages also carry
has_more, detected by over-fetching a single row — no COUNT query is
issued. The Total* strategies additionally run a COUNT in the same session
as the page query and report total_items (and total_pages for numbered
pagination).
Page numbers are 1-based. Paging inputs are not validated by the library; constrain them at the API boundary (see FastAPI below).
Cursor pagination¶
Cursor (keyset) pagination requires the sqlakeyset package (the
sqlargon[pagination] extra) and is imported from its own module:
from sqlargon.pagination.cursor import CursorPagination
class LogRepository(SQLAlchemyRepository[LogEntry]):
default_order_by = LogEntry.id
paginate = CursorPagination(default_page_size=500)
page = await LogRepository().paginate() # first page
page = await LogRepository().paginate(page.next_page)
Cursors are opaque bookmarks: paging stays stable under concurrent inserts
and cheap at any depth, but requires the query to have a deterministic
ORDER BY (e.g. via default_order_by). CursorPage carries cursor,
next_page and previous_page (None at the edges), plus has_next /
has_previous properties.
Mappings instead of models¶
Every paginator accepts as_model — with False the items are SQLAlchemy
RowMapping objects instead of model instances, and the page type follows
the flag:
Iterating over all pages¶
Paginators expose pages(), an async iterator that keeps fetching until the
last page (following has_more, or next_page for cursors):
Filters and routing¶
Pagination composes with the rest of the repository API — it paginates the repository's current query and follows its routing preference:
Pagination never forces read_only routing by itself; opt in with
using(...) as with any other read.
Multiple or standalone strategies¶
A repository can expose several strategies under different names, and a strategy can be bound to a repository ad hoc without a class attribute:
class UserRepository(SQLAlchemyRepository[User]):
paginate = TotalPageNumberPagination()
paginate_cursor = CursorPagination()
paginator = LimitOffsetPagination().bind(UserRepository())
batch = await paginator(offset=500, limit=100)
Configuration¶
Strategies are immutable and shareable; they take:
default_page_size— used when the call omitspage_size/limit(default100).unique— apply.unique()to the results, required with joined eager loads (defaultFalse; not applicable to cursor pagination).
FastAPI¶
Validate paging inputs with pydantic types in the endpoint signature; the repository plugs in as a dependency:
from typing import Annotated
from annotated_types import Interval
from fastapi import Depends, FastAPI
from sqlargon.pagination import TotalNumberedPage
PageNumber = Annotated[int, Interval(ge=1)]
PageSize = Annotated[int, Interval(ge=1, le=100)]
app = FastAPI()
@app.get("/users", response_model=UsersPage)
async def list_users(
repo: Annotated[UserRepository, Depends()],
page: PageNumber = 1,
page_size: PageSize = 25,
) -> TotalNumberedPage[UserModel]:
return await repo.paginate(page, page_size)
Pages are plain dataclasses, so a pydantic response model reads them with
model_config = {"from_attributes": True} — see the
FastAPI example for the complete service.