skip to content

Is Django's cache.incr() atomic, and what happens when you call it on a key that does not exist yet?

level: middleimportance: should knowfreq 34%

answer

  1. depends on the backend
  2. native increment versus read-then-write
  3. missing key raises
  4. initialise with add first

basics

~10 s

Django's cache.incr() is atomic only on backends with a native increment, like Memcached and Redis; others read then write. A missing key raises ValueError, so initialise the counter with cache.add(key, 0) first.

solid answer

~40 s

`cache.incr(key, delta=1)` and `cache.decr()` add to a stored number and return the new value. The docs say they are **not guaranteed to be atomic**: the Memcached backends call the server's increment, `RedisCache` stores integers unpickled and uses Redis's own increment command, and `LocMemCache` increments under its lock within one process, but `DatabaseCache` and `FileBasedCache` inherit the base implementation, which reads the value and writes it back, so concurrent increments can be lost; that write-back also resets the entry to the alias's default timeout. Every built-in backend raises `ValueError` for a missing key, so the safe idiom for a leaderboard view counter is `cache.add(key, 0, timeout)` followed by `cache.incr(key)`. For counts that must not be lost, persist them in the database.

go deeper

for a junior

Recall that incr and decr change a stored number and that the key must exist first.

for a middle

Explain which backends increment natively, the ValueError on missing keys, and the add-then-incr idiom.

for a senior

Spot lost updates and timeout resets from the base fallback, and move exact counts to the database.

for a principal

Classify counters as approximate or exact and choose the store for each accordingly.

## What incr and decr do `cache.incr(key, delta=1, version=None)` adds `delta` to the stored value and returns the result; `cache.decr()` subtracts. Their async twins are `aincr()` and `adecr()`. They are handy for counters: page views on a leaderboard, rate-limit hits, a generation number. ## Atomicity depends on the backend Django's documentation is explicit: `incr()` and `decr()` are **not guaranteed to be atomic**. Backends with a native increment are atomic; the rest fall back to a two-step read and write. | Backend | Implementation | Atomic? | |---|---|---| | `PyMemcacheCache`, `PyLibMCCache` | Server `incr` / `decr` | Yes, across all clients | | `RedisCache` | Checks the key exists, then Redis's increment command on an unpickled integer | The increment itself, yes | | `LocMemCache` | Read and write under the store's lock | Within one process only | | `DatabaseCache`, `FileBasedCache` | Base `get()` then `set()` | No | With the base implementation, two requests can both read `41`, both write `42`, and one increment is lost. On a busy counter, losses are routine rather than rare. The Redis backend deliberately leaves plain integers unpickled when it stores them, precisely so that `INCR` on the server works and stays atomic. ## Missing keys raise ValueError Every built-in backend raises **`ValueError`** ("Key '...' not found") when you increment a key that does not exist or has expired. Django never creates the key for you. The idiom is: ```python from django.core.cache import cache key = "leaderboard:views:2026-09-26" cache.add(key, 0, timeout=86400) # no-op if the counter already exists views = cache.incr(key) ``` `add()` writes only when the key is absent, so it is safe to call on every request. There is still a small window where the key can expire between `add()` and `incr()`; catch `ValueError` and retry if that matters. ## Timeouts and incr - The **base** implementation writes the new value with `set()` and no timeout argument, so the entry gets the alias's **default `TIMEOUT`** again (300 seconds unless configured). A counter created with a 24-hour timeout on the database cache quietly shrinks to five minutes after the first increment. - `LocMemCache` updates the value in place and keeps the original expiry; the Memcached and Redis backends increment on the server, which does not change the key's expiry. - If exact lifetime matters, call `cache.touch(key, timeout)` after incrementing, or keep the counter on a backend whose increment is native. ## Values that are not integers `incr()` expects the stored value to support `+`. On the base implementation any such object works; on Redis and Memcached the stored value must be an integer, because the server does the arithmetic. Store counters as plain `int`. ## A rate-limit example A soft limit of 100 leaderboard API calls per client per minute is a typical cache counter: 1. Build a key from the client and the current minute, for example `ratelimit:leaderboard:<client id>:<minute>`. 2. `cache.add(key, 0, 60)` so the counter exists and expires with the minute. 3. `count = cache.incr(key)`; if `count` exceeds 100, reject the request. On Redis or Memcached this is atomic and the counter expires on schedule. On `DatabaseCache` two things break: concurrent increments can be lost, letting clients exceed the limit, and each increment resets the timeout to the alias default. On `LocMemCache` each worker process keeps its own counter, so the real limit is 100 times the number of processes. ## When not to use cache counters Caches can lose data: eviction under memory pressure, a restart, `LocMemCache` being per process. A cache counter is fine for approximate numbers, like "views in the last hour" or a soft rate limit. Anything that must be exact, such as a player's points, belongs in the database, updated with an `F()` expression inside the write that changes it.

  • Why does RedisCache store integers without pickling them?
    So that Redis itself can do the arithmetic. Redis's increment commands only work on values it can parse as integers; a pickled byte string would not qualify. Leaving plain `int` values unpickled lets `cache.incr()` map to one atomic server command.
  • A counter on DatabaseCache was created with a one-day timeout but disappears after about five minutes. Why?
    `DatabaseCache` uses the base `incr()`, which reads the value and writes it back with `set()` and no timeout, so each increment resets the entry to the alias's default `TIMEOUT` of 300 seconds. Pass the timeout again with `touch()` after incrementing, or use a backend with a native increment.

saying these in an interview costs you the question

  • cache.incr() creates the key with value 1 if it is missing.
  • cache.incr() is atomic on every Django cache backend.
  • LocMemCache increments are atomic across worker processes.
  • Incrementing never changes a key's expiry on any backend.
  • Cache counters are a safe place for balances or scores.