Skip to main content

django-stash

PyPI 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. A scope has to be open for stash.set to stick — StashMiddleware, or stash_scope() in this same class. See Install.

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

Nothing is stored unless a scope is open. No INSTALLED_APPS entry needed.

If you want to use django-stash in an app you own — a website or web app — StashMiddleware makes it easy to get started:

MIDDLEWARE = [
    "stash.middleware.StashMiddleware",
    # ...
]

If you want to use it in a package (an add-on, a performance monitoring tool, or a CMS framework), open the scope from middleware you already require, or add one of your own. That way installers don't have to add an unfamiliar third-party middleware to their project settings:

import stash

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

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

The same with works in async middleware.

Do not open the HTTP scope from a view. Django renders TemplateResponse after the view returns — TemplateView, ListView, and DetailView all do this — so a mixin on dispatch(), a decorator on as_view(), or with stash_scope(): around the view body would close before template tags run. Middleware is the only opener that covers the full request/response cycle: get_response includes the view and that deferred render. StashMiddleware does it for an app you own; in a package, wrap get_response as above.

with stash_scope(): inside a view is still fine for work that finishes before you return — a loop, a loader. That is a nested block, not the request opener.

Management commands sit outside the request cycle, so StashMiddleware never runs. Mix StashCommandMixin onto the command so each invocation gets a stash scope — in an app you own, or in a package (installers never see it):

from django.core.management.base import BaseCommand

import stash

class Command(stash.StashCommandMixin, BaseCommand):
    def handle(self, *args, **options):
        tenant = stash.get_or_set("tenant", load_tenant)
        self.stdout.write(str(tenant))

In a package, set stash_scopes = ("wagtail",) so that named scope is open for the whole run. The mixin still opens the default too.

These openers compose. A package stash_scope() around get_response is safe even if the project also uses StashMiddleware — keep StashMiddleware first so it opens the request, then later middleware stacks on it.

Keys in the default scope are shared with the project. If a value is yours alone, give it a named scope so nothing else can read, overwrite, or clear it.

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. Mix StashCommandMixin onto a management command to scope the whole run, and wrap each row in stash_scope() so per-item memos die with the block. See Nested scopes.

Where this doesn't fit

Skip stash when the value is easy to pass, cheap to compute, or belongs in a different tool:

  • You can pass the value — or request — through a few functions you own. view → helper → do_the_thing can take tenant as an argument. That's a clearer API: the dependency is in the signature, and the function is easy to test. Stash is for call sites that don't share a chain — a template tag, a signal handler, a model method — not for skipping an argument you'd rather not write.
  • The value is only needed in one place. Compute it there. Remembering it only pays off when unrelated code needs the same answer during one request.
  • The lookup is cheap. A dict access, a simple attribute, an already-loaded relation. Stash is for work you don't want to repeat — a query, a permission that fans out, a hostname lookup.
  • Opening the HTTP scope from a view mixin or decorator. Django renders TemplateResponse after the view returns, so template tags would miss. Use middleware — it is the only way to cover the full request/response cycle. See Install.
  • Secrets, tokens, passwords, or anything you would not put in Django's cache. Stash is ambient: any code in the same scope can stash.get the value by name. A named scope only keeps your keys apart from other packages; it does not hide anything. Leave credentials out.
  • 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, a management command run, 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, StashCommandMixin, or stash_scope() opens and closes the scope around each unit of work, 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 stored here

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")

Nested scopes

stash_scope() with no name uses the same default scope as StashMiddleware and StashCommandMixin. Nesting one inside the other does not wipe the outer values:

class Command(stash.StashCommandMixin, BaseCommand):
    def handle(self, *args, **options):
        tenant = stash.get_or_set("tenant", load_tenant)
        for row in rows:
            with stash.stash_scope():
                # tenant is still visible here
                process(row)   # per-item memos live only in this block
        # tenant is still here; per-item memos are gone

Writes in the inner with stay there. Reads look in the inner block first, then the outer one. clear("tenant") forgets that key in both; clear() with no key only forgets what the inner block stored.

Named scopes

A name is a separate namespace. stash.get("page") never sees stash.get("page", scope="wagtail"), and clear("page") in one does not touch the other.

In a reusable package, use a unique name for values that are yours. You cannot know whether the project — or another package — already uses "page" or "tenant". Pick the package name, open that scope from middleware you control, and pass scope= at every call site, including @stash.memoize(scope="wagtail"):

import stash

class PageMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        with stash.stash_scope("wagtail"):
            stash.set("page", resolve_page(request), scope="wagtail")
            return self.get_response(request)


def get_current_page():
    return stash.get("page", scope="wagtail")

Management commands need the same name on the mixin, or get_or_set(..., scope="wagtail") will not store:

class Command(stash.StashCommandMixin, BaseCommand):
    stash_scopes = ("wagtail",)

Leave the name off when the rest of the project is supposed to read the value — the current tenant, the current site. That is the Install example: stash.set("tenant", ...) with no scope=, so stash.get("tenant") works from models, template tags, and signals. Document those keys.

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.

For a management command, mix in StashCommandMixin so each run gets a scope — the same idea as StashMiddleware for HTTP. See Install. Per-item memos go in a nested stash_scope() as in Nested scopes. In a Celery task or shell, open the scope yourself:

with stash.stash_scope():
    process_item()

A test is not a special case: no scope, no stored value. See Testing.

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 scope runs loader() every time; stores nothing

Notes

  • Do not stash secrets. Same rule as Django's cache: no passwords, API keys, session tokens, or raw credentials. See Where this doesn't fit.
  • 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.

Testing

Nothing is stored unless a scope is open, and a test does not get one for free. stash.set with no scope does nothing, stash.get returns the default, and stash.get_or_set calls the loader on every call — the same rule as outside a request. If a helper opened a scope for every test, the suite would stay green after StashMiddleware disappeared from production. Nothing below turns itself on unless the test asks.

Open the scope the same way production does for the code under test.

Through the real opener

HTTP. Put StashMiddleware in the test MIDDLEWARE — the list you actually ship — and use the Django test client. The client runs middleware, so the scope covers the view and a deferred TemplateResponse. Using RequestFactory, or calling the view yourself, skips middleware, so it skips the scope too.

StashMiddleware does not join a scope the test already opened. It calls enable() at the start of the request, which drops whatever was there, and disable() when the response is done. stash.set("tenant", ...) in the test, then self.client.get(...), does not show that tenant to the view, and stash.get after the client returns is a miss. Put the tenant where the middleware actually finds it, and assert on the response:

response = self.client.get("/", HTTP_HOST="acme.test")
self.assertContains(response, "acme")
self.assertFalse(stash.enabled())

A package that opens the scope with with stash.stash_scope(): around get_response stacks, instead of replacing. An outer scope opened in the test is visible for that request; writes inside the with still die with it. If StashMiddleware is also installed and listed first, its enable() has already dropped that outer scope — same as production, where nothing is open before middleware.

Management commands. Use call_command, not Command().handle(). StashCommandMixin opens the scope in execute(), and calling handle() directly skips it. The mixin stacks on a scope the test already opened, and drops only its own frame when call_command returns. You usually don't need that outer scope — the mixin owns the run.

Calling a function directly

A template tag, a model method, get_current_tenant() from the top of this page — nothing is about to open a scope for you. Open one around the call. This does not prove the middleware is installed. The client test above is the one that proves that.

def test_current_tenant(self):
    tenant = Tenant(domain="acme.test")
    with stash.stash_scope():
        stash.set("tenant", tenant)
        self.assertEqual(get_current_tenant(), tenant)

@stash.stash_scope() on the method is the same block, when the whole method needs it. @stash.stash_scope("wagtail") is the named scope. It stacks, and on the way out it restores whatever was open outside. It does not call disable().

You do not need a scope to check the value from something that only uses get_or_set. No scope means the loader runs every time; the answer is still the answer. Open a scope when the assertion is "loaded once", or when the code uses set / get. A "called once" assertion that fails because the loader ran twice is the test saying the scope is not open — the same thing production does if the middleware is missing.

A whole class, or pytest

Repeating with stash.stash_scope(): gets old, and it will not notice a previous test that called enable() and never disable(). stash.testing is that fence. Entering it drops any scope already open. Leaving it drops them again, including one this test opened and did not close.

from django.test import TestCase

import stash

from stash.testing import StashTestMixin, activate


class TenantTests(StashTestMixin, TestCase):
    def setUp(self):
        super().setUp()  # scope is open after this
        stash.set("tenant", "acme")

    def test_current_tenant(self):
        self.assertEqual(get_current_tenant(), "acme")


class OneOffTests(TestCase):
    @activate()
    def test_current_tenant(self):
        stash.set("tenant", "acme")
        self.assertEqual(get_current_tenant(), "acme")

    @activate("wagtail")
    def test_page(self):
        stash.set("page", "home", scope="wagtail")
        self.assertEqual(stash.get("page", scope="wagtail"), "home")

Mix StashTestMixin in ahead of TestCase, the same way StashCommandMixin goes ahead of BaseCommand. It always opens the default scope. stash_scopes = ("wagtail",) — or a single string — also opens that named scope, the same attribute as on the command mixin. Call super().setUp() before stash.set; the scope is not open until you do. It is still open in tearDown. Subtests share it. stash.clear(), or a nested stash_scope(), when one of them needs a fresh memo.

activate is the same fence for one method, or for a with block that must not see anything from outside. Don't use it for a nested block whose outer values you still need afterwards: exit calls disable(), and the outer scope is not put back. That is what stash_scope() is for.

Don't combine the mixin or activate() with the test client when StashMiddleware is installed. The middleware's enable() / disable() throws that scope away for the request and does not restore it.

pytest, when django-stash is installed:

import pytest

import stash


@pytest.mark.stash_scope
def test_current_tenant():
    stash.set("tenant", "acme")
    assert stash.get("tenant") == "acme"


@pytest.mark.stash_scope("wagtail")
def test_page():
    stash.set("page", "home", scope="wagtail")
    assert stash.get("page", scope="wagtail") == "home"


def test_current_tenant_via_fixture(stash_scope):
    stash.set("tenant", "acme")
    assert stash.get("tenant") == "acme"

The marker and the stash_scope fixture do what activate() does: a fresh default scope, plus any names passed to the marker, cleared when the test ends. The marker applies to a function, a class, or a module (pytestmark = pytest.mark.stash_scope). Requesting the fixture as well is fine; it still uses the marker's names. Neither turns itself on for a test that didn't ask. If plugin autoload is off, pass -p stash.pytest_plugin.

No scope, on purpose

Some tests should prove the inactive path: a command without the mixin, a branch that runs in the shell. Leave the scope closed. stash.disable() in setUp and tearDown, so a leak from another test cannot look like this one passed:

def setUp(self):
    super().setUp()
    stash.disable()


def tearDown(self):
    stash.disable()
    super().tearDown()

The mixin and activate already do that around the tests that opt in. They do not do it for the rest of the suite.

A scope opened in the test is visible to Django's async def test methods. It is not visible to a threading.Thread you start yourself — a request's scope stays on the request thread too.

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.5.0

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.5.0
File Size Uploaded
django_stash-0.5.0.tar.gz 23.4 kB Details

Built distribution (wheel)

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

Total release size: 42.3 kB

Release files / django_stash-0.5.0.tar.gz

Download URL django_stash-0.5.0.tar.gz
Size 23.4 kB
Tags Source
SHA-256 checksum
How to use checksums
609277cedc2934e95fb04b67c34430cbece44c154581a6b4f60b1eb0b4a75fd5
BLAKE2b-256 checksum
How to use checksums
cccbb73eb78f29502a60c8be4ccb38f1daef749362704882c548b96bcfbd4a0f
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 27, 2026.

Transparency log

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

Download URL django_stash-0.5.0-py3-none-any.whl
Size 18.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e10efdbae323f14ca87e19337363789ea417f0ebebba9864e1167d9f92ba040b
BLAKE2b-256 checksum
How to use checksums
44be10a43b1b1bd6517bbf65b6d5e7271055ccd72a147c1c326e5f12d9f48a73
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 27, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

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