django-swr-memoize
Stale-while-revalidate memoization for Django. The API is the same as django-memoize, but only the very first call for a key ever waits for the function.
Why
With django-memoize, when a cached value expires, the next caller runs the function and waits for it. If the function takes 9 s and its value expires hourly, someone waits 9 s every hour. Under load it's worse: every caller that arrives during those 9 s misses the cache too, and they all run the function at the same time. This is known as a cache stampede [3].
django-swr-memoize serves the old value straight away, recomputes it in a background thread, and the next caller gets the new value. As long as a key is read often enough, nobody ever waits.
Background
The name and the model come from HTTP caching. RFC 5861 [1] defines two
Cache-Control extensions, and RFC 9111 [2], the current HTTP caching standard,
still refers to them:
stale-while-revalidate=N: a cache may keep serving a response for up to N seconds after it goes stale, while it revalidates in the background ("without blocking").stale-if-error=N: when revalidating fails, a stale response may still be used.
In this library, fresh_for plays the part of HTTP's freshness lifetime
(max-age), and max_age - fresh_for is the stale-while-revalidate window.
A failed refresh keeps the stale value, like stale-if-error, but never past
max_age.
Vattani, Chierichetti and Lowenstein [3] study cache stampedes formally and give an optimal probabilistic early recomputation (XFetch): each request, shortly before expiry, recomputes with a probability that rises as expiry approaches. Only a few requests end up recomputing, but each of those still waits for it. django-swr-memoize takes a different route: a lock picks exactly one refresher per key, and it runs in the background, so no caller waits.
References
- M. Nottingham. HTTP Cache-Control Extensions for Stale Content. RFC 5861, IETF, May 2010.
- R. Fielding, M. Nottingham, J. Reschke (eds.). HTTP Caching. RFC 9111, IETF, June 2022.
- A. Vattani, F. Chierichetti, K. Lowenstein. Optimal Probabilistic Cache Stampede Prevention. Proceedings of the VLDB Endowment 8(8), 886–897, 2015. PDF
Installation
pip install django-swr-memoize
It uses Django's configured cache (django.core.cache.cache), so there's
nothing to add to INSTALLED_APPS and no broker to run. For the lock that keeps
refreshes to one per key, the cache has to be shared between processes:
database, Redis or Memcached, not LocMemCache.
Usage
from swr_memoize import delete_memoized, memoize
@memoize(timeout=3600)
def pool_state(assignment_id: int) -> dict:
... # slow
pool_state(743) # first call ever: computes and waits
pool_state(743) # served from the cache
delete_memoized(pool_state) # forget every value; the next call waits again
How old a value may be
How a call behaves depends on how old the cached value is:
| Age of the cached value | What the caller gets |
|---|---|
younger than fresh_for |
the cached value |
between fresh_for and max_age |
the cached value at once; a background thread recomputes it for the next caller |
older than max_age, or never computed |
a newly computed value (the caller waits) |
timeoutmeans what it means in django-memoize: no value is ever served older than this. It's another name formax_age.fresh_fordefaults to half ofmax_age. Withtimeout=3600, a value is served as it is for 30 minutes, then served and refreshed for up to 60 minutes. A key read at least once pertimeout / 2never makes anyone wait, and each key is recomputed at most twice pertimeout.- Pass
fresh_for=to choose a different split.fresh_for == max_agebehaves exactly like django-memoize.
| Parameter | Default | Meaning |
|---|---|---|
timeout / max_age |
the cache's default timeout | a value older than this is never served; None keeps values forever |
fresh_for |
half of max_age |
a value younger than this is served without refreshing it |
make_name |
None |
maps the function name to the one used in the key |
unless |
None |
a callable; when it returns True the cache is bypassed |
on_miss |
wait | a value to return at once on a miss, while the function runs in the background |
Never waiting, even the first time
By default, a miss (a value never computed, or older than max_age) waits for
the function, as django-memoize does. With on_miss, a miss returns that value
at once and computes the real one in the background, so no call ever waits:
@memoize(timeout=3600, on_miss=None)
def pool_state(assignment_id: int) -> dict | None:
... # slow
pool_state(743) # first call ever: None at once; computing in the background
pool_state(743) # a moment later: the value
pool_state.wait_on_miss(744) # this caller needs the value: waits on a miss
wait_on_miss shares the cache with the plain call. Use it where a placeholder
would be wrong, for example in a background job that acts on the value.
Background refreshes
- One at a time per key, across processes. A lock taken with
cache.addmeans only one process refreshes a key at once, so 40 gunicorn workers still make one refresh. - Refreshes are threads, not tasks. If a process dies mid-refresh, the stale
value keeps being served, and the lock expires after
Memoizer(refresh_lock_timeout=300)seconds. - A failed refresh is logged and changes nothing. The stale value stays
until
max_age, and the next caller pastfresh_forretries. - Database connections are closed. The refresh thread closes its own Django database connections when it finishes.
- Version keys never expire. django-memoize stores each function's version
hash with the function's timeout, so when it lapses every value of the
function becomes unreachable at once. Here version keys only change through
delete_memoized.
Moving from django-memoize
These behave the same as in django-memoize, except that version keys never expire (see above):
memoize(timeout, make_name, unless)delete_memoized(f, *args, **kwargs)anddelete_memoized_verhash(f)- the
Memoizerclass - the
uncached,cache_timeout,make_cache_keyanddelete_memoizedattributes on the decorated function
To move a function over, change its import from memoize to swr_memoize.
Both libraries can be installed side by side, since this one stores its
entries under its own swr_memoize key prefix. That lets you move functions
over one at a time.
Supported versions
Every combination below runs the full test suite in CI on every push and pull request, and weekly.
| Django | Python |
|---|---|
| 4.2 | 3.10, 3.11, 3.12 |
| 5.2 | 3.10, 3.11, 3.12, 3.13, 3.14 |
| 6.0 | 3.12, 3.13, 3.14 |
| 6.1 | 3.12, 3.13, 3.14 |
Development
git clone https://github.com/jimmy927/django-swr-memoize
cd django-swr-memoize
uv run --with pytest --with django python -m pytest -v
To test against one particular Python and Django, as CI does:
uv run --isolated --python 3.12 --with pytest --with "Django~=5.2.0" python -m pytest -v
The tests are in tests/test_memoize.py. They cover:
- fresh, stale and expired values
- a single refresh under concurrent callers
- a failed refresh
- both forms of
delete_memoized - instance methods,
unlessanduncached on_missandwait_on_miss- a background refresh surviving past the first value's
max_age
A fake clock drives the timing, so the whole suite runs in well under a second.
Releasing
Update __version__ in src/swr_memoize/__init__.py, then create a GitHub
release. The Publish workflow builds the package and uploads it to PyPI
through trusted publishing, so no API token is stored anywhere.
License
BSD-3-Clause. See LICENSE.
Release files for django-swr-memoize 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_swr_memoize-0.2.0.tar.gz | 12.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_swr_memoize-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 23.2 kB
Release files / django_swr_memoize-0.2.0.tar.gz
| Download URL | django_swr_memoize-0.2.0.tar.gz |
|---|---|
| Size | 12.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
84c22d17cb3d693e342bb4b819ebdd799bcf94d412738b2d1150a6e8430bce4b
|
|
BLAKE2b-256 checksum How to use checksums |
fba1533bf8ab89a1349f8cba21361d9104d35024a0c8285ce2e70960507b5ff1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency logRelease files / django_swr_memoize-0.2.0-py3-none-any.whl
| Download URL | django_swr_memoize-0.2.0-py3-none-any.whl |
|---|---|
| Size | 10.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
03d07670597d5986fe784244399d148f5189fe6b7d29d974f3d67acf7f1286df
|
|
BLAKE2b-256 checksum How to use checksums |
3939b69f3cb0a87ed17f10c321da320422dd1af213e40cd0d50850c53139b37e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency log