Usage

Setup

Install the package (with hiredis for the faster C parser, if you like):

pip install action0-django-acache            # or: uv add action0-django-acache
pip install "action0-django-acache[hiredis]"

Then point a cache at the backend. Everything Django’s Redis backend accepts works the same way: one or several servers in LOCATION, TIMEOUT, KEY_PREFIX, VERSION, and the redis-py connection options in OPTIONS.

CACHES = {
    "default": {
        "BACKEND": "action0.django_acache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379",
        "TIMEOUT": 600,
        "OPTIONS": {
            "socket_timeout": 2,
        },
    },
}

Async methods

Use the cache’s async methods in async views, middleware, consumers or tasks, exactly as with any Django cache:

from django.core.cache import cache
from django.http import JsonResponse


async def weather(request, city):
    report = await cache.aget(f"weather:{city}")
    if report is None:
        report = await fetch_weather(city)
        await cache.aset(f"weather:{city}", report, timeout=300)
    return JsonResponse(report)

Django’s own backend implements these by running its sync methods in a worker thread (asgiref’s sync_to_async), which costs a thread hop per call and limits concurrency to the thread pool. Here they are coroutines that talk to the server through redis.asyncio:

Method

Implementation

aget, aset, aadd, atouch, adelete, ahas_key

native, one command each (aadd with a zero timeout: two)

aget_many

native, one MGET

aset_many

native, one pipeline: MSET plus an EXPIRE per key

adelete_many

native, one DEL

aincr, adecr

native, EXISTS then an atomic INCRBY

aclear

native, FLUSHDB

aget_or_set, aincr_version, adecr_version

Django’s generic versions, built on the native ones above

aclose

does nothing — see Pools

The sync methods are Django’s, untouched: they use the sync redis-py client as before, and write the same keys with the same serializer. Sync and async code therefore share one cache — a value stored with cache.set() is found by await cache.aget() and vice versa. Django’s own cache users (the cache middleware, cache_page, the cache session backend) call the sync methods and keep working unchanged.

Two small differences to the sync methods: aclear() returns None (as Django’s base class declares) rather than the server’s reply, and adelete_many() of an empty generator does nothing instead of sending an empty DEL.

ASYNC_OPTIONS

OPTIONS apply to the async connection pools, too. A few redis-py options, though, name classes that come in a sync and an async variant, and the sync one does not work on the async side. ASYNC_OPTIONS, next to OPTIONS, overrides keys for the async pools only:

from redis.asyncio.retry import Retry as AsyncRetry
from redis.backoff import ExponentialBackoff
from redis.retry import Retry

CACHES = {
    "default": {
        "BACKEND": "action0.django_acache.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379",
        "OPTIONS": {
            "pool_class": "redis.BlockingConnectionPool",
            "max_connections": 50,
            "retry": Retry(ExponentialBackoff(), 3),
        },
        "ASYNC_OPTIONS": {
            "pool_class": "redis.asyncio.BlockingConnectionPool",
            "retry": AsyncRetry(ExponentialBackoff(), 3),
        },
    },
}

Option

Sync side (OPTIONS)

Async side

pool_class

default redis.ConnectionPool

default redis.asyncio.ConnectionPool, never the sync one

parser_class

default redis-py’s sync parser

default redis-py’s async parser, never the sync one

connection_class, retry

as given

as in OPTIONS, but a sync class or Retry is an error — override it

serializer

as given

always the same as the sync side; not allowed in ASYNC_OPTIONS

everything else

as given

as in OPTIONS

As in OPTIONS, pool_class and parser_class may be dotted import paths.

A sync class or object that would reach the async pools, whether inherited from OPTIONS or given in ASYNC_OPTIONS, raises ImproperlyConfigured as soon as the cache is first used — by a sync method, too. Some of them would otherwise only fail on the first async call; a sync Retry would even be accepted silently and simply never retry. The message names the fix:

django.core.exceptions.ImproperlyConfigured: OPTIONS['retry'] is a redis.retry.Retry, which the async side cannot use: set ASYNC_OPTIONS['retry'] to a redis.asyncio.retry.Retry

Pools

A redis.asyncio connection belongs to the event loop it was opened on. And under ASGI, Django usually creates a new cache instance for every request (its cache handler keeps instances per async context). Connection pools kept on the cache instance would mean a new connection to the server for every request.

So the async pools are kept in one process-wide registry instead, per event loop and per configuration:

  • A pool is created on a loop’s first async cache call, for one server URL, pool class and set of options. Its connections are opened on demand.

  • All cache instances with equal settings share it — whichever request created it. max_connections therefore limits the connections of the whole loop, not of one request.

  • Every loop gets pools of its own: each asyncio.run(), each thread running a loop, each async_to_sync() call made outside an event loop.

  • A loop’s pools are closed when the loop shuts down. asyncio.run() and asyncio.Runner (which ASGI servers like uvicorn use), asgiref’s async_to_sync and unittest.IsolatedAsyncioTestCase all shut down a loop’s async generators before closing it, and that is the moment the registry uses.

  • Django calls every cache’s close() after each request. That does not touch the async pools, and neither does aclose(): other requests are still using them.

To close the running loop’s pools earlier, e.g. at the end of a test that checks for leaked connections, call aclose_pools(). A later cache call simply creates new pools.

from action0.django_acache import aclose_pools

await aclose_pools()

A loop that is closed without shutting down its async generators (by calling loop.close() yourself) cannot have its pools closed: redis-py reports the open connections with a ResourceWarning, and the registry drops them the next time a new loop asks for a pool.

Testing with fakeredis

fakeredis can stand in for the server in your test settings. Give the sync and the async side the connection class for their side, and the same FakeServer, so that both see the same data:

import fakeredis

CACHES = {
    "default": {
        "BACKEND": "action0.django_acache.RedisCache",
        "LOCATION": "redis://localhost:6379/0",
        "OPTIONS": {
            "connection_class": fakeredis.FakeRedisConnection,
            "server": fakeredis.FakeServer(),
        },
        "ASYNC_OPTIONS": {
            "connection_class": fakeredis.FakeAsyncRedisConnection,
        },
    },
}

This library’s own tests run exactly like that, and in CI additionally against real Redis and Valkey servers.