Home Database Redis Data Structures — Memory Eviction from Missing TTL
Intermediate 11 min · March 05, 2026

Redis Data Structures — Memory Eviction from Missing TTL

Random auth failures from Redis memory eviction caused by String keys without TTL.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 25 min
  • Solid grasp of fundamentals
  • Comfortable reading code examples
  • Basic production concepts
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Strings: single values up to 512MB, atomic counters, TTL in one command
  • Hashes: field-value maps for objects, granular updates, memory-efficient under 128 fields
  • Lists: ordered sequences, O(1) push/pop from ends, ideal for queues with BRPOP
  • Sets: unordered unique members, set algebra O(N), great for tags and deduplication
  • Sorted Sets: score-ordered members, O(log N) insert/range, powers leaderboards and time-range queries
  • Performance insight: server-side operations (INCR, SINTER, ZRANGE) avoid network round-trips and keep app logic thin
  • Production insight: wrong type choice (e.g., String for multi-field objects) leads to serialization overhead, race conditions, and wasted memory
✦ Definition~90s read
What is Redis Data Structures?

Redis is an in-memory key-value store that distinguishes itself from simple caches like Memcached by offering a rich set of data structures beyond plain strings. It exists to solve the problem of needing fast, atomic operations on complex data types—like sorted sets for leaderboards, lists for message queues, or hashes for object storage—without serializing to JSON or hitting a relational database.

Imagine your kitchen has different storage containers: a jar for sugar (one thing, quick to grab), a recipe card box organized by category (fields and values), a stack of plates (order matters), a bag of unique coins (no duplicates allowed), and a leaderboard on your fridge with scores next to names.

Each structure has its own memory footprint and command complexity (O(log n) for sorted sets vs O(1) for hash field access), which directly impacts eviction behavior when you omit TTLs. Without TTL discipline, Redis's maxmemory policy (e.g., allkeys-lru or volatile-ttl) will evict your most critical data first, turning a real-time leaderboard into a ticking time bomb.

The decision tree in this article helps you choose between structures based on access patterns: use hashes for partial updates on user profiles, sorted sets for ranking with score-based queries, and lists for FIFO job queues. But every structure shares one hard truth—if you don't set TTLs, Redis will evict them for you, and it won't ask permission.

Plain-English First

Imagine your kitchen has different storage containers: a jar for sugar (one thing, quick to grab), a recipe card box organized by category (fields and values), a stack of plates (order matters), a bag of unique coins (no duplicates allowed), and a leaderboard on your fridge with scores next to names. Redis is like that kitchen — it gives you the exact right container for what you're storing, so you're never cramming spaghetti into a sugar jar.

You’ve used Redis for caching, sure. But its value lives in the data structures themselves—strings, hashes, lists, sets, sorted sets, bitmaps, and HyperLogLog. Pick the wrong one or forget TTL discipline, and you’ll paint yourself into a corner with memory bloat or O(n) bottlenecks. This article walks each structure, its real-world job, and the command complexity you need to know before your next deploy.

Why Redis Data Structures Need TTL Discipline

Redis data structures are in-memory key-value stores where each key maps to one of several native types: strings, hashes, lists, sets, sorted sets, bitmaps, hyperloglogs, streams, or geospatial indexes. The core mechanic is that every operation runs in O(1) or O(log n) time, but memory is finite. When no TTL (time-to-live) is set, keys persist indefinitely, and Redis must evict data under the configured maxmemory policy once RAM is exhausted. This eviction is not a bug—it's a deliberate trade-off between availability and data retention.

In practice, Redis uses an approximate LRU (or LFU, TTL-based, random) algorithm to select keys for eviction. The default policy is noeviction, which causes writes to fail with an OOM error. More common policies like allkeys-lru or volatile-lru evict keys without TTL or only those with TTL, respectively. The key property: eviction happens at write time, not proactively. A sudden write spike can trigger mass eviction, removing data you assumed was safe. Without TTLs, all keys are eligible under allkeys-* policies, making your cache behavior unpredictable.

Use this knowledge to design for eviction. In caching layers, always set TTLs to bound memory usage and control eviction scope. For session stores or rate limiters, use volatile-* policies so only expirable keys are evicted. In real systems, a missing TTL on a hot key can cause a cascade: the key gets evicted, the next request recomputes it (expensive), and the surge of recomputation overloads the backend. TTLs are not optional—they are the primary lever for predictable memory management.

⚠ TTL Is Not a Suggestion
A key without TTL is a permanent resident. Under allkeys-lru, it can be evicted at any moment—even if you never set an expiry. Always set TTLs to control eviction scope.
📊 Production Insight
Teams using Redis as a session store without TTLs see sudden logouts under load as allkeys-lru evicts old sessions.
Symptom: users report being logged out mid-session, and Redis memory graphs show a sawtooth pattern of evictions.
Rule: always set a TTL on session keys and use volatile-lru to protect non-expirable data from eviction.
🎯 Key Takeaway
Every key without TTL is a candidate for eviction under allkeys-* policies—plan accordingly.
Eviction happens at write time, not on a timer; a write spike can purge large portions of your dataset.
Choose your maxmemory-policy based on whether you can tolerate data loss: volatile-* for caches, noeviction for persistent stores.
redis-data-structures Redis Data Structure Eviction Hierarchy Layered view of memory management and data types Application Layer Write Requests | Read Requests | TTL Settings Data Structure Layer Strings | Hashes | Lists Eviction Policy Layer allkeys-lru | volatile-lru | allkeys-lfu Memory Management Layer maxmemory | Eviction Pool | Sample Size Storage Layer RAM | RDB Snapshots | AOF Logs THECODEFORGE.IO
thecodeforge.io
Redis Data Structures

Strings — Redis's Swiss Army Knife (and Why It's More Than Just Text)

The String type is the most deceptively simple structure in Redis. It stores a single value — bytes, really — up to 512 MB. That value can be plain text, a serialized JSON blob, a binary image, or an integer. When the value is an integer, Redis lets you increment and decrement it atomically with INCR and DECR, which is the key insight most beginners miss.

Atomic means no race condition. If two servers both call INCR on the same key at the exact same millisecond, Redis executes them sequentially — you get 1, then 2, never 1 and 1. This makes Strings the go-to choice for rate limiting and counting: page views, API calls, login attempts.

The second superpower is the SET options. You can set a value with an expiry in one atomic command using SET key value EX seconds. This is how session tokens work in Redis — store the token as the key, the user ID as the value, and attach a TTL. No separate expiry-management logic needed.

When should you NOT use a String? When your data has multiple fields. Storing a JSON blob like '{"name":"Alice","age":30}' as a String forces you to deserialize the whole thing to update one field. That's exactly when you want a Hash.

string_rate_limiter.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Rate limiting: allow max 5 requests per user per 60 seconds
-- The key encodes both the user ID and the current time window

-- User 42 makes their first request in this window
SET rate_limit:user:42:window:1700000000 0 EX 60 NX
-- NX means 'only set if key does NOT exist'
-- EX 60 means expire in 60 seconds automatically
-- This gives us a self-cleaning counter

-- Now increment on each request
INCR rate_limit:user:42:window:1700000000
-- Returns: (integer) 1

INCR rate_limit:user:42:window:1700000000
-- Returns: (integer) 2

-- Check value before allowing the request
GET rate_limit:user:42:window:1700000000
-- Returns: "2"

-- Simulate hitting the limit
INCR rate_limit:user:42:window:1700000000
INCR rate_limit:user:42:window:1700000000
INCR rate_limit:user:42:window:1700000000
-- Returns: 5 — at this point your app logic blocks the request

-- Check remaining TTL (useful to tell user when they can retry)
TTL rate_limit:user:42:window:1700000000
-- Returns: (integer) 47  (13 seconds have passed)
Output
(integer) 1
(integer) 2
"2"
(integer) 3
(integer) 4
(integer) 5
(integer) 47
💡Pro Tip: Use SET ... NX Instead of SETNX
The old SETNX command sets a key only if it doesn't exist, but it can't set a TTL in the same operation. Always use SET key value EX seconds NX — it's atomic, safe, and keeps your rate limiter from leaking keys that never expire.
📊 Production Insight
Storing JSON as a String for objects with independent fields causes serialization overhead on every update.
Rate limiter keys without TTL leak memory until the server runs out of RAM.
Rule: String for single values only; use Hashes for multi-field objects and always set TTL on time-bounded keys.
🎯 Key Takeaway
Strings are for single values, counters, and simple caches.
Use INCR and SET EX NX for atomic rate limiting.
Never store multi-field objects as a single String — you'll pay serialization cost on every update.

Command Complexity Reference Table

Understanding the time complexity of Redis commands is essential for writing efficient production code. Redis is single-threaded — a slow O(N) command blocks all other operations. The table below summarizes the complexity of common commands across data structures. Use it to decide whether your access pattern is safe at scale.

For Strings and Hashes, most operations are O(1). Lists have O(1) push/pop from ends but O(N) for index access. Sets and Sorted Sets have O(1) for membership, but set operations are O(N + M). Sorted Set range queries are O(log N + result set). Streams add and read are O(1) per entry, but XRANGE and XREAD with large ranges can be O(N).

Production note: Always test with your actual data size. A command that is O(log N) on a million-element sorted set (∼20 steps) may be acceptable, but O(N) on a stream with a million entries (a million steps) can block Redis for seconds.

complexity_reference.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- Quick commands to estimate your data size
-- Check list length (O(1))
LLEN mylist

-- Check set cardinality (O(1))
SCARD myset

-- Check sorted set size (O(1))
ZCARD myzset

-- Fetch top 10 from sorted set (O(log N) + 10)
ZREVRANGE myzset 0 9 WITHSCORES

-- Check stream length (O(1))
XLEN mystream

-- Generic memory usage of a key (O(N) on the data)
MEMORY USAGE mykey
Output
(integer) 150000
(integer) 50000
(integer) 100000
... (10 entries)
(integer) 200000
(integer) 5242880
📊 Production Insight
Avoid KEYS, SMEMBERS, and HGETALL on large collections — they are O(N) and block the event loop. Use SCAN, SSCAN, and HSCAN with COUNT for iterative access. Monitor latency with LATENCY DOCTOR if you suspect blocking commands.
🎯 Key Takeaway
Know the worst-case complexity of every command you use. A single O(N) command on a large key can degrade throughput for all clients.
Complexity Classification by Data Structure
Single value or counterFields updated independentlyFIFO queue or capped feedUnique members and set opsOrdered by scoreBoolean flags per userApproximate cardinalityPersistent event logChoose Redis Data StructureAccess PatternString: O1 get/setHash: O1 per fieldList: O1 push and popSet: O1 add, ON intersectSorted Set: OlogN insertBitmap: O1 getbit/setbitHyperLogLog: O1 addStream: O1 append
redis-data-structures TTL vs No TTL in Redis Eviction Impact on memory management and data retention With TTL Set Without TTL Eviction Target Expired keys removed first via volatile All keys eligible; no expiration-based p Memory Predictability Controlled data lifecycle; predictable m Unbounded growth until maxmemory, then a Cache Efficiency Stale data automatically purged; fresh d Frequently used data may be evicted alon Operational Overhead Requires TTL management per key or batch No TTL tracking; simpler but riskier Best Use Case Caching, session stores, time-limited da Persistent data, queues, or manual evict THECODEFORGE.IO
thecodeforge.io
Redis Data Structures

Data Structure Decision Tree

Choosing the right Redis data structure is the most important performance decision you'll make. The decision tree below guides you step by step from your application requirement to the best Redis data type. Follow the arrows based on your access patterns — ordering, uniqueness, field-level updates — and you'll avoid the common trap of using Strings for everything.

The tree is designed for production decisions: it considers not just functionality but memory efficiency (ziplist encoding in Hashes, bit-level storage in Bitmaps) and blocking behavior (avoid O(N) commands). Use it when you're designing a new feature or refactoring a slow Redis interaction.

Below the diagram, we've included a concrete example: if you need a cache that tracks 'which users have seen this notification' and also produces a feed of recent notifications, the tree tells you to use a Set for the seen-tracking (SISMEMBER) and a List with LTRIM for the feed. If you later need to sort the feed by time, the tree would suggest a Sorted Set with timestamps as scores.

decision_tree_example.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Example: Notification system following the decision tree
-- Step 1: Need unique 'seen' flags per user? → Set
SADD seen:user:42 "notif_789"

-- Step 2: Need an ordered feed of recent notifications? → List with trim
LPUSH feed:global "notif_789:User Alice posted"
LTRIM feed:global 0 99

-- Step 3: Need to know who has seen a notification quickly? → SISMEMBER
SISMEMBER seen:user:42 "notif_789"
-- Returns 1

-- Alternatively, if the feed needed timestamps for sorting:
-- Use a Sorted Set with Unix time as score
-- ZADD feed:global 1700000000 "notif_789"
-- ZREVRANGEBYSCORE feed:global +inf -inf WITHSCORES LIMIT 0 10
Output
(integer) 1
OK
OK
(integer) 1
📊 Production Insight
The decision tree helps you avoid the two most common memory pitfalls: using Strings for multi-field objects (wasteful serialization) and using Sorted Sets when a simple List would do (extra O(log N) overhead and memory for scores). Match the structure to the access pattern, not to what you 'know' from relational databases.
🎯 Key Takeaway
Always start with the decision tree: ask if you need ordering, uniqueness, field-level updates, or approximate counting. The correct data structure is usually the one with the smallest memory footprint that still supports your access pattern.
Redis Data Structure Decision Tree
NoYesYesNoYesNoScoreInsertion order onlyYesNoYesNoYesNoYesNoWhat is your data accesspattern?Do you need ordering?Do you need uniqueness?Ordered by score/value?SADD / SMEMBERS / SINTER -SetMultiple fields?HSET / HGET / HINCRBY - HashSET / GET / INCR - StringZADD / ZRANGE / ZREVRANK -Sorted SetLPUSH / RPOP / BRPOP - ListNeed approximate cardinality?PFADD / PFCOUNT -HyperLogLogNeed atomic counters?INCR / DECR - StringNeed time-range queries?ZRANGEBYSCORE - Sorted Setwith timestampsNeed persistent replay andconsumer groups?XADD / XREADGROUP / XACK -Stream

Hashes and Lists — Modeling Objects and Building Queues

A Hash stores a map of field-value pairs under a single key. Think of it as a lightweight row in a database — one Redis key holds an entire user profile, product record, or config object. The power is granular updates: HSET user:99 email 'new@example.com' updates one field without touching the rest. No deserialization, no re-serialization, no wasted bandwidth.

Hashes are also memory-efficient. When a Hash has fewer than 128 fields and each value is under 64 bytes, Redis internally uses a compact encoding called a ziplist (listpack in newer versions) that's significantly smaller than storing each field as a separate String key. This is a free optimization you get just by modelling your data correctly.

Lists are an ordered sequence of strings, implemented as a doubly-linked list. Elements are pushed and popped from both ends in O(1) time. This makes Lists the natural fit for job queues: producers LPUSH work onto the left end, consumers BRPOP block-wait on the right end. The 'B' in BRPOP is crucial — it means blocking. The consumer sits idle, consuming zero CPU, until work arrives. No polling loop needed.

Lists also work as activity feeds. Push the latest N events with LPUSH and trim the list to a fixed length with LTRIM so it never grows unbounded. Combined, LPUSH and LTRIM on every write give you a capped, real-time feed in two commands.

hash_and_list_patterns.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
-- ═══════════════════════════════
-- HASH: Storing a user profile
-- ═══════════════════════════════

-- Set multiple fields at once on first creation
HMSET user:99 \
  username    "alice_dev" \
  email       "alice@example.com" \
  plan        "pro" \
  login_count 0

-- Retrieve the whole profile
HGETALL user:99
-- Returns all fields and values as a flat list

-- Update just the plan without touching other fields
HSET user:99 plan "enterprise"

-- Atomically increment login_count (same as INCR but for Hash fields)
HINCRBY user:99 login_count 1
-- Returns: (integer) 1

-- Only fetch the two fields you actually need (saves bandwidth)
HMGET user:99 username plan
-- Returns: 1) "alice_dev"  2) "enterprise"


-- ═══════════════════════════════
-- LIST: Capped activity feed
-- ═══════════════════════════════

-- Push a new activity event for user 99 to the front of the list
LPUSH activity_feed:user:99 "logged_in"
LPUSH activity_feed:user:99 "viewed_dashboard"
LPUSH activity_feed:user:99 "exported_report"

-- Trim to the 10 most recent events — run this after every LPUSH
-- Index 0 = newest, index 9 = 10th newest, -1 = last element
LTRIM activity_feed:user:99 0 9

-- Read the 5 most recent events (newest first)
LRANGE activity_feed:user:99 0 4

-- Job queue: producer pushes, consumer blocks until work arrives
LPUSH email_queue "send_welcome:user:99"

-- On the worker process (blocks for up to 30 seconds)
BRPOP email_queue 30
-- Returns: 1) "email_queue"  2) "send_welcome:user:99"
Output
-- HMSET
OK
-- HGETALL user:99
1) "username"
2) "alice_dev"
3) "email"
4) "alice@example.com"
5) "plan"
6) "pro"
7) "login_count"
8) "0"
-- HSET (plan update)
(integer) 0
-- HINCRBY
(integer) 1
-- HMGET
1) "alice_dev"
2) "enterprise"
-- LRANGE (3 events, newest first)
1) "exported_report"
2) "logged_in"
3) "viewed_dashboard"
-- BRPOP
1) "email_queue"
2) "send_welcome:user:99"
⚠ Watch Out: LPUSH + LTRIM Must Be Atomic in High-Concurrency Systems
If two workers both LPUSH before either runs LTRIM, your list can temporarily exceed the cap. Wrap them in a Lua script or a MULTI/EXEC transaction to guarantee the push-and-trim is atomic. In most low-concurrency cases it's fine, but in production under load, one missed LTRIM can cause unbounded memory growth.
📊 Production Insight
Hash ziplist encoding is lost when you add more than 128 fields or values >64 bytes — memory doubles.
Lists with no LTRIM grow unbounded; a forgotten producer can fill memory with stale events.
Rule: For queues, use BRPOP with a timeout; for feeds, always pair LPUSH with LTRIM in a transaction.
🎯 Key Takeaway
Hashes give you atomic field updates without serialization overhead.
Lists with BRPOP are the correct queue primitive — no polling.
Always cap lists with LTRIM and wrap push+trim in a transaction under concurrency.

Sets and Sorted Sets — Unique Collections and Real-Time Leaderboards

A Set is an unordered collection of unique strings. 'Unique' is the whole point — Redis enforces it automatically, so you never write deduplication logic yourself. The classic use cases are tagging systems, tracking which users have seen a notification, and social graph relationships like followers.

The feature that makes Sets genuinely powerful beyond simple uniqueness is set algebra: SINTER (intersection), SUNION (union), and SDIFF (difference) let you compute 'users who follow both Alice and Bob', 'all tags on either post', or 'users who bought A but not B' in a single command. Doing that computation in your application layer means pulling thousands of IDs over the network first. Doing it in Redis means only the result travels over the wire.

Sorted Sets (ZSets) are the crown jewel of Redis. Every member gets a floating-point score, and Redis keeps members ordered by that score at all times. Insertion, removal, and score updates are O(log N). Range queries — 'give me the top 10 players', 'give me everyone with a score between 1000 and 2000' — are also O(log N + result size). This is the data structure behind every real-time leaderboard you've ever used.

The score can represent anything orderable: timestamps (for time-sorted feeds), relevance scores (for search ranking), or geographic distances (for proximity search via geohashing). If you need ordered access by any numeric dimension, a Sorted Set is almost always the answer.

sets_and_sorted_sets.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
-- ═══════════════════════════════
-- SET: Social follow graph
-- ═══════════════════════════════

-- Store who user:alice follows
SADD following:alice  "bob" "carol" "dave"
SADD following:bob    "alice" "carol" "eve"

-- Store alice's followers
SADD followers:alice  "bob" "dave"

-- Who do alice AND bob both follow? (mutual interests)
SINTER following:alice following:bob
-- Returns: 1) "carol"  (both follow carol)

-- Everyone either alice or bob follows (combined feed sources)
SUNION following:alice following:bob

-- Mark notification 789 as seen by user alice (deduplication)
SADD seen_notifications:alice "789"

-- Has alice seen it? O(1) lookup
SISMEMBER seen_notifications:alice "789"
-- Returns: (integer) 1  (yes)

SISMEMBER seen_notifications:alice "999"
-- Returns: (integer) 0  (no)


-- ═══════════════════════════════
-- SORTED SET: Game leaderboard
-- ═══════════════════════════════

-- Add players with their scores
-- Score is the second argument, member is the third
ZADD game:leaderboard 4850 "player:alice"
ZADD game:leaderboard 6200 "player:bob"
ZADD game:leaderboard 5930 "player:carol"
ZADD game:leaderboard 7100 "player:dave"
ZADD game:leaderboard 3200 "player:eve"

-- Update alice's score after she beats a level (just ZADD again)
ZADD game:leaderboard 5500 "player:alice"

-- Top 3 players, highest score first (WITHSCORES shows the score too)
ZREVRANGE game:leaderboard 0 2 WITHSCORES

-- What rank is carol? (0-indexed, ZREVRANK = rank by descending score)
ZREVRANK game:leaderboard "player:carol"
-- Returns: (integer) 2  (3rd place, 0-indexed)

-- All players with scores between 5000 and 7000
ZRANGEBYSCORE game:leaderboard 5000 7000 WITHSCORES

-- How many players scored over 5000?
ZCOUNT game:leaderboard 5001 +inf
-- Returns: (integer) 3
Output
-- SINTER following:alice following:bob
1) "carol"
-- SUNION following:alice following:bob
1) "bob"
2) "carol"
3) "dave"
4) "alice"
5) "eve"
-- SISMEMBER (seen)
(integer) 1
(integer) 0
-- ZREVRANGE top 3 WITHSCORES
1) "player:dave"
2) "7100"
3) "player:bob"
4) "6200"
5) "player:carol"
6) "5930"
-- ZREVRANK carol
(integer) 2
-- ZRANGEBYSCORE 5000 to 7000 WITHSCORES
1) "player:alice"
2) "5500"
3) "player:bob"
4) "6200"
5) "player:carol"
6) "5930"
7) "player:dave"
8) "7100"
-- ZCOUNT > 5000
(integer) 3
🔥Interview Gold: Why Use a Sorted Set for Timestamps?
Store Unix timestamps as scores and event IDs as members. Then ZRANGEBYSCORE feed:user:42 1700000000 1700086400 gives you every event in a 24-hour window with zero scanning. This is how time-series-style queries are done in Redis without a full time-series database.
📊 Production Insight
Set intersection on large sets (millions) takes O(N * K) and can block Redis for seconds — use SSCAN or limit set sizes.
Sorted Set scores are floats; score equality comparisons can fail due to floating-point precision.
Rule: For leaderboards with ties, assign deterministic tie-breakers (e.g., inverted timestamp as fractional score).
🎯 Key Takeaway
Sets enforce uniqueness and enable server-side set algebra — avoid pulling raw data to app.
Sorted Sets provide O(log N) ordered access; use scores as timestamps for time-range queries.
Watch out for floating-point precision and large-set blocking — paginate with SSCAN and ZRANGEBYSCORE.

Bitmaps and HyperLogLog — Efficient Counting and Boolean Operations

Bitmaps are not a distinct type — they're just Strings on which you can perform bit-level operations. Use SETBIT and GETBIT to manipulate individual bits. That gives you an incredibly memory-efficient way to store boolean flags: one user's daily sign-in for a year takes just 365 bits — about 46 bytes. Compare that to storing a String per day.

Bit operations like BITCOUNT, BITOP (AND, OR, XOR, NOT) let you compute retention, cohort analysis, and feature flags across millions of users in a single command. For example, BITOP AND daily:2026-03-01 daily:2026-03-02 shows users active on both days.

HyperLogLog is a probabilistic data structure for approximate cardinality counting with fixed memory. Each key takes ~12KB regardless of how many unique items you add. PFADD adds elements, PFCOUNT returns the approximate count (typical error 0.81%). It's perfect for counting unique visitors, search queries, or IPs where precision within a few percent is acceptable.

Use cases: daily active users (HyperLogLog), feature flags (Bitmap), user behaviour tracking (Bitmap).

bitmaps_hyperloglog.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- ═══════════════════════════════
-- BITMAP: Track daily sign-ins
-- ═══════════════════════════════

-- User 42 signs in on day 0 of the year (Jan 1)
SETBIT signin:2026 42 1
-- (integer) 0  (was 0 before)

-- User 42 signs in again on day 100 (Apr 10)
SETBIT signin:2026 100 1

-- Has user 42 signed in at all?
GETBIT signin:2026 42
-- Returns: (integer) 1

-- How many users signed in on day 100?
BITCOUNT signin:2026 0 199

-- Users active on both day 100 and day 101? (intersection)
BITOP AND active_both signin:2026 day100 day101

-- ═══════════════════════════════
-- HYPERLOGLOG: Unique visitors
-- ═══════════════════════════════

-- Add visitor IPs to the hyperloglog key for today
PFADD visitors:2026-03-25 "192.168.1.1" "10.0.0.2" "172.16.0.3"
PFADD visitors:2026-03-25 "192.168.1.1"  -- duplicate, ignored

-- Approximate unique count
PFCOUNT visitors:2026-03-25
-- Returns: (integer) 3

-- Merge yesterday and today for weekly unique visitors
PFMERGE visitors:week12 visitors:2026-03-24 visitors:2026-03-25
PFCOUNT visitors:week12
Output
-- SETBIT returns old bit value
(integer) 0
(integer) 0
-- GETBIT
(integer) 1
-- BITCOUNT
(integer) 342
-- PFCOUNT
(integer) 3
-- PFMERGE
OK
-- PFCOUNT merged
(integer) 5
💡Memory Trade-off: Bitmap vs Hash for User Flags
If you have millions of users and need to store a boolean per user, a Bitmap uses 1 bit per user. A Hash storing a key per user would be hundreds of MB. Bitmaps are the right choice when the user ID can be mapped to a contiguous integer offset.
📊 Production Insight
Bitmaps are still Strings; a key larger than 512MB is impossible, but you can shard across multiple keys.
HyperLogLog gives exact count for small sets; PFCOUNT is safe but merging many keys (PFMERGE) is O(N).
Rule: Use Bitmaps for per-user boolean flags where user ID ≤ 2^32; use HyperLogLog for high-cardinality counts where 2-3% error is acceptable.
🎯 Key Takeaway
Bitmaps store 8 boolean flags per byte — perfect for daily activity tracking and feature flags.
HyperLogLog uses ~12KB regardless of set size — ideal for unique visitor counts where absolute precision isn't required.
Choose Bitmaps when you need exact per-user state; choose HyperLogLog when you need approximate cardinality with bounded memory.

Analytical Structures — Bitmaps and HyperLogLog

While Strings, Hashes, Lists, and Sets handle transactional data, Bitmaps and HyperLogLog are purpose-built for analytical queries at scale. They trade off some features (range queries, field updates) for extreme memory efficiency and server-side aggregation.

Bitmaps let you store boolean arrays compactly. With BITCOUNT and BITOP you can answer questions like 'how many users logged in on both Monday and Tuesday?' across millions of users in microseconds — no scanning, no joins. The memory cost is 1 bit per user per day; a year of daily tracking for 10 million users costs ~456 MB.

HyperLogLog solves the 'how many unique items?' problem with a fixed memory of ~12KB per key, regardless of how many millions of distinct elements you add. The error is typically 0.81%, which is acceptable for dashboards and trend analysis. PFMERGE lets you combine multiple days into a weekly or monthly count without storing individual elements.

Choose Bitmaps when your user base is a known integer range (e.g., user IDs from 1 to 10 million) and you need exact per-day boolean status. Choose HyperLogLog when you need unique counts of arbitrary strings (IPs, search queries, email addresses) and can tolerate small errors.

Production tip: Bitmaps are not an independent type — they are Strings. If you need to shard across multiple keys (for >2^32 bits), use consistent hashing on the user ID to select the key.

analytical_structures_example.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- Analytical example: 7-day active user count
-- Day 1: users with ID 1,2,3,4,5
SETBIT active:day1 1 1
SETBIT active:day1 2 1
SETBIT active:day1 3 1
SETBIT active:day1 4 1
SETBIT active:day1 5 1

-- Day 2: users 3,4,5,6,7
SETBIT active:day2 3 1
SETBIT active:day2 4 1
SETBIT active:day2 5 1
SETBIT active:day2 6 1
SETBIT active:day2 7 1

-- Users active on both days (intersection)
BITOP AND active_both active:day1 active:day2
BITCOUNT active_both
-- Returns: 3 (users 3,4,5)

-- Total unique users across both days using HyperLogLog
PFADD unique:day1 user1 user2 user3 user4 user5
PFADD unique:day2 user3 user4 user5 user6 user7
PFCOUNT unique:day1 unique:day2
-- Returns: 7 (all unique users)
Output
(integer) 3
(integer) 7
⚠ HyperLogLog Error Can Spike on Small Cardinalities
PFCOUNT returns exact counts for very small sets (≤ 2^4 elements) but has higher error near expected cardinalities of a few thousand. For absolute accuracy, use a Set and SADD with SCARD, but accept the memory cost.
📊 Production Insight
BITOP on multiple large keys (e.g., JOIN of 365 daily bitmaps) can be CPU intensive — consider incremental aggregation (e.g., weekly bitmaps). For HyperLogLog, PFMERGE is efficient; use it to precompute weekly/monthly aggregates during off-peak hours.
🎯 Key Takeaway
Use Bitmaps for exact per-user boolean analysis; use HyperLogLog for approximate cardinality with fixed memory. Both enable server-side analytical queries that would be expensive with other data structures.

Redis Streams — The Append-Only Log for Event Sourcing

Redis Streams (introduced in Redis 5.0) is an append-only log data structure. Each stream entry has a unique auto-generated ID (timestamp-sequence) and a set of field-value pairs — essentially a persistent, ordered, and replayable event log.

The key features are: non-destructive reads (consumers can read the same entry multiple times), consumer groups (competing consumers with acknowledgments), and blocking reads (like BRPOP but for streams). This makes Streams the natural choice for event sourcing, message queues with persistence, and audit trails.

Unlike Lists, entries in a stream are never removed by reading — they stay until explicitly trimmed (XTRIM) or evicted by maxlen. This allows replay and multiple consumer groups. Use XADD to append, XREAD to read in blocking mode, XREADGROUP for consumer groups, and XACK to acknowledge processed messages.

Streams also support range queries by ID, so you can replay historical events from a given point — invaluable for debugging or rebuilding state.

streams_event_log.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- ═══════════════════════════════
-- STREAM: Order event log
-- ═══════════════════════════════

-- Add an event to the order stream (auto-generated ID using *)
XADD order:events * event_name "order_placed" user_id "42" amount "29.99"
-- Returns: "1700000000000-0" (timestamp-sequence)

XADD order:events * event_name "payment_received" user_id "42" amount "29.99"

XADD order:events * event_name "shipped" user_id "42" tracking "1Z999AA10123456784"

-- Read all events from the start (blocking, latest offset $)
-- Non-blocking: read from start to latest
XREAD COUNT 100 STREAMS order:events 0

-- Block until new events arrive (like BRPOP)
XREAD BLOCK 5000 STREAMS order:events $

-- Create a consumer group for competing consumers
XGROUP CREATE order:events order-processors $ MKSTREAM

-- Consumer 1: read and claim
XREADGROUP GROUP order-processors consumer-1 COUNT 10 BLOCK 2000 STREAMS order:events >

-- Acknowledge processing
XACK order:events order-processors 1700000000000-0

-- Trim the stream to keep only the last 1000 entries
XTRIM order:events MAXLEN ~ 1000
-- ~ means approximate trimming for efficiency
Output
-- XADD returns the entry ID
"1700000000000-0"
"1700000001000-0"
"1700000002000-0"
-- XREAD returns nested array
1) 1) "order:events"
2) 1) 1) "1700000000000-0"
2) 1) "event_name" 2) "order_placed" 3) "user_id" 4) "42" 5) "amount" 6) "29.99"
2) 1) "1700000001000-0"...
-- XREADGROUP returns pending entries
1) 1) "order:events"
2) 1) 1) "1700000000000-0"...
-- XACK
(integer) 1
-- XTRIM
(integer) 3
🔥When to Use Streams vs Lists
Use Streams when you need: multiple consumers with different offsets, message persistence and replay, or audit logging. Use Lists for simple FIFO queues where messages are removed on read and you don't need replay or consumer groups.
📊 Production Insight
Streams without XTRIM grow unbounded — memory exhaustion is a real risk.
Consumer group pending entries can grow if consumers crash without XACK — use XAUTOCLAIM for auto-claiming.
Rule: Always set MAXLEN on production streams and monitor pending entries with XPENDING.
🎯 Key Takeaway
Streams are append-only logs with persistent reads — ideal for event sourcing and durable message queues.
Use consumer groups for competing consumers with acknowledgments.
Always trim streams (XTRIM MAXLEN) to prevent unbounded memory growth.

Memory Overcommit — Why Your Sorted Set Will Eat RAM for Breakfast

You picked Redis because it's fast. Speed costs memory. Every data structure has a memory footprint that scales non-linearly. Sorted Sets look innocent — score, member, done. But internally Redis uses a skip list plus a hash table. For 10 million members, that's roughly 1.2 GB of overhead before you store a single byte of actual data. Strings are worse when used as counters — each key carries ~90 bytes of metadata. If you store 50 million session tokens as individual keys, you've burned 4.5 GB on structure alone. The competing pages never show you the bill. They show you the pretty syntax. Here's the fix: benchmark with redis-cli --bigkeys before you commit to production. Watch the memory/expiry ratio. Set a maxmemory policy that matches your data pattern — not the default noeviction. Your Sorted Set leaderboard should be backed by a capped ZREMRANGEBYRANK maintenance job. Run it every N writes. Not on a cron. On write. Production doesn't wait for cron.

MemoryAudit.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — database tutorial

// Audit memory per key type — run before any schema decision
redis-cli --bigkeys

// Example output for a production box with 12 GB dataset

// Biggest keys by type
// Strings: 2.3M keys, avg size 84 bytes, total 193 MB
// Hashes: 140K keys, avg size 2.1 KB, total 287 MB
// Sorted Sets: 850 keys, avg size 1.4 MB, total 1.19 GB
// Lists: 12 keys, avg size 45 MB, total 540 MB

// Watch the overhead: skip list + hash table = 2x raw data
redis-cli MEMORY USAGE leaderboard:q4_2025
// (integer) 2234832
Output
// Biggest keys by type
// Strings: 2.3M keys, avg size 84 bytes, total 193 MB
// Hashes: 140K keys, avg size 2.1 KB, total 287 MB
// Sorted Sets: 850 keys, avg size 1.4 MB, total 1.19 GB
// Lists: 12 keys, avg size 45 MB, total 540 MB
⚠ Production Trap:
Default maxmemory-policy noeviction will crash your Redis when RAM fills. Set allkeys-lru for caches, volatile-ttl for TTL-managed data. Test with 80% capacity, not 95%.
🎯 Key Takeaway
Redis data structures have hidden memory overhead. Audit with --bigkeys, cap with ZREMRANGEBYRANK, and never trust the theoretical size.

Persistence Semantics — RDB vs AOF vs No Save. Pick One or Lose Data

Competitors treat persistence as an afterthought. 'Turn on RDB and you're safe.' That's a lie. RDB snapshots are point-in-time. Lose the last 60 seconds of writes and your leaderboard resets. AOF logs every write — but at fsync every second, you still lose one second per crash. The only zero-loss path is AOF with appendfsync always, which kills throughput by 10x. Nobody tells you that. Here's what they miss: your data structure choice determines your acceptable loss. Streams with consumer groups demand AOF. Cached session tokens? No persistence needed — rebuild on restart. Sorted Set leaderboards for a game? RDB every 5 minutes is fine because you can replay from game events. The real sin is mixing persistence tolerances in one Redis instance. Your analytics pipeline runs HyperLogLog with no persistence. Your payment queue runs Streams with AOF. Same box, different guarantees. Split them. Run one Redis for ephemeral, one for durable. Senior engineers ship two configs, not one.

PersistenceConfigs.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — database tutorial

// Ephemeral cache instance — no persistence, max throughput
save ""
appendonly no
maxmemory-policy allkeys-lru

// Durable queue instance — AOF every write, no compromise
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync always
aof-use-rdb-preamble yes

// Check actual AOF rewrite lag in production
redis-cli INFO Persistence
// aof_rewrite_in_progress:0
// aof_last_rewrite_time_sec:12
Output
// aof_rewrite_in_progress:0
// aof_last_rewrite_time_sec:12
💡Senior Shortcut:
Use aof-use-rdb-preamble yes. It writes an RDB entry at the head of AOF so restarts load faster than replaying the whole AOF. You get AOF durability with RDB boot speed.
🎯 Key Takeaway
Match persistence to data structure criticality. Never run AOF fsync always on a cache. Split disposable from durable data into separate Redis instances.

Command Pipelining — The Single Biggest Performance Lever You're Ignoring

Every competitor shows you how to SET a key. None show you the cost of round-trips. At 100 microseconds per command on a local network, 10,000 pipelined commands take 10 seconds — one at a time. Pipeline them and it's 500 milliseconds. Twenty times faster. That's not theory. That's a production incident where your batch job for Sorted Set leaderboard updates locked the app for 12 seconds because you sent ZADD one by one. The fix: batch into chunks of 500-1000 commands. Use pipelining for all bulk writes — Hash set, List push, Stream XADD. But watch the pipeline buffer. Send too many and you'll OOM the client. Redis 7.4 improved this with client-side caching, but 90% of you are on older versions. For Streams, use XADD with MAXLEN ~1000 in batch — don't trim per entry. For Sets, SADD 500 members at once. The competitor tutorials treat Redis as a single-command store. It's not. It's a pipeline engine. Treat it like one.

PipelineBatch.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — database tutorial

// Batch ZADD for leaderboard updates — 750 members at once
local pipeline = redis.pipeline()
for i = 1, 750 do
  pipeline:zadd("leaderboard:arena:2025-04", {score = math.random(1000, 999999), member = "player_" .. i})
end
local replies = pipeline:execute()

// VS naive loop — 750 round trips, ~7 seconds
for i = 1, 750 do
  redis.call("ZADD", "leaderboard:arena:2025-04", math.random(1000, 999999), "player_" .. i)
end

// Measure the difference
redis-cli --latency -h localhost -p 6379
// min: 0, max: 3, avg: 0.12 (milliseconds) — pipeline wins hard
Output
// min: 0, max: 3, avg: 0.12 (milliseconds) — pipeline wins hard
🔥Production Reality:
Pipeline doesn't mean flush and forget. If a batch of 1000 fails, all 1000 fail. Use MULTI/EXEC for atomic batches. Pipeline for non-atomic bulk. Know the difference.
🎯 Key Takeaway
Pipeline all bulk operations. Batch 500-1000 commands per round-trip. Never loop single writes in production.

Redis Modules: RedisJSON, RedisSearch, RedisTimeSeries, RedisGraph

Redis Modules extend Redis core functionality with specialized data structures and query capabilities. RedisJSON enables native JSON document storage and manipulation using JSONPath expressions. For example, to store and query a JSON document: JSON.SET user:1 $ '{"name":"Alice","age":30,"address":{"city":"NYC"}}'. You can then retrieve fields: JSON.GET user:1 $.name. RedisSearch provides full-text search and secondary indexing, allowing SQL-like queries: FT.SEARCH idx:users '@name:Alice'. RedisTimeSeries offers time-series data with downsampling and aggregation: TS.ADD temperature:room1 1609459200 22.5. RedisGraph implements graph databases using the Cypher query language: GRAPH.QUERY social "MATCH (a:User)-[:FOLLOWS]->(b:User) RETURN a.name, b.name". These modules are essential for modern applications requiring complex data models beyond simple key-value pairs. They integrate seamlessly with Redis Stack and can be loaded as dynamic libraries. When using modules, consider memory overhead and ensure compatibility with your Redis version. Modules are particularly useful for reducing application complexity by moving logic closer to data.

redis_modules_examples.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- RedisJSON
JSON.SET user:1 $ '{"name":"Alice","age":30}'
JSON.GET user:1 $.name

-- RedisSearch
FT.CREATE idx:users ON HASH PREFIX 1 user: SCHEMA name TEXT age NUMERIC
FT.SEARCH idx:users '@name:Alice'

-- RedisTimeSeries
TS.CREATE temperature:room1 RETENTION 86400000
TS.ADD temperature:room1 1609459200 22.5
TS.RANGE temperature:room1 1609459200 1609545600 AGGREGATION avg 3600000

-- RedisGraph
GRAPH.QUERY social "CREATE (:User {name:'Alice'})-[:FOLLOWS]->(:User {name:'Bob'})"
GRAPH.QUERY social "MATCH (a:User)-[:FOLLOWS]->(b:User) RETURN a.name, b.name"
🔥Module Compatibility
📊 Production Insight
In production, monitor module memory usage and performance. For RedisSearch, tune index settings (e.g., FT.CREATE with STOPWORDS and SCHEMA options) to balance search speed and memory. For RedisTimeSeries, set appropriate retention policies to avoid unbounded memory growth.
🎯 Key Takeaway
Redis Modules like RedisJSON, RedisSearch, RedisTimeSeries, and RedisGraph provide specialized data structures and query capabilities, enabling complex data models directly in Redis.

Streams: Kafka-Like Message Queue in Redis

Redis Streams provide an append-only log data structure similar to Apache Kafka, enabling reliable message queuing, event sourcing, and real-time data streaming. Streams support consumer groups, message acknowledgment, and blocking reads. To create a stream and add messages: XADD mystream * sensor-id 1234 temperature 19.8. This auto-generates a unique ID (timestamp-sequence). To read messages: XRANGE mystream - +. Consumer groups allow multiple consumers to process messages in a distributed manner: XGROUP CREATE mystream mygroup $ creates a group starting from the latest message. Consumers within a group read messages with XREADGROUP GROUP mygroup consumer1 BLOCK 2000 COUNT 10 STREAMS mystream >. Streams support pending entries (PEL) for fault tolerance: XPENDING mystream mygroup shows unacknowledged messages. Unlike Kafka, Redis Streams are lightweight and can be used for simple queuing with minimal overhead. They are ideal for microservices communication, task queues, and event-driven architectures. However, they lack Kafka's partitioning and long-term storage capabilities. For high-throughput scenarios, consider using Streams with Redis Cluster for horizontal scaling. Streams also integrate with Redis Modules like RedisTimeSeries for time-series event processing.

redis_streams_example.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- Add messages to stream
XADD mystream * sensor-id 1234 temperature 19.8
XADD mystream * sensor-id 5678 temperature 21.5

-- Read all messages
XRANGE mystream - +

-- Create consumer group
XGROUP CREATE mystream mygroup $

-- Read messages as consumer
XREADGROUP GROUP mygroup consumer1 BLOCK 2000 COUNT 10 STREAMS mystream >

-- Acknowledge messages
XACK mystream mygroup 1609459200000-0

-- Check pending messages
XPENDING mystream mygroup
⚠ Stream Memory Management
📊 Production Insight
In production, set appropriate MAXLEN on streams to control memory usage. Use XREADGROUP with BLOCK for efficient polling. Monitor consumer group lag using XINFO GROUPS and XPENDING to ensure timely processing. For high availability, replicate streams across nodes.
🎯 Key Takeaway
Redis Streams offer a Kafka-like message queue with consumer groups, acknowledgment, and blocking reads, suitable for event sourcing and microservices communication.

Probabilistic Data Structures: Bloom Filter, Cuckoo Filter, HyperLogLog

Redis provides probabilistic data structures for memory-efficient approximate counting and membership testing. Bloom Filters test set membership with a configurable false positive rate. For example: BF.ADD bloomfilter user:1234 adds an item, and BF.EXISTS bloomfilter user:1234 checks membership. Bloom Filters never produce false negatives but may have false positives. Cuckoo Filters offer similar functionality with support for deletion: CF.ADD cuckoofilter item1, CF.EXISTS cuckoofilter item1, CF.DEL cuckoofilter item1. HyperLogLog counts unique elements with minimal memory (approx 12KB for up to 2^64 elements): PFADD hll user:1234, PFCOUNT hll. These structures are ideal for deduplication, spam filtering, cache optimization, and analytics. For instance, use a Bloom Filter to avoid caching unpopular items: check if a key exists in the filter before caching. HyperLogLog is perfect for counting daily active users or unique visitors. However, these structures are probabilistic: Bloom and Cuckoo Filters have false positives, and HyperLogLog has a standard error of ~0.81%. They trade accuracy for memory efficiency. When using them, consider the acceptable error rate. For Bloom Filters, set the error rate and capacity: BF.RESERVE bloomfilter 0.01 1000000 for 1% false positive rate and 1M items. Cuckoo Filters allow similar tuning. These structures are part of the RedisBloom module, which must be loaded.

probabilistic_structures_example.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Bloom Filter
BF.RESERVE bloomfilter 0.01 1000000
BF.ADD bloomfilter user:1234
BF.EXISTS bloomfilter user:1234

-- Cuckoo Filter
CF.RESERVE cuckoofilter 1000000
CF.ADD cuckoofilter item1
CF.EXISTS cuckoofilter item1
CF.DEL cuckoofilter item1

-- HyperLogLog
PFADD hll user:1234 user:5678
PFCOUNT hll
PFMERGE merged_hll hll1 hll2
💡Choosing the Right Filter
📊 Production Insight
In production, pre-allocate Bloom Filters with expected capacity and error rate using BF.RESERVE to avoid performance degradation. For HyperLogLog, use PFMERGE to combine counts across shards. Monitor false positive rates and adjust parameters if needed. These structures are part of the RedisBloom module, ensure it's loaded.
🎯 Key Takeaway
Probabilistic data structures like Bloom Filters, Cuckoo Filters, and HyperLogLog provide memory-efficient membership testing and cardinality estimation with controlled error rates.
● Production incidentPOST-MORTEMseverity: high

The Case of the Exploding Memory: Using Strings for User Sessions Without TTL

Symptom
Redis memory usage climbed steadily over months; the server started evicting keys at peak traffic, causing random authentication failures and customer complaints.
Assumption
The team assumed the session data would be cleaned up when users logged out or after a reasonable time, but the application never explicitly deleted old sessions, and no TTL was set.
Root cause
Every session key was a String with no expiry. Even after the user logged out, the key persisted. Redis accumulated thousands of stale session blobs — some up to 2KB each — until the memory limit triggered eviction.
Fix
Added TTL to all session keys: SET session:abc123 user_data EX 3600. Also implemented a Lua script to bulk-set TTL on existing keys during a maintenance window.
Key lesson
  • Always set an expiry on any key that represents a time-bounded concept: sessions, rate limiters, temporary caches.
  • Audit keys with TTL -1 periodically using redis-cli --scan --pattern "*" | xargs redis-cli TTL.
  • Use the Redis MEMORY USAGE command to find the biggest consumers and check if they should have TTL.
Production debug guideSymptom → Action guide for the most common Redis data structure problems5 entries
Symptom · 01
High memory usage; evictions appear in INFO stats
Fix
Run MEMORY DOCTOR, then MEMORY STATS. Identify large keys with --bigkeys. Use TYPE to check data structure; look for Strings with no TTL or oversized Hashes/Lists.
Symptom · 02
Slow responses from KEYS * or SMEMBERS on large sets
Fix
Replace KEYS with SCAN 0 COUNT 100 (non-blocking). For large sets, use SSCAN instead of SMEMBERS. Add pagination to client code.
Symptom · 03
BRPOP blocking consumers never return or timeout unexpectedly
Fix
Check LIST length with LLEN. Ensure producers are LPUSHing. If list is empty and consumers wait, check network timeout settings (default 0 = infinite block). Use CLIENT LIST to see blocked clients.
Symptom · 04
ZREVRANGE on leaderboard returns wrong order or scores
Fix
Verify ZADD scores are correct. If scores are integers, Redis stores them as floats; check for floating-point corruption. Use ZSCORE to inspect individual member scores.
Symptom · 05
SINTER returns unexpected results or empty set
Fix
Confirm both sets exist with EXISTS. Check if values have leading/trailing spaces. Use SMEMBERS on each set to verify membership.
★ Redis Data Structures Debugging Cheat SheetQuick commands and immediate actions for the most common Redis data structure production issues.
Redis memory usage exceeds limit, evictions starting
Immediate action
Identify large keys and keys without TTL
Commands
redis-cli --bigkeys
redis-cli --scan --pattern '*' | xargs -I {} redis-cli TTL {} | grep -E '^-1$'
Fix now
Set TTL on eligible keys: redis-cli --eval set_ttl.lua , 3600
KEYS command blocks server+
Immediate action
Kill the offending client and switch to SCAN
Commands
CLIENT LIST | grep -i 'cmd=keys'
CLIENT KILL <client-id>
Fix now
Replace KEYS with SCAN 0 COUNT 500 in code
BRPOP consumers not receiving messages+
Immediate action
Check if list exists and has items
Commands
EXISTS queue_name
LLEN queue_name
Fix now
If list exists but empty, producers may be failing. Check producer logs. If list doesn't exist, ensure producer creates it on first push.
Sorted set range query slow+
Immediate action
Check cardinality and whether you need pagination
Commands
ZCARD leaderboard_key
ZREVRANGE leaderboard_key 0 100 WITHSCORES
Fix now
If >1M members, consider pagination with ZRANGEBYSCORE or use a Redis time-series module for range queries.
Data StructureOrderingUniquenessBest Real-World Use CaseWorst Misuse
StringN/A (single value)N/ARate limiting, sessions, counters, feature flagsStoring multi-field objects as JSON blobs when fields are updated independently
HashInsertion order (Redis 7.4+)Fields are uniqueUser profiles, product records, config objects with multiple attributesStoring hundreds of thousands of tiny Hashes (use one Hash per entity, not one key per field)
ListInsertion order maintainedDuplicates allowedJob queues (BRPOP), capped activity feeds (LPUSH + LTRIM)Random access by index — use a Sorted Set if you need score-based retrieval
SetUnorderedMembers are uniqueTag systems, social graphs, 'has user seen this?' flags, set algebraWhen you need ordered output — you'll have to sort in your app layer
Sorted SetScore-based (float)Members are unique, scores can repeatLeaderboards, time-series lookups, priority queues, autocompleteTreating it like a List — if you don't need ordering or range queries, a Set or List is cheaper
BitmapN/A (bit positions)N/AUser daily active tracking, feature flags, real-time analytics at scaleStoring non-boolean data — bit operations on large numbers >2^32 require sharding
HyperLogLogN/AProbabilistic (no exact count)Unique visitor counting, search query cardinality, IP uniquenessWhen you need exact counts — error of 0.81% is unacceptable for financial transactions
StreamAppend order (by entry ID)N/A (entries unique by ID)Event sourcing, durable message queues, audit logs, state rebuildsSimple FIFO queue where messages are removed on read — use List instead
⚙ Quick Reference
14 commands from this guide
FileCommand / CodePurpose
string_rate_limiter.redisSET rate_limit:user:42:window:1700000000 0 EX 60 NXStrings
complexity_reference.redisLLEN mylistCommand Complexity Reference Table
decision_tree_example.redisSADD seen:user:42 "notif_789"Data Structure Decision Tree
hash_and_list_patterns.redisHMSET user:99 \Hashes and Lists
sets_and_sorted_sets.redisSADD following:alice "bob" "carol" "dave"Sets and Sorted Sets
bitmaps_hyperloglog.redisSETBIT signin:2026 42 1Bitmaps and HyperLogLog
analytical_structures_example.redisSETBIT active:day1 1 1Analytical Structures
streams_event_log.redisXADD order:events * event_name "order_placed" user_id "42" amount "29.99"Redis Streams
MemoryAudit.sqlredis-cli --bigkeysMemory Overcommit
PersistenceConfigs.sqlsave ""Persistence Semantics
PipelineBatch.sqllocal pipeline = redis.pipeline()Command Pipelining
redis_modules_examples.sqlJSON.SET user:1 $ '{"name":"Alice","age":30}'Redis Modules
redis_streams_example.sqlXADD mystream * sensor-id 1234 temperature 19.8Streams
probabilistic_structures_example.sqlBF.RESERVE bloomfilter 0.01 1000000Probabilistic Data Structures

Key takeaways

1
Choose your data structure based on the access pattern, not the data shape
a Sorted Set with Unix timestamps as scores is a faster time-range query than any String-stored JSON.
2
Hashes use ziplist/listpack encoding under 128 fields and 64-byte values
model objects as Hashes (not one String key per field) and you get compact memory for free.
3
BRPOP is a blocking pop
it suspends the consumer with zero CPU until work arrives, making it the correct primitive for a Redis-backed job queue without polling.
4
INCR and HINCRBY are atomic by design
they are the right way to count anything in a distributed system; a GET-then-SET sequence from application code is never safe under concurrency.
5
Bitmaps store 8 boolean flags per byte
use them for per-user daily tracking and feature flags instead of storing separate keys.
6
Streams with MAXLEN prevent unbounded memory growth
always set a cap on production streams and monitor pending entries.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
You need to build a leaderboard that shows the top 100 players and lets ...
Q02JUNIOR
What's the difference between a Redis Set and a Sorted Set? Give me a co...
Q03SENIOR
If Redis is single-threaded, how does INCR avoid race conditions, and wh...
Q04SENIOR
What are the memory implications of using Hashes vs storing each field a...
Q01 of 04SENIOR

You need to build a leaderboard that shows the top 100 players and lets any player instantly see their own rank. Which Redis data structure would you use, and why not a regular database query with ORDER BY?

ANSWER
Use a Sorted Set (ZSET). Store each player's ID as the member and their score as the score. ZREVRANGE 0 99 WITHSCORES gives you the top 100 in O(log N + 100) time, and ZREVRANK returns any player's rank in O(log N). A relational database would require an ORDER BY score DESC LIMIT 100 query, which scans and sorts the table — even with an index it's slower and adds network overhead. Redis does the sorting in memory on the server, returning only the results. For individual rank, a sorted set's O(log N) retrieval is far faster than a subquery counting rows.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What are the five main Redis data structures?
02
When should I use a Redis Hash instead of storing JSON in a String?
03
Is Redis really single-threaded, and does that make it slow?
04
What is the difference between List and Stream for message queues?
05
How do I choose between Set and Sorted Set?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's NoSQL. Mark it forged?

11 min read · try the examples if you haven't

Previous
Redis Basics
7 / 27 · NoSQL
Next
Cassandra Basics