Skip to main content

django-stash

License: BSD-3-Clause Ruff

Remember a value for the rest of the current request, and read it back from anywhere — without passing request around.

import stash

# middleware.py — the one place that actually has `request`
class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        stash.set("tenant", Tenant.objects.get(domain=request.get_host()))
        return self.get_response(request)


# models.py, templatetags/, signals.py — no `request` in sight
def get_current_tenant():
    return stash.get("tenant")

TenantMiddleware resolves the tenant from the hostname once, because that's the only place request is available. Every other call to get_current_tenant() during that request — from a model manager, a template tag, a signal handler — reads the same value back. Next request: middleware resolves it again.

This is not a cache backend. Nothing is shared between requests, processes, or workers. Values live only while a request is being handled, and are thrown away when it finishes.

Install

pip install django-stash
MIDDLEWARE = [
    "stash.middleware.StashMiddleware",
    # ...
]

No INSTALLED_APPS entry needed.

The problem it solves

Not every case needs request at all. Sometimes you just have something expensive, needed in several unrelated places during one request — a template tag, a model method, a serializer, a signal handler:

# views.py
settings = SiteSettings.objects.get()

# templatetags/theme.py
settings = SiteSettings.objects.get()   # same query again

# models.py
settings = SiteSettings.objects.get()   # and again

The usual fixes are awkward: thread request through every function, hang attributes off request, or reach for a global cache and then worry about invalidation.

With stash, each call site asks for the value by name and the first one pays:

import stash

def get_site_settings():
    return stash.get_or_set("site_settings", lambda: SiteSettings.objects.get())

Where this fits

Reach for stash when a value is expensive, needed in more than one place during a single request, and awkward to get to from where you are:

  • A request-derived value needed somewhere without request. This is the example at the top: something that can only be resolved from the hostname, a header, or a cookie — the current tenant, the authenticated API client, an A/B test bucket — computed once where request exists, then read back from wherever it doesn't.
  • A check that fans out across a page. A changelist renders 50 rows and calls can_edit(obj) for each. If can_edit starts with something request-wide — "is this user a reviewer this month" — that shouldn't be recomputed 50 times.
  • You're about to write request._cached_thing = .... This pattern already exists in most Django codebases: stash something on request in middleware, read it back everywhere else. It works, but it only works where request is reachable, and it leaks the caching detail into every call site. stash.get_or_set is the same idea, usable from anywhere, without a request reference.
  • A value that must be refreshed mid-request after a write. Read settings early; a view updates them; later code in the same request must see the new value. One stash.clear("site_settings") call after the write handles it — see clear below.
  • Batch scripts, one memo per unit of work. Wrap each row/item in stash_scope() so lookups inside it are cheap, and nothing survives to the next item.

Where this doesn't fit

  • You want the answer shared across requests, workers, or deploys. That's Django's cache framework (django.core.cache, backed by Redis/Memcached/the DB). Stash never outlives one scope — put it in front of that cache as L1 if you want both.
  • The value needs to reach other processes. Stash is per-process, per-scope. One worker's stash tells another worker nothing.
  • A pure function with no invalidation need, no request in the picture. functools.lru_cache is simpler and doesn't need a scope at all.

Alternatives

Scope Needs request? Cost per hit Crosses requests / processes? Invalidation
stash One request, or a stash_scope() block No Dict lookup Never Automatic when the scope ends; clear() any time before that
django-request-cache One request Yes — exposes the whole request globally to get one Attribute lookup No Automatic at request end only
request._cached_x (manual) One request Yes, at every call site Attribute lookup No Manual, ad hoc
Django's cache framework Until TTL, eviction, or delete No locmem: lock + dict. Redis/Memcached/DB: network round trip + (de)serialization, every call Yes — that's the point TTL, cache.delete(), or signals
functools.lru_cache Process lifetime No Dict lookup Accidentally, forever — same answer until the process restarts None, short of calling cache_clear() yourself
Bare thread-local / module global However long you remember to keep it valid No Dict/attribute lookup Accidentally — sync workers reuse a thread across requests Whatever you remember to write, wherever you remember to write it

The last row is the trap: a hand-rolled threading.local() or asgiref.Local() looks identical to stash until a worker process reuses its thread for a second request and the old value is still sitting there. Stash's storage is the same mechanism (asgiref.local.Local), but the lifetime is never left to memory — StashMiddleware opens and closes the scope on every single request, so there's no window where a stale value can survive into the next one.

django-request-cache takes the same idea a step further: instead of exposing one named value, it makes the whole request object reachable from anywhere first (via django-userforeignkey's get_current_request()), then hangs a cache off it as an attribute. That's an extra dependency, and a much bigger object made globally available than most call sites need. Stash's storage never holds request — only the specific values you chose to stash, by name.

Django's cache framework (django.core.cache) is still the right choice the moment a value needs to survive past one request — nothing here replaces it. The trade-off in the table above is the one to remember: reach for the cache framework when a value is shared across requests or processes, and put stash in front of it when the same value is read more than once inside a single request.

Usage

get_or_set

import stash

def get_feature_flags():
    return stash.get_or_set("feature_flags", load_feature_flags_from_db)
  • First call in a request: runs load_feature_flags_from_db(), stores the result, returns it.
  • Later calls in the same request: returns the stored result. The loader is not called.
  • After the request ends: the stored value is gone.

memoize

Same thing, as a decorator:

import stash

@stash.memoize
def get_exchange_rate(currency):
    return ExchangeRate.objects.get(currency=currency).rate

get_exchange_rate("EUR") and get_exchange_rate("USD") are remembered separately (the key is built from the arguments). Use @stash.memoize(key="...") for a fixed key that ignores arguments.

get, set, clear

stash.set("user_permissions", perms)
stash.get("user_permissions")            # -> perms, or None
stash.get("missing", default=[])         # -> []

stash.clear("user_permissions")          # forget one value
stash.clear()                            # forget everything for this request

clear is what you call after writing to the underlying data, so later reads in the same request see the new state:

def update_site_settings(**changes):
    SiteSettings.objects.filter(pk=1).update(**changes)
    stash.clear("site_settings")

Outside a request

Nothing is stored unless a scope is open. In a management command, Celery task, or shell:

stash.get_or_set("k", loader)   # just calls loader() every time
stash.set("k", value)           # does nothing
stash.get("k")                  # -> None

That is deliberate: a long-lived worker never accumulates stale values by accident.

If you want the same per-unit-of-work behaviour there, open a scope yourself:

with stash.stash_scope():
    process_batch()   # values remembered for this block only

Behaviour in one table

Situation get_or_set("k", loader)
Inside a request, first call runs loader(), stores, returns
Inside a request, later call returns stored value
After stash.clear("k") runs loader() again
Different request runs loader() again
Different thread / process runs loader() again
No middleware / no scope runs loader() every time; stores nothing

Notes

  • Values are stored and returned as shallow copies. Mutating what you get back does not change what is stored. Don't rely on identity.
  • Stash is an L1 in front of whatever you already do. If you also want cross-process sharing, keep using Django's cache as L2 inside your loader.
  • Works under WSGI and ASGI, with sync or async views. The middleware is sync- and async-capable, so it does not force Django to adapt the rest of the chain. Storage is asgiref.local.Local.

Development

cd django-stash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[testing,development]"
python testmanage.py test

Metadata

Release files for django-stash 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-stash 0.2.1
File Size Uploaded
django_stash-0.2.1.tar.gz 8.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-stash 0.2.1
File Interpreter ABI Platform
django_stash-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 17.2 kB

Release files / django_stash-0.2.1.tar.gz

Download URL django_stash-0.2.1.tar.gz
Size 8.2 kB
Tags Source
SHA-256 checksum
How to use checksums
8f4e8165acac479a114b36a247161b8e6a77f4548450ae539904475bb9b7ae8d
BLAKE2b-256 checksum
How to use checksums
f43d027cf8c265fdbfb41bc1fd75018b811df4bd2c2cbf0031083b97b7da52be
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 12, 2026.

Transparency log

Release files / django_stash-0.2.1-py3-none-any.whl

Download URL django_stash-0.2.1-py3-none-any.whl
Size 9.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a3e351ea334659ff7e50f51bd2a2df1ea466c7b352d58443f79de8f9ed75a39a
BLAKE2b-256 checksum
How to use checksums
be4f4ba2140884b010213a88e2d43fd3d0e7ef873eeb10265a80bd1e73a8428e
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 12, 2026.

Transparency log

Release history Release notifications | RSS feed

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page