Redis Data Structures Explained — The Right Type for Every Job
Every production system eventually hits a wall where a relational database is simply too slow for certain jobs. Not because SQL is bad — it's excellent — but because reading a user's session data, tallying votes in real time, or powering an autocomplete box requires sub-millisecond responses that a disk-backed database can't reliably provide. Redis lives in memory, and it was designed from day one around one idea: give developers a small set of powerful, purpose-built data structures so that the data's shape in storage matches the problem being solved.
The real problem Redis solves isn't just 'be fast.' Any cache can be fast. What makes Redis different is that its data structures let you do meaningful work on the server — increment a counter atomically, pop from a queue, compute the intersection of two sets — without pulling data into your application first. That server-side computation is what keeps your network traffic lean and your application logic clean.
By the end of this article you'll be able to look at a backend requirement — a leaderboard, a rate limiter, a social graph, a job queue — and immediately know which Redis data structure to reach for, why it's the right fit, and exactly how to implement it. You'll also know the gotchas that trip up engineers in their first month of production Redis use.
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.
-- 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)
(integer) 2
"2"
(integer) 3
(integer) 4
(integer) 5
(integer) 47
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: 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"
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) "viewed_dashboard"
3) "logged_in"
-- BRPOP
1) "email_queue"
2) "send_welcome:user:99"
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.
-- ═══════════════════════════════ -- 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
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:carol"
4) "5930"
5) "player:bob"
6) "6200"
7) "player:dave"
8) "7100"
-- ZCOUNT > 5000
(integer) 3
| Data Structure | Ordering | Uniqueness | Best Real-World Use Case | Worst Misuse |
|---|---|---|---|---|
| String | N/A (single value) | N/A | Rate limiting, sessions, counters, feature flags | Storing multi-field objects as JSON blobs when fields are updated independently |
| Hash | Insertion order (Redis 7.4+) | Fields are unique | User profiles, product records, config objects with multiple attributes | Storing hundreds of thousands of tiny Hashes (use one Hash per entity, not one key per field) |
| List | Insertion order maintained | Duplicates allowed | Job queues (BRPOP), capped activity feeds (LPUSH + LTRIM) | Random access by index — use a Sorted Set if you need score-based retrieval |
| Set | Unordered | Members are unique | Tag systems, social graphs, 'has user seen this?' flags, set algebra | When you need ordered output — you'll have to sort in your app layer |
| Sorted Set | Score-based (float) | Members are unique, scores can repeat | Leaderboards, time-series lookups, priority queues, autocomplete | Treating it like a List — if you don't need ordering or range queries, a Set or List is cheaper |
🎯 Key Takeaways
- 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.
- 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.
- 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.
- 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Storing entire JSON objects as Strings and updating one field — Symptom is high CPU and bandwidth spikes because you GET the full blob, deserialize it in the app, change one field, serialize it back, then SET the whole thing again. Fix: model multi-field objects as Hashes and use HSET to update individual fields atomically.
- ✕Mistake 2: Forgetting TTLs on keys that should expire — Symptom is Redis memory climbing steadily until the server hits maxmemory and starts evicting random keys or refusing writes. Fix: always set an expiry on session keys, rate limit counters, and any key tied to a time-bounded concept. Run 'redis-cli --scan --pattern "*" | xargs redis-cli TTL' periodically to audit keys with TTL -1 (no expiry set).
- ✕Mistake 3: Using KEYS * in production to find keys by pattern — Symptom is Redis blocking all other commands for seconds (or minutes on a large dataset) because KEYS is O(N) and single-threaded Redis stops the world while it runs. Fix: always use SCAN with a COUNT hint instead — it's cursor-based, non-blocking, and safe to run on a live server.
Interview Questions on This Topic
- QYou 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?
- QWhat's the difference between a Redis Set and a Sorted Set? Give me a concrete scenario where you'd choose one over the other.
- QIf Redis is single-threaded, how does INCR avoid race conditions, and why can't you replicate the same safety with a GET followed by a SET from your application code?
Frequently Asked Questions
What are the five main Redis data structures?
The five core Redis data structures are Strings (single values, counters, session tokens), Hashes (field-value maps for objects), Lists (ordered sequences for queues and feeds), Sets (unique unordered collections for tags and social graphs), and Sorted Sets (unique members with scores for leaderboards and time-range queries). Redis 5+ also ships HyperLogLog, Streams, and Geospatial indexes as specialized extensions.
When should I use a Redis Hash instead of storing JSON in a String?
Use a Hash whenever you need to read or update individual fields independently. Storing JSON as a String forces you to deserialize the whole document just to change one field, then re-serialize and re-write it entirely. A Hash lets you call HSET to update one field and HGET to read one field — no serialization, less bandwidth, and no overwrite race condition.
Is Redis really single-threaded, and does that make it slow?
Redis processes commands on a single thread, which is actually what makes it fast and safe. There's no lock contention between threads, so commands like INCR are inherently atomic. The single thread avoids context-switching overhead, and since Redis lives in memory rather than on disk, I/O is not the bottleneck — the CPU can process hundreds of thousands of simple commands per second on modest hardware. Redis 6+ added I/O threading for network reads and writes while keeping command execution single-threaded.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.