Skip to main content

Automatic query caching and row-level cache invalidation for SQLAlchemy

Project description

sqlacache

A slick library that adds automatic queryset caching and row-level invalidation to SQLAlchemy async sessions.

Tests PyPI version Python License: MIT


session.get(User, 42) is cached. session.commit() invalidates it. That's the whole idea.

Built on top of cashews for storage and tag-based dependency tracking.

  • Zero changes to your session usage — reads are intercepted automatically
  • Row-level invalidation — only the rows that changed are evicted, not the whole table
  • Cross-process invalidation via Redis pub/sub — all workers stay in sync
  • Declarative config — map models to ops and TTLs in one place, with wildcard fallback
  • Two backends: redis:// for production, mem:// for dev and testing

Requirements

  • Python 3.10+
  • SQLAlchemy >= 1.4
  • cashews >= 7.0
  • Redis (for production; not needed for mem://)

Installation

pip install sqlacache
pip install "sqlacache[redis]"   # with Redis support

Using uv:

uv add "sqlacache[redis]"

Setup

Call configure() once at startup — alongside your engine setup.

from sqlalchemy.ext.asyncio import create_async_engine
from sqlacache import configure

engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")

sqlacache = configure(
    backend="redis://localhost:6379/1",
    models={
        "app.models.User":    {"ops": {"get", "fetch"}, "timeout": 900},
        "app.models.Product": {"ops": "all",            "timeout": 3600},
        "*":                  {"timeout": 3600},
    },
)
await sqlacache.bind(engine)

That's all the wiring needed. From here, your sessions work as normal.


Usage

Reads are cached automatically

async with AsyncSession(engine) as session:
    user = await session.get(User, 42)           # cache miss → fetches from DB, stores result
    user = await session.get(User, 42)           # cache hit → returned instantly

    result = await session.execute(select(User).where(User.active == True))
    users = result.scalars().all()               # multi-row fetch, also cached

Writes invalidate the cache automatically

async with AsyncSession(engine) as session:
    user = await session.get(User, 42)
    user.name = "Alice"
    await session.commit()     # evicts all cached reads that touched User id=42

No decorators. No manual cache keys. No changes to how you write queries.


FastAPI Example

from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlacache import configure

engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
session_maker = async_sessionmaker(engine, expire_on_commit=False)

sqlacache = configure(
    backend="redis://localhost:6379/1",
    models={"app.models.User": {"ops": "all", "timeout": 300}},
)


@asynccontextmanager
async def lifespan(app: FastAPI):
    await sqlacache.bind(engine)
    yield
    await sqlacache.disconnect()


app = FastAPI(lifespan=lifespan)


async def get_session():
    async with session_maker() as session:
        yield session


@app.get("/users/{user_id}")
async def get_user(user_id: int, session: AsyncSession = Depends(get_session)):
    return await session.get(User, user_id)   # cached after the first request

Configuration Reference

configure()

sqlacache = configure(
    backend="redis://localhost:6379/1",   # or "mem://" for in-memory
    models={...},
    prefix="sqlacache",       # key prefix (default: "sqlacache")
    default_timeout=3600,     # TTL when not specified per model (default: 3600)
    serializer="sqlalchemy",  # cashews serializer (default: "sqlalchemy")
)
await sqlacache.bind(engine)

Model config

models={
    "app.models.User": {
        "ops": {"get", "fetch"},   # operations to cache
        "timeout": 900,            # TTL in seconds
    },
    "app.models.Product": {"ops": "all", "timeout": 3600},
    "*": {"timeout": 3600},        # wildcard fallback
}

Ops:

Op What it covers
"get" session.get(Model, pk)
"fetch" session.execute(select(Model))
"count" select(func.count()) queries
"exists" select(exists(...)) queries
"all" All four above

Backends

Backend URL Notes
Redis redis://host:port/db Production; enables cross-process invalidation
In-memory mem:// Dev and testing; no infrastructure needed

Manual Control

You rarely need these — sqlacache handles everything through the session automatically. They're available for edge cases:

# Cache a statement explicitly
result = await sqlacache.execute(session, select(User).where(User.active == True), timeout=300)

# Invalidate specific rows
await sqlacache.invalidate(User, pks=[42, 99])

# Invalidate all cached reads for a model
await sqlacache.invalidate(User)

# Flush everything
await sqlacache.invalidate_all()

How It Works

session.get(User, 42)
    │
    ├── cache HIT  → return immediately
    └── cache MISS → execute SQL → tag result as "users:42" → return

session.commit()  [User id=42 changed]
    │
    └── after_flush event → delete tag "users:42" → all reads that touched that row are evicted
          └── (Redis) → pub/sub → other workers evict their copies too
  • Cache keys are a hash of the compiled SQL + bound parameters + a per-table version counter.
  • Tags ("{tablename}:{pk}") let cashews atomically evict all keys that depended on a row.
  • Bulk UPDATE ... WHERE ... bumps a table-level version so all cached queries for that model go stale.

Caveats

  • Async only. Sync Session is not supported yet (planned for v0.2.0).
  • Bulk writes use table-level invalidation. session.execute(update(Model).where(...)) evicts all cached reads for that model, not just the affected rows.
  • Eager-loaded relationships are not tracked. If a related row changes, queries that joined or selectin-loaded it won't be invalidated.
  • Raw SQL is not intercepted. text(...) and engine.execute() bypass sqlacache entirely.

Development

git clone https://github.com/hr-samsami/sqlacache
cd sqlacache
uv sync --extra redis --group dev

make test          # unit tests (no infrastructure needed)
make lint          # ruff
make format        # ruff format
make typecheck     # mypy

docker-compose up -d
make integration   # Redis + Postgres integration tests

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sqlacache-0.1.1.tar.gz (167.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sqlacache-0.1.1-py3-none-any.whl (20.7 kB view details)

Uploaded Python 3

File details

Details for the file sqlacache-0.1.1.tar.gz.

File metadata

  • Download URL: sqlacache-0.1.1.tar.gz
  • Upload date:
  • Size: 167.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sqlacache-0.1.1.tar.gz
Algorithm Hash digest
SHA256 884cf9d861b8d865bb0177c6b61a899b146c80b3313be90c079f800183eef1d9
MD5 07f53398d502cf4f478c5064af852ea7
BLAKE2b-256 79b66287231f2fc7fc165a1db855ebcc964fbeb90f6027b831e6a3d034ca6535

See more details on using hashes here.

Provenance

The following attestation bundles were made for sqlacache-0.1.1.tar.gz:

Publisher: release.yml on hr-samsami/sqlacache

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sqlacache-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: sqlacache-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 20.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for sqlacache-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 bc8573283b3b82fcd20deeb7e44fda0c90d8f5c2ddc24df2891fc3e5d631231b
MD5 c77b922088fbd97ba1876c2c2a7a39ad
BLAKE2b-256 b333fa90d3ed051ce29d2905b54d3605188e1ad7ec24c7e17e18f57d1a8ee1e0

See more details on using hashes here.

Provenance

The following attestation bundles were made for sqlacache-0.1.1-py3-none-any.whl:

Publisher: release.yml on hr-samsami/sqlacache

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page