Ch. 27 · Redis

Redis Data Structures and When to Use Each One

Strings, hashes, lists, sets, sorted sets, streams, HyperLogLog and bitmaps: what each Redis type costs and which interview use case it fits.

~8 min readbeginnerupdated Oct 6, 2026

“What data types does Redis support, and when would you use each?” is close to guaranteed in a Redis or system design interview. Listing the types is easy; interviewers are really asking whether you can map a feature to the right structure: a leaderboard, a shopping cart, a job queue, a unique visitor count. The wrong choice shows up later as race conditions, memory blow-ups or O(N) commands that stall the server.

This guide covers the core types in Redis 7.4 and 8.x, their costs, and a walkthrough that builds several features of one app. Valkey supports the same core types.

Before you start

You should know that Redis stores values under string keys and that each command operates on one type: HSET on a hash, ZADD on a sorted set. Calling a command on the wrong type returns a WRONGTYPE error. Big-O notation helps, because the cost of commands is the main reason to prefer one type over another. The examples use redis-cli and Python with redis-py.

The short answer

Redis has strings (bytes, counters, flags), hashes (field-value maps for objects), lists (ordered sequences with fast pushes and pops at both ends), sets (unique unordered members), sorted sets (unique members ordered by a score), streams (append-only logs with consumer groups), plus HyperLogLog for approximate unique counts, bitmaps for bit flags and geospatial indexes. I pick the type by the operations I need: hashes for objects updated field by field, sorted sets for anything ranked or time-ordered, streams for durable messaging, HyperLogLog when an approximate distinct count is enough. Redis 8 also ships JSON, the Query Engine, probabilistic types and vector sets in the core distribution.

How it works

Each type has one or two internal encodings. Small collections use a compact, contiguous listpack (or an intset for small sets of integers), which is cheap in memory but O(N) to search; Redis converts to a real hash table, skip list or quicklist once the collection exceeds a threshold such as hash-max-listpack-entries 128. OBJECT ENCODING key shows which one a key uses.

Type Encodings Signature commands Cost Use when you need
String int, embstr, raw SET, GET, INCR, SET NX O(1) a blob, counter, flag or lock
Hash listpack, hashtable HSET, HGET, HINCRBY, HEXPIRE O(1) per field an object with independent fields
List listpack, quicklist LPUSH, RPOP, BLMOVE, LTRIM O(1) at the ends a simple queue or a capped recent list
Set intset, listpack, hashtable SADD, SISMEMBER, SINTER O(1) membership uniqueness and set algebra
Sorted set listpack, skiplist + hashtable ZADD, ZINCRBY, ZRANGE, ZRANK O(log N) ranking, scheduling, time windows
Stream radix tree of listpacks XADD, XREADGROUP, XACK O(1) append durable events with consumer groups
HyperLogLog sparse, dense PFADD, PFCOUNT, PFMERGE at most 12 KB approximate distinct counts
Bitmap string SETBIT, BITCOUNT, BITOP 1 bit per ID per-ID boolean flags

Two details matter in interviews. A sorted set keeps both a skip list (for ordered ranges) and a hash table (for O(1) score lookup by member), which is why ZSCORE is O(1) while ZRANK is O(log N). And a list is not a good random-access array: LINDEX in the middle of a long list is O(N).

Step-by-step walkthrough

The steps below build features of a small shop: page counters, carts, recently viewed items, a leaderboard, an order queue and visitor statistics.

Step 1: Strings for counters and cached blobs

Strings hold any bytes up to 512 MB. When the value is an integer, INCR and INCRBY update it atomically on the server, which is why counters belong in Redis rather than in “read, add, write” application code.

127.0.0.1:6379> INCR views:product:42
(integer) 1
127.0.0.1:6379> SET product:42:json '{"name":"Desk lamp","price":39}' EX 300
OK
Terminal

A cached JSON blob is fine when you always read and replace the whole object. It becomes a problem as soon as two writers change different parts of it, as the worked scenario shows.

Step 2: Hashes for objects that change field by field

A cart maps SKUs to quantities. As a hash, each change is one atomic command and nothing is read back first:

127.0.0.1:6379> HSET cart:42 sku:111 2 sku:222 1
(integer) 2
127.0.0.1:6379> HINCRBY cart:42 sku:111 1
(integer) 3
127.0.0.1:6379> HGETALL cart:42
1) "sku:111"
2) "3"
3) "sku:222"
4) "1"
Terminal

Small hashes use the listpack encoding, so a cart with a dozen lines costs little memory. Redis 7.4 added per-field expiry (HEXPIRE cart:42 3600 FIELDS 1 promo), useful for a time-limited coupon line without expiring the whole cart.

Step 3: Lists for capped recent items, sorted sets for rankings

“Recently viewed” needs the newest items first and a fixed length. LPUSH followed by LTRIM keeps the list bounded:

pipe = r.pipeline()
pipe.lpush("recent:42", "p7")
pipe.ltrim("recent:42", 0, 4)       # keep the five newest
pipe.execute()
r.lrange("recent:42", 0, -1)        # ['p7', 'p6', 'p5', 'p4', 'p3']
python

A best-sellers board needs ranking. A sorted set with product IDs as members and units sold as scores does it in O(log N):

r.zincrby("bestsellers:2026-w41", 3, "p42")
top = r.zrange("bestsellers:2026-w41", 0, 9, desc=True, withscores=True)
rank = r.zrevrank("bestsellers:2026-w41", "p42")      # 0 = best seller
python

Members with equal scores are ordered by name, and reversed when you read in descending order, so bob comes before alice at the same score with desc=True. Encode a tie-breaker in the score if order among equals matters.

Step 4: Streams for work that must not be lost

A list with LPUSH and BRPOP is a fine queue until a worker crashes after popping a job: the job is gone. Streams keep entries and track delivery per consumer group:

127.0.0.1:6379> XGROUP CREATE orders billing $ MKSTREAM
OK
127.0.0.1:6379> XADD orders * customer 42 total 99.50
"1759834023123-0"
127.0.0.1:6379> XREADGROUP GROUP billing worker-1 COUNT 10 BLOCK 5000 STREAMS orders >
1) 1) "orders"
   2) 1) 1) "1759834023123-0"
         2) 1) "customer"
            2) "42"
            3) "total"
            4) "99.50"
127.0.0.1:6379> XACK orders billing 1759834023123-0
(integer) 1
Terminal

Until XACK, the entry sits in the group’s pending entries list; XAUTOCLAIM lets another worker take entries that stayed pending too long. Trim with XADD orders MAXLEN ~ 1000000 * ... so the stream does not grow forever.

Step 5: HyperLogLog and bitmaps for statistics

Unique visitors per day with a set would store every visitor ID. HyperLogLog estimates the count with about 0.81% standard error in at most 12 KB per key:

PFADD uv:2026-10-07 user:42 user:77 user:42
PFCOUNT uv:2026-10-07                          # 2
PFCOUNT uv:2026-10-06 uv:2026-10-07            # unique across both days
Terminal

When user IDs are dense integers and you need exact answers, a bitmap stores one bit per user: SETBIT active:2026-10-07 42 1, then BITCOUNT. Ten million users fit in about 1.2 MB per day, and BITOP AND finds users active on every day of a week.

Worked scenario

A shop stored each cart as a JSON string. Customers with two browser tabs open reported items disappearing. The code:

# Broken: read-modify-write across two round trips
cart = json.loads(r.get(f"cart:{uid}") or "{}")
cart[sku] = cart.get(sku, 0) + qty
r.set(f"cart:{uid}", json.dumps(cart), ex=86400)
python

Tab A reads {"sku:111": 1}, tab B reads the same, A writes {"sku:111": 1, "sku:222": 1}, then B writes {"sku:111": 2}, and the lamp A added is gone. Each command is atomic, but the sequence is not.

# Fixed: one atomic server-side update per change
pipe = r.pipeline()
pipe.hincrby(f"cart:{uid}", sku, qty)
pipe.expire(f"cart:{uid}", 86400)
pipe.execute()
python

HINCRBY changes one field without reading the others, so concurrent tabs cannot overwrite each other. If the cart truly needs nested data, the Redis 8 JSON type offers path updates such as JSON.NUMINCRBY, which are atomic in the same way.

Common mistake

  • “Use a list as a queue for anything.” A popped job is lost if the worker dies. Use BLMOVE into a processing list, or a stream with consumer groups, when jobs must not be lost.
  • “A set will do for unique counts.” At tens of millions of members per day the memory cost is large; HyperLogLog trades exactness for a fixed 12 KB.
  • Storing every object as JSON. Partial updates race and every read transfers the whole object.
  • Unbounded collections. A list, stream or sorted set that only grows becomes a big key; trim with LTRIM, MAXLEN or ZREMRANGEBYRANK.
  • Reading whole collections. HGETALL or SMEMBERS on a key with millions of elements blocks the server; use HSCAN and SSCAN.

Verify the behavior

Encodings and costs are observable with a few commands:

redis-cli DEL h
redis-cli HSET h f1 v1
redis-cli OBJECT ENCODING h          # "listpack"
redis-cli CONFIG GET hash-max-listpack-entries    # 128 by default
# add 200 fields, then check again
for i in $(seq 1 200); do redis-cli HSET h "f$i" v > /dev/null; done
redis-cli OBJECT ENCODING h          # "hashtable"
redis-cli MEMORY USAGE h             # bytes used by the key
redis-cli TYPE h                     # hash
Terminal

To check the cart race, run the broken and fixed versions from two processes in a loop and compare the final totals: the JSON version loses increments, the hash version never does.

Follow-up questions

How would you implement a delayed job queue? A sorted set with the run-at timestamp as the score. Workers fetch due jobs with ZRANGE jobs -inf <now> BYSCORE LIMIT 0 10 and claim each with ZREM, which only one worker can win, ideally inside a Lua script.

Why is ZSCORE O(1) but ZRANK O(log N)? The score lookup uses the sorted set’s hash table; the rank needs a walk through the skip list.

What does Redis offer for “find stores within 5 km”? Geospatial commands (GEOADD, GEOSEARCH ... BYRADIUS 5 km), stored as a sorted set of geohash scores.

When would you use a Bloom filter instead of HyperLogLog? A Bloom filter (BF.ADD, BF.EXISTS, built into Redis 8) answers “have I seen this item?” with false positives; HyperLogLog answers “how many distinct items?” but cannot test membership.

Interview exercise

Design the Redis keys for a news site: article view counts, the top 20 articles of the last 24 hours, a per-user “already read” check, and the daily number of unique readers. Name the type for each and its key cost.

Answer and reasoning

View counts: a string per article with INCR views:{id}, O(1). Top 20 of the last 24 hours: hourly sorted sets top:2026100714 incremented with ZINCRBY, each expiring after 25 hours, combined on read with ZUNIONSTORE over the last 24 keys (or precomputed every minute) and read with ZRANGE ... REV LIMIT; this avoids storing per-view timestamps. “Already read”: a set per user with SISMEMBER, O(1), or a Bloom filter if a rare false positive is acceptable and users read thousands of articles. Unique readers per day: HyperLogLog with PFADD, at most 12 KB per day, because an exact count is not needed for a dashboard. Each choice follows from the operation required, not from the shape of the data.

Continue learning

Practise with the Redis interview questions and the Redis MCQs. Related notes: Why is Redis single-threaded? explains why O(N) reads on big keys hurt, and Node.js caching layers and invalidation shows strings and hashes used as an application cache. Primary sources: Redis data types, Redis sorted sets, Redis Streams and HyperLogLog.

More in Redis

esc