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 NX and 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, and WAIT narrows 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 protectsUse
An optimization, so double execution is merely wastefulSET NX PX, single instance, and move on
Correctness, where double execution corrupts dataNot 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.

AlgorithmMemory per callerBoundary burstAllows a burst on purpose
Fixed window1 counterUp to 2× the limitNo
Sliding window logOne member per requestNoneNo
Token bucket2 hash fieldsNoneYes, 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
NeedUseCost
Gate duplicate processing, exactlySET NX EX per idOne key per id, each self-expiring
Membership plus set algebraA set with a TTLGrows with ids, one TTL for all
Unique count at scaleHyperLogLog12KB flat, 0.81 percent error
Dense small integer idsA bitmap1 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.

CommandWhat it doesComplexity
GEOADD key [NX|XX] [CH] lon lat member ...Add or update positionsO(log n)
GEOPOS key m [m ...]Longitude and latitude per memberO(1) per member
GEODIST key m1 m2 [M|KM|FT|MI]Distance between two membersO(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 stringsO(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

ProblemStructureKey commands
Cache a row or a rendered pageString with a jittered TTLSET ... EX, UNLINK
An object with fields you updateHashHSET, HGETALL, HINCRBY
Counters that share one expiryHashHINCRBY plus one EXPIRE
FIFO work queueListLPUSH, BLMOVE, LREM
Last N events per userList, cappedLPUSH plus LTRIM
Tags, followers, set algebraSetSADD, SINTER, SINTERCARD
Ranking or top-NSorted setZINCRBY, ZRANGE ... REV
Schedule by timestampSorted setZADD, ZRANGE ... BYSCORE
Sliding-window rate limitSorted setZREMRANGEBYSCORE, ZCARD, ZADD
Event log with acks and replayStreamXADD, XREADGROUP, XACK
Live fan-out, loss acceptablePub/SubPUBLISH, SUBSCRIBE
Unique counts at scaleHyperLogLogPFADD, PFCOUNT
Dense boolean flags per idBitmapSETBIT, BITCOUNT, BITOP
Nearest-by-locationGeo (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.