Redis Data Structures in Practice: Choosing the Right Type for the Job
Strings, lists, sets, sorted sets, hashes, hyperloglogs, bitmaps, and streams each solve a different class of problem. Here is the decision tree, the memory cost of each type, and the production patterns that separate the "I learned Redis in a tutorial" tier from the "I run it in production" tier.
Every Redis tutorial starts the same way: “Redis is an in-memory data structure store.” What they don’t tell you is that picking the wrong data structure costs you real money. A sorted set with 10 million members consumes roughly 1.2 GB of RAM. The same data in a list consumes about half that. The same data in a hash with field-level expiration costs something else entirely.
In production, the difference between a well-chosen Redis type and a convenient one is measurable in Redis memory, CPU, network round-trips, and ultimately your cloud bill. This post maps every data structure to the problems it solves best, with the memory characteristics and operational traps you need to know before you deploy it.
The decision tree
Before you write a single Redis command, ask three questions about your data:
- Do I need to access individual fields within a record, or is the record atomic? Hashes vs. strings.
- Do I need ordering, uniqueness, or both? Sets (unique, unordered), sorted sets (unique, ordered), lists (ordered, duplicates allowed).
- Do I need approximate counts, or exact ones? HyperLogLog (approximate, 12 KB fixed) vs. a sorted set or set (exact, memory scales with cardinality).
The rest of this post works through each type with the code patterns you will actually use and the gotchas that will bite you in production.
Strings: the swiss army knife (but don’t use it for everything)
Strings are the default. They store up to 512 MB per key, they support atomic increment/decrement, and they are the only type that works with SET NX (distributed locks) and SET EX (expiring cache entries). They are also the most abused type in Redis.
Good fits: Cache values (serialized JSON, HTML fragments), counters, distributed locks, rate-limit tokens, session tokens.
Bad fits: Any data with multiple fields (use a hash), any ordered collection (use a sorted set or list), any collection that needs uniqueness checks at scale (use a set).
Memory characteristics
A string key stores the key name plus the value. At 10 million keys holding 100-byte values, you are looking at roughly 1 GB of RAM just for the keys, plus the values. The overhead per key is about 50 bytes (Redis object headers, pointers, allocator waste). At scale, this overhead dominates.
# Python with redis-py — the pattern is the same in any language
import redis
r = redis.Redis()
# Cache a serialized object (good use)
r.setex("user:42:profile", 3600, '{"name":"Alice","role":"admin"}')
# Counter (excellent use)
r.incr("page:homepage:visits")
# Distributed lock (good use with proper caution)
ok = r.set("lock:deploy", "1", nx=True, ex=30)
# The trap: storing structured data as a string when you need fields
# Don't store "user:42:profile" as a JSON string if you need to read
# just the email address — that is a hash job
The SET trap: SET key value without NX or EX overwrites the key unconditionally. Use SET with explicit TTL for cache entries. Use SETNX or SET ... NX for locks. Use SETEX or SET ... EX for expiration. The multi-argument SET (Redis 2.6.12+) is your friend.
Hashes: structured records without the serialization tax
If your value has multiple named fields, use a hash. A single hash with 100 fields is a single Redis key. The same data in 100 separate string keys is 100 keys with 100x the overhead.
Good fits: User profiles, session data, product records, configuration objects, any structured entity with field-level access patterns.
Memory characteristics: Fields within a hash are stored compactly below a threshold (default hash-max-ziplist-entries = 512 and hash-max-ziplist-value = 64). Above those limits, Redis converts to a hash table internally, and memory usage jumps by 2-3x. Tune these limits if your hashes are small but numerous.
# Hash: one key, many fields
r.hset("user:42", mapping={
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
"created_at": "2026-01-15"
})
# Read one field without fetching the whole record
email = r.hget("user:42", "email")
# Atomic field update
r.hincrby("user:42", "login_count", 1)
# Get all fields (expensive for large hashes — use with caution)
profile = r.hgetall("user:42")
The HGETALL trap: HGETALL fetches every field and value. On a hash with 10,000 fields, this is a multi-megabyte response over the network. If you only need one field, use HGET. If you need several, use HMGET with the specific field names. HGETALL is for debugging and admin panels, not hot paths.
Lists: queues, stacks, and time-series buffers
Lists are ordered collections of strings. They support push/pop from both ends (LPUSH, RPUSH, LPOP, RPOP) and block on pop (BLPOP, BRPOP). They are the backbone of Redis-based queues.
Good fits: Job queues (with BRPOP), recent-activity feeds, log buffers, rate-limit windows as rolling buffers.
Bad fits: Random access by index at scale (O(N) after the first few elements), uniqueness checks (use a set), priority-based ordering (use a sorted set).
Memory characteristics: Lists use a doubly-linked list internally. Each element carries a 21-byte overhead (two pointers, length, pre-allocation). At 10 million elements with 100-byte values, you are looking at about 1.2 GB. The MEMORY USAGE command estimates this per key.
# Job queue — producer side
r.rpush("queue:email", json.dumps({"to": "user@example.com", "template": "welcome"}))
# Job queue — consumer side (blocking, waits up to 5 seconds)
_, job_json = r.blpop("queue:email", timeout=5)
# Recent activity feed (trimmed to 100 items)
r.lpush("feed:global", "user:42 posted a comment")
r.ltrim("feed:global", 0, 99)
# The trap: don't use LINDEX on growing lists
# LINDEX is O(N) and gets slower as the list grows
# Use a sorted set if you need positional access
The unbounded-list trap: If you push without trimming, the list grows forever. Memory is finite. Always set a cap with LTRIM after push, or use a bounded pattern like LPUSH + LTRIM in a transaction or Lua script.
Sets: uniqueness without ordering
Sets store unique strings with O(1) add, remove, and membership check. They support set operations (union, intersection, difference) server-side, which is their killer feature.
Good fits: Tag systems, permission sets, deduplication filters, “who is online” tracking, IP allow/deny lists.
Memory characteristics: Sets use a hash table internally. At 1 million members with 36-byte strings (UUIDs), expect roughly 80 MB. The overhead per entry is about 64 bytes (hash table entry, key pointer, value pointer, and bucket overhead).
# Tag a post with tags
r.sadd("post:101:tags", "redis", "performance", "database")
# Check if a post has a specific tag
has_tag = r.sismember("post:101:tags", "redis") # True
# Find posts with overlapping tags
overlap = r.sinter("post:101:tags", "post:102:tags")
# Deduplicate — track processed IDs
processed = r.sadd("processed:webhooks:hook-123", "event-456")
if processed:
# This ID was not processed before
process_event()
# The trap: SMEMBERS at scale
# SMEMBERS returns every member in one shot. On a set with 100k members,
# that is a 100k-element list over the wire. Use SSCAN for iteration.
The SMEMBERS trap: SMEMBERS is the HGETALL of sets. It fetches everything. For sets larger than a few thousand members, use SSCAN with a cursor to iterate in batches, or SRANDMEMBER if you only need a sample.
Sorted sets: the leaderboard Swiss Army knife
Sorted sets add a score (a 64-bit floating-point number) to each member. Members are ordered by score, with O(log N) for add, remove, and range queries. They are the most versatile data structure in Redis.
Good fits: Leaderboards, rate-limit counters with sliding windows, delayed job queues (score = timestamp), autocomplete indexes, time-series data with timestamp scores, any “top N” query.
Memory characteristics: Sorted sets use a skip list plus a hash table. At 10 million members, expect roughly 1.2 GB. The overhead per entry is about 70 bytes (hash table entry, skip list nodes, score), which makes them the most expensive Redis type per element.
# Leaderboard: add player scores
r.zadd("leaderboard:daily", {"player:42": 1500, "player:17": 2300, "player:99": 800})
# Top 3 players
top3 = r.zrevrange("leaderboard:daily", 0, 2, withscores=True)
# Returns [('player:17', 2300.0), ('player:42', 1500.0), ('player:99', 800.0)]
# Player rank (0-indexed, so +1 for 1-based)
rank = r.zrevrank("leaderboard:daily", "player:42") # 1 (second place)
# Score range query: find scores between 1000 and 2000
results = r.zrangebyscore("leaderboard:daily", 1000, 2000)
# Sliding window rate limiter — score = timestamp in ms
# Clean old entries then count
now = int(time.time() * 1000)
window_ms = 60_000 # 1 minute
r.zremrangebyscore("ratelimit:user:42", 0, now - window_ms)
count = r.zcard("ratelimit:user:42")
if count < 100:
r.zadd("ratelimit:user:42", {f"req:{now}": now})
The score-precision trap: Scores are 64-bit floats. If your score needs to be an exact integer (like a Unix timestamp in milliseconds), it fits exactly up to 2^53. Beyond that, float precision causes rounding errors. For IDs and large integers, store the exact value in the member string and use the score only for ordering.
The ZREVRANK vs ZRANK trap: ZREVRANK returns rank from highest score to lowest (rank 0 = highest). ZRANK returns rank from lowest to highest. Mixing them up gives you inverted leaderboards. Pick one convention and stick to it, or wrap it in a function that documents the direction.
HyperLogLog: counting unique things with 12 KB
HyperLogLog (HLL) estimates the cardinality of a set using a fixed 12 KB of memory, regardless of how many unique items you count. The error is about 0.81% at standard error, which is good enough for most counting use cases.
Good fits: Unique visitor counts (daily active users, page views), distinct query counts, cardinality estimation for large datasets where exact counts are not required, bot detection (unique IPs per session).
Bad fits: Any situation where you need the actual members (HLL only gives counts), sub-million cardinalities where exact counts are easy, any use case where 0.81% error is unacceptable (auditing, billing).
# Count unique visitors to a page
r.pfadd("uv:page:homepage:2026-07-04", "user:42", "user:17", "user:99")
r.pfadd("uv:page:homepage:2026-07-04", "user:42") # Duplicate, ignored
# Get approximate unique count
approx = r.pfcount("uv:page:homepage:2026-07-04")
# Returns 3, even though we added user:42 twice
# Merge multiple HLLs for a weekly report
r.pfmerge("uv:page:homepage:week-27", "uv:page:homepage:2026-07-01",
"uv:page:homepage:2026-07-02", "uv:page:homepage:2026-07-04")
weekly_approx = r.pfcount("uv:page:homepage:week-27")
The small-cardinality trap: HLL has higher relative error at small cardinalities (under a few thousand). Below 1000, the error can be 5-10%. If you need exact counts under 10,000, use a set or a sorted set instead.
Bitmaps: when you need to track millions of booleans
Bitmaps are just strings manipulated at the bit level with commands like SETBIT, GETBIT, BITCOUNT, and BITOP. They are the most memory-efficient way to track binary state for large numbers of items.
Good fits: Feature flags per user, daily active user tracking (bit position = user ID), bloom filters (approximate membership), attendance tracking, any yes/no state for millions of entities.
Memory characteristics: A bitmap with N bits uses N/8 bytes. 1 million bits = 125 KB. 100 million bits = 12.5 MB. Compare this to a set of 100 million user IDs (UUIDs = 36 bytes each = 3.6 GB) and the savings are dramatic.
# Track user sign-ins: bit position = user ID (shifted by some offset)
# User 42 signed in on day 0 (2026-07-01)
r.setbit("signin:2026-07-01", 42, 1)
# User 42 signed in on day 1 (2026-07-02)
r.setbit("signin:2026-07-02", 42, 1)
# User 17 signed in on day 2
r.setbit("signin:2026-07-03", 17, 1)
# Did user 42 sign in on 2026-07-01?
signed_in = r.getbit("signin:2026-07-01", 42) # 1 (true)
# How many users signed in on 2026-07-01?
total = r.bitcount("signin:2026-07-01")
# Users who signed in every day this week (AND across days)
r.bitop("AND", "signin:week-27-active", "signin:2026-07-01",
"signin:2026-07-02", "signin:2026-07-03")
weekly_active = r.bitcount("signin:week-27-active")
The bit-offset trap: SETBIT creates the key and pads zeros up to the byte containing the offset. Setting bit 100,000,000 on a key that doesn’t exist allocates 12.5 MB of zeroed memory immediately. Do not use sparse bitmaps with large offsets unless you are prepared for the memory spike. Pre-allocate the key with a known size or use an explicit SETRANGE approach.
The ID-to-bit mapping trap: You need a consistent mapping from entity ID to bit position. This falls apart if IDs are not sequential or if you delete entities. A common pattern is to maintain a separate Redis hash or a database sequence that maps logical IDs to sequential bitmap positions.
Streams: the message log you can replay
Redis Streams (Redis 5.0+) are append-only logs of structured messages with consumer groups, acknowledgment tracking, and replay semantics. They are closer to Kafka than to Redis Pub/Sub.
Good fits: Event sourcing, audit logs, task queues with at-least-once delivery, data pipelines that need replay capability, change-data-capture feeds.
Bad fits: Simple pub/sub (use Pub/Sub instead, which is faster and has no memory overhead), single-consumer work queues (use a list with BRPOP, which is simpler), situations where you need strong consistency with a relational database (use Postgres).
Memory characteristics: Stream entries are stored as radix trees internally, which compresses well for sequential data. Each entry stores the message ID (timestamp-sequence), the field-value pairs, and overhead. A stream of 1 million entries with two fields each uses about 100-200 MB, depending on field size. The trade-off is tunable: stream-node-max-entries and stream-node-max-bytes control the radix tree node sizes.
# Producer: add an event to the stream
r.xadd("events:orders", {
"order_id": "ord_123",
"user_id": "user_42",
"amount": "49.99",
"status": "completed"
}, maxlen=10000) # capped stream — prevents unbounded growth
# Consumer group: create group (once)
r.xgroup_create("events:orders", "email-service", id="0", mkstream=True)
# Consumer: read new messages, acknowledging after processing
messages = r.xreadgroup("email-service", "worker-1", {"events:orders": ">"}, count=10)
for stream_name, entries in messages:
for msg_id, fields in entries:
process_order(fields)
r.xack("events:orders", "email-service", msg_id)
# Replay: read from a specific point in time
old_orders = r.xrange("events:orders", "-", "+", count=100)
The unbounded-stream trap: Without MAXLEN, a stream grows forever. Unlike a list where you can LTRIM, streams need XTRIM or the MAXLEN option on XADD. Every production stream should have a cap. Pick a number that covers your replay window plus a safety margin, and trim aggressively.
The consumer-group-creation trap: Creating a consumer group on an existing stream triggers XGROUP CREATE with an $ argument (only new messages) or a 0 argument (from the beginning). If you create the group after the stream has data and want to process existing messages, use 0. If you forget to set MKSTREAM, creating a group on a non-existent stream raises an error.
Geospatial: location-based queries in two commands
Redis geospatial indexes (added in Redis 3.2) store longitude/latitude pairs in a sorted set under the hood. The score is a 52-bit geohash, meaning every geospatial command is actually a sorted set command with a geohash wrapper.
Good fits: Nearby venue lookups, ride-hailing driver discovery, delivery zone checks, geo-fencing, location-based leaderboards.
Bad fits: Complex polygon queries (Redis only supports circular radius queries), multi-polygon containment checks (do that in PostGIS), 3D geospatial.
Memory characteristics: Same as a sorted set (because it is a sorted set). Each entry adds the same 70-ish bytes of overhead. The geohash score replaces your application-level score, so you cannot have both a geospatial index and a custom sort order on the same key.
# Add venues with lat/lon
r.geoadd("venues", (13.4050, 52.5200, "berlin_central")) # (lon, lat, member)
r.geoadd("venues", (13.4114, 52.5234, "brandenburg_gate"))
r.geoadd("venues", (13.3867, 52.5163, "potsdamer_platz"))
# Find venues within 1 km of a point
nearby = r.geosearch("venues", longitude=13.4040, latitude=52.5205,
radius=1, unit="km", withdist=True)
# Returns members with distance in meters, sorted nearest-first
# Get distance between two venues
dist = r.geodist("venues", "berlin_central", "brandenburg_gate", unit="km")
The (lon, lat) vs (lat, lon) trap: GEOADD takes longitude first, then latitude. If you are used to (lat, lon) from mapping libraries or PostGIS, you will swap them on your first attempt. Redis will silently accept the wrong order and store a point on the other side of the globe. Validate with GEOPOS after adding.
The score-clobbering trap: Because geospatial indexes are sorted sets, ZADD against a geospatial key overwrites the geohash score with your value. Use GEOADD to add entries and never run ZADD on a geospatial key unless you enjoy debugging why GEORADIUS returns nothing.
Choosing the right type: the cheat sheet
| Problem | Type | Memory efficiency | Why |
|---|---|---|---|
| Cache a serialized blob | String | High | No overhead beyond key + value |
| Store a structured record with field access | Hash | High | Single key, compact under 512 fields |
| Job queue (blocking consumer) | List | Medium | Linked list, trim or it grows unbounded |
| Deduplication / uniqueness | Set | Medium | O(1) membership check, hash table overhead |
| Leaderboard / top N | Sorted Set | Low (most expensive per element) | Skip list + hash table, 70 bytes overhead per entry |
| Unique visitor count (approximate) | HyperLogLog | Very high | 12 KB fixed, regardless of cardinality |
| Track millions of booleans | Bitmap | Very high | 1 bit per item, 125 KB per million |
| Message stream with replay | Stream | Medium | Radix tree, tunable with config |
| Nearby location queries | Geo (Sorted Set) | Low (same as sorted set) | Geohash stored as score |
Four anti-patterns that cost real money
1. String-everything. The single most common Redis anti-pattern. Every JSON blob stored as a string is a key you cannot query internally, a field you cannot update atomically, and an overhead entry you pay for with RAM. If your value has more than one logical field, it belongs in a hash.
2. Sets as unbounded dedup filters. A set with 50 million UUIDs can consume 3 GB of RAM. If you only need to check “have I seen this ID before?” and you can tolerate a tiny false-positive rate, use a Bloom filter (via RedisBloom module) or a bitmap. If you must use a set, set a TTL or eviction policy.
3. Sorted sets for everything “ordered.” Sorted sets are expensive. If you do not need both uniqueness and score-based ordering, you are overpaying. A list is cheaper for a simple FIFO queue. A set is cheaper for uniqueness without order. A hash is cheaper for structured records. Reserve sorted sets for actual leaderboard or priority-queue use cases.
4. No TTL on anything. Redis is volatile memory. If every key you write is permanent, your memory usage only goes up. Every key should have a TTL unless you have explicitly decided it should not. Use EXPIRE on existing keys or SET ... EX / SETEX on new ones. The allkeys-lru eviction policy is a safety net, not a storage plan.
The takeaway
Redis offers eight distinct data structures, each with different memory characteristics, access patterns, and operational trade-offs. The difference between a well-chosen type and a convenient one is measurable in your Redis memory graph, your request latency, and your cloud bill.
The decision is not complicated: ask whether you need ordering, uniqueness, field-level access, approximate counts, or geospatial queries. Pick the cheapest type that satisfies your requirements. Set a TTL. Cap your collections. Validate coordinates before storing them. Your production Redis instance will thank you with lower memory, fewer slow commands, and fewer 3 AM pages.
A note from Yojji
The difference between a Redis instance that holds comfortably at 4 GB and one that burns through 16 GB and still needs evictions is almost always a data structure decision made in the first week of development. Getting these choices right requires the kind of production experience that comes from shipping systems under real load, not from reading the documentation.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams work across the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and build scalable, data-intensive applications where every byte of Redis memory is accounted for. If your team needs senior engineering depth on data architecture, infrastructure design, or full-cycle product development, Yojji is worth a conversation.