Redis isn't just a key-value store — what data structures does it offer and what real problems do they solve?
Hashes, sorted sets, lists, and streams each carry their own complexity guarantees and durability trade-offs — picking the right one is the actual skill.
LEARN BY DOINGPut your intuition to work3 steps
Redis isn't just a key-value store — what data structures does it offer and what real problems do they solve?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Redis as a data-structure server, not a hash map with a network port
The "Redis is just a fast key-value store" mental model is the one that gets exposed fastest in a senior interview. The value behind any key can be a string, but it can just as easily be a hash, a sorted set, a list, a set, a stream, a bitmap, or a HyperLogLog — each with its own command family and its own complexity guarantees, and each one exists because it solves a specific class of problem that a plain GET/SET can't solve without pulling the whole blob into your application and doing the work there.
#Hashes: a record, not a blob
A String value forces you to serialize an entire object to update one field. A Hash stores field-value pairs under one key, so a single field can be read or written in O(1) without touching the rest.
HSET cart:user:42 sku:1001 2 sku:2002 1HGET cart:user:42 sku:1001HINCRBY cart:user:42 sku:1001 1HGETALL cart:user:42
A shopping cart, a user session, or a product record maps naturally onto a hash: incrementing the quantity of one SKU (HINCRBY) never requires reading and rewriting the whole cart as JSON. Compare that to a JSON-string value, where the same update means GET, deserialize, mutate one field, serialize, SET — and a race between two concurrent updates can silently drop one of them.
#Sorted sets: order as a first-class feature
A Sorted Set (ZSET) stores unique members each with a floating-point score, kept in score order internally (a skip list plus a hash table), so range queries by rank or by score are cheap.
Leaderboard, the canonical use:
ZADD leaderboard:season1 4500 "player:17"ZADD leaderboard:season1 6200 "player:42"ZINCRBY leaderboard:season1 150 "player:17"ZREVRANGE leaderboard:season1 0 9 WITHSCORESZREVRANK leaderboard:season1 "player:17"
Sliding-window rate limiting is the other flagship use, and it's worth knowing cold: use the current timestamp as the score, the request id as the member, trim anything older than the window, then count what's left.
ZADD ratelimit:user:42 1735142400123 "req-a1b2"ZREMRANGEBYSCORE ratelimit:user:42 -inf (1735142400123-60000)ZCARD ratelimit:user:42
If ZCARD returns more than the allowed request count, reject the request. This gives you an exact sliding window (not the bucketed approximation of a fixed-window counter) in three commands, all O(log N). ZADD, ZINCRBY, ZRANGE/ZREVRANGE and ZREMRANGEBYSCORE are all O(log N) (or O(log N + M) for a range of M results) — cheap enough to run on every request.
#Lists: a queue, until it isn't enough
A List is a doubly linked list under the hood — O(1) push and pop at either end, O(N) to index into the middle. That makes it a natural, minimal job queue:
LPUSH jobs:emails '{"to":"a@x.com","template":"welcome"}'BRPOP jobs:emails 5
This works, and it's genuinely how a lot of lightweight background-job systems start. Its ceiling is durability: BRPOP removes the item the instant a worker picks it up. If that worker crashes mid-job, the job is simply gone — there was never an acknowledgment step, because a List doesn't have a concept of "in progress but not yet confirmed done."
#Streams: a list that remembers who's read what
A Stream is an append-only log — think a lightweight Kafka partition living inside Redis — where entries are never removed on read, and multiple consumer groups can each track their own position independently, with explicit acknowledgment.
XADD orders:events '*' orderId 9931 status "created"XREADGROUP GROUP shipping consumer-1 COUNT 10 STREAMS orders:events '>'XACK orders:events shipping 1735142400123-0XPENDING orders:events shipping
XADD appends (the '*' asks Redis to auto-generate a timestamp-based id); XREADGROUP hands a consumer group the next unread entries; XACK marks one as done. Critically, an entry that's been delivered but never acked stays visible in the group's pending entries list (XPENDING) — so a crashed worker's in-flight jobs aren't lost, they're just waiting to be claimed by another consumer (XCLAIM). That's the durability guarantee a plain List queue never had.
#Choosing the structure
| Structure | Key operations | Typical complexity | Canonical use |
|---|---|---|---|
| String | GET, SET, INCR | O(1) | Simple cached values, counters |
| Hash | HSET, HGET, HINCRBY | O(1) per field | Objects/records (cart, session) |
| Sorted Set | ZADD, ZRANGE, ZREMRANGEBYSCORE | O(log N) | Leaderboards, sliding-window rate limits, priority queues |
| List | LPUSH, RPOP, BRPOP | O(1) at the ends | Simple queues, recent-items lists |
| Set | SADD, SINTER, SISMEMBER | O(1) add/check | Tags, unique membership, set algebra |
| Stream | XADD, XREADGROUP, XACK | O(1) amortized append | Event logs, durable multi-consumer queues |
🧠 Check your understanding
0/4 · 0/4 answered1. You need an exact sliding-window rate limiter (not a fixed-window approximation). Which Redis structure and approach fits best?
2. A worker using BRPOP on a Redis List crashes right after popping a job but before finishing it. What happens to that job?
3. What does a Stream's consumer group give you that a plain List-based queue doesn't?
4. Why is running KEYS * against a large production keyspace risky?