Redis Cheatsheet
Patterns
Use this Redis reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Cache-aside
The default caching shape. Read the cache, and on a miss read the source of truth and populate. Redis never talks to your database, your application does.
def get_user(r, db, uid): key = f"hu:user:{uid}" cached = r.get(key) if cached is not None: return json.loads(cached) row = db.fetch_user(uid) # miss, go to the source if row is None: # cache the negative result briefly, or a bad id becomes a DB hammer r.set(key, "null", ex=30) return None r.set(key, json.dumps(row), ex=300 + random.randint(0, 60)) return row
Three things make the difference between this working and not:
- Jitter the TTL. A thousand keys written in the same second by a deploy all expire in
the same second, and every one of them stampedes the database at once. ex=300 + rand(0,60)
spreads the reissue over a minute and costs nothing.
- Cache negatives too, briefly. Without it, a scanner probing nonexistent ids goes
straight through to your database on every request.
- Invalidate on write, do not update. DEL (or UNLINK) the key when the row changes.
Writing the new value into the cache from the writer races other writers and leaves stale
data, while deleting means the next reader repopulates from the truth.
def update_user(r, db, uid, **fields): db.update_user(uid, **fields) r.unlink(f"hu:user:{uid}") # invalidate AFTER the commit, never before
Order matters. Invalidating before the commit lets a concurrent reader repopulate the cache with the old row, which then sticks for the full TTL.
Cache stampede
When a hot key expires, every concurrent request misses at once and they all recompute. Three mitigations, in increasing cost.
1. A recompute lock. One request wins the right to recompute, the rest serve stale or wait.
def get_with_lock(r, db, key, ttl=300): val = r.get(key) if val is not None: return json.loads(val) # only one caller gets the lock, and it self-expires if that caller dies if r.set(f"{key}:lock", "1", nx=True, ex=10): try: fresh = db.compute(key) r.set(key, json.dumps(fresh), ex=ttl + random.randint(0, 60)) return fresh finally: r.delete(f"{key}:lock") time.sleep(0.05) # lost the race, brief backoff then re-read return get_with_lock(r, db, key, ttl)
2. Early recomputation. Store the value with a logical expiry inside it and a longer real TTL, then refresh probabilistically before it actually expires, so a request never finds an empty key.
# Store {"v": value, "exp": deadline}, real TTL well beyond exp payload = json.loads(r.get(key)) if time.time() > payload["exp"] - random.uniform(0, 30): refresh_in_background(key) # serve the slightly stale value NOW return payload["v"]
3. Never expire, refresh out of band. A cron or worker rewrites the key on a schedule and the key has no TTL. The cache is always warm, at the cost of serving stale data during an outage of the refresher, and of holding keys you may no longer need.
Distributed locks, and the honest caveat
The single-instance lock is SET NX PX with a random token, released by a Lua
compare-and-delete.
# Acquire: a unique token per holder, and a TTL so a crash cannot deadlock token=$(head -c16 /dev/urandom | xxd -p) redis-cli SET hu:lock:job:9 "$token" NX PX 30000 # OK, or (nil) if held
-- Release: only if I still own it. EVAL <script> 1 hu:lock:job:9 <token> if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0
-- Extend a lease you still hold, rather than re-acquiring if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2])) end return 0
Every part of that is load-bearing:
- NX makes acquisition atomic. A GET then SET is not a lock.
- PX guarantees release even if the holder is killed. A lock with no TTL is a deadlock
waiting for a kill -9.
- The random token is why release is safe. A plain DEL would let a caller whose lease
already expired delete the next holder's lock, and that is the bug that silently allows
two workers to run at once.
- Lua for release is why the check and the delete cannot be split. GET then DEL from
the client has a window between them.
This is not a correct distributed lock, and no configuration of Redis makes it one. Replication is asynchronous, so a primary can acknowledge your
SET NXand fail over before the replica has it. The promoted replica does not know the lock exists and happily grants it to a second holder. There is no setting that closes this, because the cause is asynchronous replication, andWAITnarrows the window without closing it.
Redlock is the multi-node algorithm proposed to address that: acquire on a majority of N independent primaries within a fraction of the TTL, and count it held only if you got a majority with time to spare. It is implemented in several client libraries. It is also genuinely contested. Martin Kleppmann's 2016 critique argues it depends on bounded clock drift and bounded process pauses, neither of which a GC pause, a hypervisor stall, or an NTP step respects, and that without a fencing token (a monotonically increasing number the protected resource checks and rejects stale writers on) no lease-based lock can be safe for correctness. Salvatore Sanfilippo, Redis's author, published a rebuttal disputing the failure model. The disagreement was never resolved, and both arguments are still worth reading before you depend on either position.
The practical conclusion, stated plainly:
| Your lock protects | Use |
|---|---|
| An optimization, so double execution is merely wasteful | SET NX PX, single instance, and move on |
| Correctness, where double execution corrupts data | Not this. Use a fencing token, or a database transaction, or a system built for consensus, such as ZooKeeper or etcd |
If a duplicate run would send two charges, write two rows, or double-post a message, put the guard where the data lives: a unique constraint, a conditional update on a version column, or an idempotency key. Use the Redis lock to reduce contention, never to guarantee exclusion.
Session store
Sessions are the archetypal Redis workload: short lived, read on every request, and losing one means a re-login rather than lost data.
# One hash per session, whole-key TTL sliding on each request HSET hu:session:9f3a uid 1042 csrf tok123 ip 1.2.3.4 EXPIRE hu:session:9f3a 1800 # On each request: read and slide the window in one round trip HGETALL hu:session:9f3a EXPIRE hu:session:9f3a 1800 # Log out one device UNLINK hu:session:9f3a # Log out everywhere: keep a reverse index SADD hu:user:1042:sessions 9f3a SMEMBERS hu:user:1042:sessions # then UNLINK each
A hash beats one JSON string when you update fields individually, and per-field TTL
(HEXPIRE, 7.4+) lets a CSRF token expire faster than the session around it. The reverse
index needs its own cleanup, since a session key expiring does not remove the id from the
set, so either prune on read or re-add the set's TTL on every write.
Rate limiting
Fixed window, cheapest and least accurate. One counter per window, atomically.
-- EVAL <script> 1 hu:rl:ip:1.2.3.4 <limit> <window_ms> local n = redis.call('INCR', KEYS[1]) if n == 1 then redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2])) end if n > tonumber(ARGV[1]) then return {0, redis.call('PTTL', KEYS[1])} end return {1, n}
The flaw is the boundary. A limit of 100 per minute allows 100 requests at 0:59 and another 100 at 1:00, so the real worst case is 2× the limit over any sliding minute.
Sliding window with a sorted set, exact at the cost of memory proportional to the limit.
-- EVAL <script> 1 hu:rl:sw:1042 <now_ms> <window_ms> <limit> <unique_id> local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window) local n = redis.call('ZCARD', KEYS[1]) if n >= limit then return {0, n} end redis.call('ZADD', KEYS[1], now, ARGV[4]) redis.call('PEXPIRE', KEYS[1], window) return {1, n + 1}
One member per request inside the window, so a limit of 10,000 means 10,000 sorted-set members per caller. Correct, and not free.
Token bucket allows a burst and refills smoothly, which is usually what an API actually wants. The full script is in the Lua scripting topic. Pass the clock in as an argument so the script stays deterministic and replays identically.
| Algorithm | Memory per caller | Boundary burst | Allows a burst on purpose |
|---|---|---|---|
| Fixed window | 1 counter | Up to 2× the limit | No |
| Sliding window log | One member per request | None | No |
| Token bucket | 2 hash fields | None | Yes, up to capacity |
Job queue
The reliable list queue, BLMOVE into a per-worker processing list so a crash leaves the job
visible.
# Producer LPUSH hu:q:email "$job" # Consumer: claim atomically, work, then acknowledge by value job=$(redis-cli BLMOVE hu:q:email "hu:q:email:proc:$WORKER" RIGHT LEFT 0) process "$job" redis-cli LREM "hu:q:email:proc:$WORKER" 1 "$job"
Delayed jobs need a sorted set scored by the due timestamp, claimed atomically.
-- EVAL <script> 1 hu:sched:due <now_epoch> <limit> local due = redis.call('ZRANGE', KEYS[1], '-inf', ARGV[1], 'BYSCORE', 'LIMIT', 0, tonumber(ARGV[2])) if #due > 0 then redis.call('ZREM', KEYS[1], unpack(due)) end return due
Keep the limit in the hundreds, since unpack on a large table overflows the Lua stack. Both
patterns are at least once, so handlers must be idempotent, and neither has retry counts
or a dead-letter queue. When you need those, plus abandoned-work reclaim, stop hand-rolling
and use a stream with a consumer group, where XPENDING gives you a delivery count and
XAUTOCLAIM reassigns stalled entries.
Deduplication and idempotency
SADD returns 1 the first time it sees a member, which is a one-command idempotency guard.
# Exact, memory grows with the number of ids if [ "$(redis-cli SADD hu:seen:webhook "$event_id")" = "1" ]; then process "$event_id" fi redis-cli EXPIRE hu:seen:webhook 86400
# Per-event key instead, so each id expires on its own schedule redis-cli SET "hu:seen:$event_id" 1 NX EX 86400 # OK = first time, nil = duplicate
# Approximate, fixed 12KB, 0.81 percent error, for counting rather than gating PFADD hu:uv:2026-09-15 "user:1042" PFCOUNT hu:uv:2026-09-15 PFCOUNT hu:uv:2026-09-15 hu:uv:2026-09-14 # union without storing it
| Need | Use | Cost |
|---|---|---|
| Gate duplicate processing, exactly | SET NX EX per id | One key per id, each self-expiring |
| Membership plus set algebra | A set with a TTL | Grows with ids, one TTL for all |
| Unique count at scale | HyperLogLog | 12KB flat, 0.81 percent error |
| Dense small integer ids | A bitmap | 1 bit per id, SETBIT plus BITCOUNT |
A set and a HyperLogLog are not interchangeable. PFADD cannot tell you whether a specific
member was seen, so it counts and never gates.
Leaderboards and counters
ZINCRBY hu:lb:2026-09 1 "user:1042" # award a point ZRANGE hu:lb:2026-09 0 9 REV WITHSCORES # top 10 ZREVRANK hu:lb:2026-09 "user:1042" WITHSCORE # rank and score, 7.2+ ZREMRANGEBYRANK hu:lb:2026-09 0 -1001 # trim to the top 1000 # A rolling 7-day board from 7 daily ones ZUNIONSTORE hu:lb:7d 7 hu:lb:d1 hu:lb:d2 hu:lb:d3 hu:lb:d4 hu:lb:d5 hu:lb:d6 hu:lb:d7 EXPIRE hu:lb:7d 3600
Rank is 0-based, so display rank + 1, and ties break lexicographically by member, which
means alphabetical order decides among equal scores. Encode a tiebreak into the score if that
matters, keeping the result inside the 2^53 exact-integer budget of a double.
ZADD ... GT records a high score with no read-modify-write race:
ZADD hu:lb:best GT CH 9500 "user:1042" # only moves the score upward
Geospatial
Geo commands are a sorted set with a geohash score, so every Z* command works on the same
key.
| Command | What it does | Complexity |
|---|---|---|
GEOADD key [NX|XX] [CH] lon lat member ... | Add or update positions | O(log n) |
GEOPOS key m [m ...] | Longitude and latitude per member | O(1) per member |
GEODIST key m1 m2 [M|KM|FT|MI] | Distance between two members | O(1) |
GEOSEARCH key <origin> <shape> [opts] | Search a radius or box (6.2+) | O(n+log m) |
GEOSEARCHSTORE dst src ... | Same, into a key (6.2+) | O(n+log m) |
GEOHASH key m [m ...] | Standard geohash strings | O(1) per member |
GEOADD hu:places -122.4194 37.7749 sf -74.0060 40.7128 nyc # LON first, then LAT GEODIST hu:places sf nyc km # "4130.2513" GEOPOS hu:places sf # Everything within 50km of a point, nearest first, with distances GEOSEARCH hu:places FROMLONLAT -122.4 37.8 BYRADIUS 50 km ASC WITHDIST # 1) 1) "sf" 2) "3.2715" GEOSEARCH hu:places FROMMEMBER sf BYRADIUS 10 km ASC COUNT 5 WITHCOORD WITHDIST GEOSEARCH hu:places FROMLONLAT -122.4 37.8 BYBOX 40 40 km ASC GEOSEARCHSTORE hu:near:sf hu:places FROMMEMBER sf BYRADIUS 10 km ASC COUNT 20
Longitude comes before latitude, which is the reverse of how everyone says "lat, long",
and it is the mistake in most first attempts. GEORADIUS and GEORADIUSBYMEMBER are the
deprecated predecessors of GEOSEARCH, still supported. Precision is around 0.5 metres and
the model is a sphere, so distances are great-circle approximations. There is no GEODEL
because ZREM is it.
Picking the structure
| Problem | Structure | Key commands |
|---|---|---|
| Cache a row or a rendered page | String with a jittered TTL | SET ... EX, UNLINK |
| An object with fields you update | Hash | HSET, HGETALL, HINCRBY |
| Counters that share one expiry | Hash | HINCRBY plus one EXPIRE |
| FIFO work queue | List | LPUSH, BLMOVE, LREM |
| Last N events per user | List, capped | LPUSH plus LTRIM |
| Tags, followers, set algebra | Set | SADD, SINTER, SINTERCARD |
| Ranking or top-N | Sorted set | ZINCRBY, ZRANGE ... REV |
| Schedule by timestamp | Sorted set | ZADD, ZRANGE ... BYSCORE |
| Sliding-window rate limit | Sorted set | ZREMRANGEBYSCORE, ZCARD, ZADD |
| Event log with acks and replay | Stream | XADD, XREADGROUP, XACK |
| Live fan-out, loss acceptable | Pub/Sub | PUBLISH, SUBSCRIBE |
| Unique counts at scale | HyperLogLog | PFADD, PFCOUNT |
| Dense boolean flags per id | Bitmap | SETBIT, BITCOUNT, BITOP |
| Nearest-by-location | Geo (a sorted set) | GEOADD, GEOSEARCH |
Two closing rules. Anything that reads a value and then writes based on it belongs in one Lua script, not in a client round trip. And Redis is a cache and a fast index, not a system of record, so every pattern here should survive losing the whole instance with nothing worse than a cold start.