skip to content

Why does Django's LocMemCache backend give inconsistent results once a site runs under several worker processes?

level: middleimportance: must knowfreq 62%

answer

  1. where the dict actually lives
  2. module-level store per process
  3. threads share, processes do not
  4. delete in one worker, stale in others

basics

~20 s

LocMemCache keeps entries in a Python dict inside each process. With several worker processes every worker has its own private copy, so a set or delete in one worker is invisible to the others and readers see stale or missing values.

solid answer

~40 s

`LocMemCache` stores data in module-level dictionaries keyed by `LOCATION`, protected by a lock, so it is **thread-safe but per-process**. A WSGI or ASGI server that forks several workers gives each worker its own dictionaries. Consequences: a `cache.delete()` or fresh `cache.set()` done while handling one request only updates that worker; other workers keep serving the old value until it times out, hit rates drop because each worker warms its own copy, memory is multiplied by the worker count, and counters such as rate limits are split. Django's docs say as much: no cross-process caching is possible, so it is fine for development and tests, but a multi-process deployment needs a shared backend such as `RedisCache`, `PyMemcacheCache` or `DatabaseCache`.

go deeper

for a junior

Recall that LocMemCache is Django's default backend and that its data lives inside one Python process.

for a middle

Explain module-level stores keyed by LOCATION, the lock that makes threads safe, and why forked workers each get their own copy.

for a senior

Diagnose the split-brain symptoms, stale pages after invalidation and low hit rates, and move shared data to Redis, Memcached or the database cache.

for a principal

Decide which data may legitimately be per-process memoised and which must be shared, and make that split visible through separate aliases.

## How LocMemCache stores data `django.core.cache.backends.locmem.LocMemCache` is Django's **default** cache backend. Its data lives in three module-level dictionaries inside `locmem.py`, one set per `LOCATION` name: - a `_caches` dict holding an ordered dict of pickled values, - an `_expire_info` dict holding each key's expiry timestamp, - a `_locks` dict holding a `threading.Lock` per store. Every `LocMemCache` instance created for the same `LOCATION` in the same Python process points at the same ordered dict. That is why the docs call it **thread-safe**: threads inside one process share the store, and the lock serialises access. When the store grows past `MAX_ENTRIES` (default `300`), it culls entries using a least-recently-used ordering. ## Why processes break it A Python module-level variable exists once **per interpreter process**. Production servers normally run several **worker processes** so they can use several CPU cores and survive a crashed worker. Each worker imports Django separately, so each has its own `_caches` dict. Nothing synchronises them. Picture four workers behind one socket: 1. A request lands on worker 1, misses, computes the value and calls `cache.set()`. Only worker 1's dict now holds it. 2. The next three requests land on workers 2, 3 and 4. Each misses and recomputes. 3. An editor saves a change; the save path calls `cache.delete()` on worker 3. Workers 1, 2 and 4 still hold the old value and keep serving it until `TIMEOUT` (default `300` seconds) runs out. ## The symptoms you see in production | Symptom | Cause | |---|---| | Page shows old data for some users, new data for others | Invalidation reached only the worker that ran it | | Cache hit rate much lower than expected | Each worker warms its own copy | | Memory grows with worker count | The same entries are stored once per process | | Rate limits or counters are too generous | `incr()` updates a per-worker counter | | "Works on runserver, fails in production" | The development server runs one process | The last row is the classic interview hook: `runserver` runs a single process, so the bug never appears locally. ## What to use instead The fix is a backend whose storage lives **outside** the Python process, so every worker on every machine reads and writes the same entries: - **`RedisCache`** or **`PyMemcacheCache` / `PyLibMCCache`**: a cache server shared by all workers and all hosts. - **`DatabaseCache`**: a table in the project database, shared by everything that connects to it; slower, but needs no extra service. - **`FileBasedCache`**: files in a directory, shared only by processes that can see that directory, so usually by one machine. ## Confirming the diagnosis Before changing backends, prove that the per-process store is the cause: 1. Check what is actually configured: in `manage.py shell`, `from django.core.cache import caches` and print `type(caches["default"])`. A project that never set `CACHES` is running `LocMemCache` without anyone having chosen it. 2. Count processes: look at how many worker processes the application server starts, and on how many hosts. 3. Reproduce with two processes locally: start the application server with two workers instead of `runserver`, set a value through one request and read it through repeated requests; roughly half will miss. 4. Watch the symptom correlate with worker identity: log the process ID next to cache hits and misses, and stale reads will cluster on the workers that did not run the invalidation. If the stale reads persist after switching to a shared backend, the cause lies elsewhere, for example in whole-page caching or in HTTP caches outside Django, which are separate topics. ## When LocMemCache is still the right choice - **Development and tests**, where one process runs and isolation between test runs is welcome. - **A deliberate per-process memo** for data that never changes while the process lives and is cheap to rebuild, declared as a second alias with its own `LOCATION` so it is obviously not the shared cache. - **Single-process deployments**, where there is only one copy anyway. Two subtleties worth stating in an interview: - Two `LocMemCache` aliases that share a `LOCATION` (including the empty default) share one store inside a process; give each a distinct name to keep them apart. - Threads do **not** cause the problem. A single process with many threads sees one consistent store; only separate processes diverge.

  • Does running one worker process with many threads avoid the LocMemCache problem?
    Yes for consistency: threads in one process share the module-level store, and its lock keeps access thread-safe, so every thread sees the same entries. It does not help once you scale to a second process or a second host, which is why a shared backend is the usual production answer.
  • Why do two LocMemCache aliases sometimes read each other's keys?
    Local-memory stores are keyed by `LOCATION`. Two aliases with the same `LOCATION`, including the empty default, point at the same dictionary in the process, so their keys collide unless `KEY_PREFIX` differs. Giving each alias a distinct `LOCATION` separates them.

LocMemCache is a sticky note on each cashier's till. When one cashier crosses out a price, the notes at the other tills still show the old price until they are thrown away.

saying these in an interview costs you the question

  • LocMemCache is unsafe with multiple threads in one process.
  • Worker processes share LocMemCache because they run the same settings module.
  • Calling cache.delete() clears the key in every worker.
  • LocMemCache is fine in production because Django picks it as the default.
  • Switching to FileBasedCache shares entries across every host in a cluster.