Skip to content

CybLow/pypaginate

pypaginate

Universal pagination toolkit for Python -- one function, any backend, auto-detects sync/async.

CI PyPI version Python Versions License: MIT codecov Ruff uv

pypaginate provides a single paginate() function that works with lists, SQLAlchemy queries (async and sync), and cursor-based pagination. The return type is automatically inferred from the params you pass in.

Features

  • One function -- paginate() handles lists, SQLAlchemy queries, sync and async
  • Type-safe inference -- OffsetParams returns OffsetPage, CursorParams returns CursorPage
  • Filtering -- 20 operators (eq, gte, contains, between, regex, etc.)
  • Sorting -- multi-column with direction and null placement control
  • Search -- full-text with optional fuzzy matching (RapidFuzz)
  • FastAPI -- Annotated dependencies for pagination, filtering, sorting, and search
  • Cursor pagination -- keyset/cursor-based pagination via sqlakeyset
  • Pipeline -- compose filter + sort + search + paginate in one call
  • 100% typed -- mypy strict mode, Pydantic v2 models

Installation

# Core (in-memory pagination only)
pip install pypaginate

# With SQLAlchemy support
pip install pypaginate[sqlalchemy]

# With FastAPI integration
pip install pypaginate[fastapi]

# With fuzzy search (RapidFuzz)
pip install pypaginate[search]

# Everything
pip install pypaginate[all]

Or with uv:

uv add pypaginate
uv add pypaginate[all]

Quick Start

Paginate a list in 3 lines:

from pypaginate import paginate, OffsetParams

page = paginate([1, 2, 3, 4, 5], OffsetParams(page=1, limit=2))

page.items       # [1, 2]
page.total       # 5
page.pages       # 3
page.has_next    # True

SQLAlchemy (Async)

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pypaginate import paginate, OffsetParams
from pypaginate.adapters.sqlalchemy import SQLAlchemyBackend

async def list_users(session: AsyncSession):
    stmt = select(User).order_by(User.created_at.desc())
    backend = SQLAlchemyBackend(session)

    page = await paginate(stmt, OffsetParams(page=1, limit=20), backend=backend)

    page.items       # list[User]
    page.total       # int
    page.has_next    # bool

For sync sessions, use SyncSQLAlchemyBackend:

from sqlalchemy.orm import Session
from pypaginate.adapters.sqlalchemy import SyncSQLAlchemyBackend

def list_users(session: Session):
    backend = SyncSQLAlchemyBackend(session)
    page = paginate(select(User), OffsetParams(page=1, limit=20), backend=backend)

Cursor Pagination

For large datasets where offset-based pagination is inefficient:

from pypaginate import paginate, CursorParams
from pypaginate.adapters.sqlalchemy import SQLAlchemyCursorBackend

async def scroll_users(session: AsyncSession, cursor: str | None = None):
    stmt = select(User).order_by(User.id)
    backend = SQLAlchemyCursorBackend(session)

    page = await paginate(stmt, CursorParams(limit=20, after=cursor), backend=backend)

    page.items            # list[User]
    page.next_cursor      # str | None -- pass to next request
    page.previous_cursor  # str | None
    page.has_next         # bool

FastAPI Integration

pypaginate provides Annotated dependency types for clean FastAPI integration:

from fastapi import FastAPI
from pypaginate import paginate, OffsetPage
from pypaginate.adapters.fastapi import OffsetDep

app = FastAPI()

@app.get("/users")
async def list_users(params: OffsetDep) -> OffsetPage[dict]:
    users = [{"name": "Alice"}, {"name": "Bob"}, {"name": "Charlie"}]
    return paginate(users, params)

Available dependencies:

Dependency Query Params Produces
OffsetDep ?page=1&limit=20 OffsetParams
CursorDep ?limit=20&after=abc CursorParams
FilterDep (user-defined fields) list[FilterSpec]
SortDep ?sort=name,-age list[SortSpec]
SearchDep ?q=alice&search_fields=name,email SearchSpec

Declarative Filters

from typing import Annotated
from fastapi import Query
from pypaginate.adapters.fastapi import FilterDep, FilterField

class UserFilters(FilterDep):
    name: str | None = FilterField(None, operator="contains")
    age_min: int | None = FilterField(None, field="age", operator="gte")
    status: str | None = FilterField(None, operator="eq")

@app.get("/users")
async def list_users(
    params: OffsetDep,
    filters: Annotated[UserFilters, Query()],
):
    # filters.to_specs() returns list[FilterSpec] for non-None fields
    ...

Sorting and Search

from pypaginate.adapters.fastapi import OffsetDep, SortDep, SearchDep

@app.get("/users")
async def list_users(params: OffsetDep, sort: SortDep, search: SearchDep):
    # sort: ?sort=name,-created_at  (- prefix = descending)
    # search: ?q=alice&search_fields=name,email
    ...

Filtering

Use FilterSpec to define filter conditions:

from pypaginate import FilterSpec
from pypaginate.filtering import FilterEngine, create_default_registry

engine = FilterEngine(create_default_registry())

users = [
    {"name": "Alice", "age": 30, "status": "active"},
    {"name": "Bob", "age": 25, "status": "inactive"},
    {"name": "Charlie", "age": 35, "status": "active"},
]

# Simple equality
active = engine.apply(users, [FilterSpec(field="status", value="active")])
# [Alice, Charlie]

# Multiple filters (AND by default)
result = engine.apply(users, [
    FilterSpec(field="age", operator="gte", value=30),
    FilterSpec(field="status", value="active"),
])
# [Alice, Charlie]

Nested Filter Groups

from pypaginate import And, Or, FilterSpec

# (status = active) AND (age >= 30 OR name contains "bob")
group = And(
    FilterSpec(field="status", value="active"),
    Or(
        FilterSpec(field="age", operator="gte", value=30),
        FilterSpec(field="name", operator="contains", value="bob"),
    ),
)

result = engine.apply(users, group)

Available Filter Operators

Operator Description Example
eq, ne Equality / inequality FilterSpec(field="status", value="active")
gt, gte, lt, lte Comparisons FilterSpec(field="age", operator="gte", value=18)
in, not_in Membership FilterSpec(field="role", operator="in", value=["admin", "user"])
contains, starts_with, ends_with Text matching FilterSpec(field="name", operator="contains", value="ali")
like, ilike SQL-style patterns FilterSpec(field="email", operator="like", value="%@gmail.com")
between Range FilterSpec(field="price", operator="between", value=[10, 100])
is_null, is_not_null Null checks FilterSpec(field="notes", operator="is_null")
empty, not_empty Empty checks FilterSpec(field="tags", operator="not_empty")
exists Field existence FilterSpec(field="id", operator="exists")
regex Regex matching FilterSpec(field="code", operator="regex", value="^A\\d+")

Sorting

from pypaginate import SortSpec, SortDirection

from pypaginate.sorting import SortEngine

engine = SortEngine()

users = [
    {"name": "Charlie", "age": 35},
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 25},
]

sorted_users = engine.apply(users, [
    SortSpec(field="age", direction=SortDirection.DESC),
])
# [Charlie (35), Alice (30), Bob (25)]

Search

from pypaginate import SearchSpec

from pypaginate.search import SearchEngine

engine = SearchEngine()

users = [
    {"name": "Alice Smith", "email": "alice@example.com"},
    {"name": "Bob Johnson", "email": "bob@example.com"},
]

results = engine.apply(users, SearchSpec(
    query="alice",
    fields=("name", "email"),
))
# [Alice Smith]

Fuzzy search (requires pypaginate[search]):

from pypaginate import SearchSpec, FuzzyMode

results = engine.apply(users, SearchSpec(
    query="alce",
    fields=("name",),
    fuzzy=FuzzyMode.FUZZY,
    threshold=75,
))

Pipeline (Filter + Sort + Search + Paginate)

Compose all operations in a single call:

from pypaginate import OffsetParams, FilterSpec, SortSpec, SortDirection
from pypaginate.engine.pipeline import SyncPipeline
from pypaginate.engine.paginator import Paginator
from pypaginate.adapters.memory import (
    MemoryBackend,
    MemoryFilterBackend,
    MemorySortBackend,
)

pipeline = SyncPipeline(
    Paginator(MemoryBackend()),
    filter_backend=MemoryFilterBackend(),
    sort_backend=MemorySortBackend(),
)

page = pipeline.execute(
    users,
    OffsetParams(page=1, limit=10),
    filters=[FilterSpec(field="status", value="active")],
    sorting=[SortSpec(field="name", direction=SortDirection.ASC)],
)

For async (e.g., SQLAlchemy), use AsyncPipeline with AsyncPaginator.

Architecture

pypaginate/
├── domain/        # Models, specs, enums, protocols (no deps)
├── engine/        # Paginator, cursor paginator, pipeline
├── filtering/     # In-memory filter engine + operators
├── sorting/       # In-memory sort engine
├── search/        # In-memory search engine
└── adapters/
    ├── memory/        # In-memory backends (filter, sort, search)
    ├── sqlalchemy/    # SA backends (offset, cursor, filter, sort, search)
    └── fastapi/       # Annotated dependencies (OffsetDep, FilterDep, etc.)

CI Pipeline

Tiered pipeline with 40+ concurrent jobs across 4 Python versions and 3 operating systems:

                              ┌─────────┐
                              │  Setup  │
                              └────┬────┘
                 ┌─────────────────┼─────────────────┐
                 ▼                 ▼                  ▼
           ┌──────────┐     ┌──────────┐       ┌──────────┐
           │ Quality  │     │ Security │       │ CodeQL   │
           │ ruff+mypy│     │ bandit   │       │          │
           └────┬─────┘     └──────────┘       └──────────┘
        ┌───────┼────────┐
        ▼                ▼
  ┌──────────┐    ┌──────────────────────────────────────────┐
  │ Arch     │    │ Unit Tests (12 jobs)                     │
  │ 72 tests │    │ Python 3.11-3.14 × Linux/macOS/Windows   │
  └──────────┘    └──────────────────┬───────────────────────┘
            ┌──────────┬─────────────┼──────────┬──────────┐
            ▼          ▼             ▼          ▼          ▼
    ┌────────────┐ ┌────────┐ ┌──────────┐ ┌────────┐ ┌───────┐
    │Integration │ │  E2E   │ │PostgreSQL│ │Property│ │Bench  │
    │ 12 jobs    │ │ 6 flows│ │ real DB  │ │Hypothe.│ │293 pts│
    │ 4Py × 3OS │ │        │ │          │ │        │ │       │
    └────────────┘ └────────┘ └──────────┘ └────────┘ └───────┘
Test Suite Jobs Coverage
Unit 12 (4 Python × 3 OS) All modules, parallel execution
Integration 12 (4 Python × 3 OS) Cross-module with real SQLite
E2E 1 Full FastAPI user journeys
PostgreSQL 1 Real Postgres 16 via service container
Property 1 Hypothesis invariant checking
Architecture 1 File limits, imports, protocols
Benchmarks 1 293 perf benchmarks, PR regression alerts
Total 29+ 872+ tests, 85% coverage gate

Live Benchmark Dashboard -- performance tracked on every commit.

Development

git clone https://github.com/CybLow/pypaginate.git
cd pypaginate
uv sync

# Run all checks
uv run ruff format . && uv run ruff check --fix . && uv run mypy src/ && uv run pytest

# Individual commands
uv run pytest                  # Tests
uv run pytest --cov            # Coverage
uv run ruff format .           # Format
uv run ruff check --fix .      # Lint
uv run mypy src/               # Type check

Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Run tests and quality checks (uv run pytest && uv run ruff check .)
  4. Commit with conventional commits (git commit -m 'feat: add amazing feature')
  5. Open a Pull Request

License

MIT -- see LICENSE for details.

About

Universal pagination, filtering, sorting & search toolkit for Python — one function, any backend, type-safe, async-native, FastAPI & SQLAlchemy ready

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors