The full data structure surface and how each uses listpack vs ziplist vs skiplist vs hashtable encoding under the hood — what triggers encoding upgrades and how encoding determines memory usage.
F-3 — Lists, Hashes, Sets, and Sorted Sets
Who this module is for: You know Redis has data structures beyond Strings — you have seen them in documentation — but you have never understood when to reach for each one, why they exist, or what is happening under the hood when you use them. This module teaches the full command surface and internal mechanics of Redis's four most important composite types. By the end, you will not just know the commands; you will know which structure to reach for before you start writing code.
Why Data Structures Matter in Redis
In F-2, you learned that a Redis String is not a string — it is a binary-safe byte array with encoding-aware storage. The same design philosophy applies to every Redis type: the shape of your data and the operations you need should determine which type you use.
This is different from how you might think about SQL. In PostgreSQL, you store everything in tables and columns, then join and aggregate at query time. In Redis, you choose the structure that makes your operations atomic and your memory efficient. The wrong choice does not cause a crash — it causes unnecessary complexity, non-atomic operations, and wasted RAM.
The four structures in this module — Lists, Hashes, Sets, and Sorted Sets — cover the vast majority of Redis use cases. Master these four and you can implement caching, queues, counters, leaderboards, sessions, pub/sub fan-out, social graphs, and rate limiters without reaching for anything else.
Lists
What a Redis List Is
A Redis List is an ordered sequence of strings, linked as a doubly-linked list (or, for small sizes, a listpack — more on this in the encoding section below). You can push elements to either end and pop elements from either end. This makes it both a stack (LIFO) and a queue (FIFO) depending on how you use it.
LPUSH tasks "task-c" → pushes to the LEFT (head)
LPUSH tasks "task-b"
LPUSH tasks "task-a"
State: [task-a, task-b, task-c] ← head is left
RPUSH tasks "task-d" → pushes to the RIGHT (tail)
State: [task-a, task-b, task-c, task-d]
Core Commands
Pushing and popping:
LPUSH key val [val ...] → push one or more values to the head, return new length
RPUSH key val [val ...] → push one or more values to the tail, return new length
LPOP key [count] → pop from the head, return value(s)
RPOP key [count] → pop from the tail, return value(s)
Multi-element pop (count argument) was added in Redis 6.2. Before that, you had to call LPOP in a loop.
Inspecting without consuming:
LLEN key → number of elements
LRANGE key start stop → return elements from index start to stop (inclusive)
LINDEX key index → return element at index (0 = head, -1 = tail)
LRANGE key 0 -1 returns all elements. Redis list indexes are zero-based, and negative indexes count from the tail: -1 is the last element, -2 is the second-to-last, and so on.
127.0.0.1:6379> RPUSH queue "job-1" "job-2" "job-3"
(integer) 3
127.0.0.1:6379> LRANGE queue 0 -1
1) "job-1"
2) "job-2"
3) "job-3"
127.0.0.1:6379> LPOP queue
"job-1"
127.0.0.1:6379> LRANGE queue 0 -1
1) "job-2"
2) "job-3"
Blocking pop — the pattern that powers job queues:
BLPOP key [key ...] timeout → block until an element is available at the head
BRPOP key [key ...] timeout → block until an element is available at the tail
BLPOP myqueue 0 blocks the client indefinitely until another client pushes to myqueue. The moment a push occurs, the blocked client is woken and the element is returned. This is how you build a job queue without polling:
# Worker process (blocking, waiting for work)
BLPOP tasks 0 → blocks here
# Producer (in another process/connection)
LPUSH tasks "job-99" → wakes the blocked worker
The timeout argument is in seconds. 0 means block forever (until an element arrives or the connection is closed). A non-zero timeout returns (nil) if nothing arrives within the timeout — useful for workers that need to periodically check for shutdown signals.
BLPOP accepts multiple keys and returns from the first one that has data — this lets a single worker consume from multiple queues with a single connection.
Moving between lists atomically:
LMOVE source destination LEFT|RIGHT LEFT|RIGHT
LMOVE source destination RIGHT LEFT pops from the right of source and pushes to the left of destination atomically. This is the correct way to implement a reliable queue: move a job from pending to processing, and if the worker crashes, the job is still in processing and can be recovered.
The non-atomic alternative — RPOP from one list then LPUSH to another — has a window where the job is in neither list if your application crashes between the two commands.
Trimming a list to a fixed size:
LTRIM key start stop
After LTRIM mylog 0 999, only the first 1,000 elements remain. Use this after every LPUSH to cap a capped log or recent-activity feed.
When to Use a List
- Job queue / task queue — RPUSH to enqueue, BLPOP to consume (FIFO)
- Stack — LPUSH to push, LPOP to pop (LIFO)
- Activity feed / recent events — LPUSH new events, LTRIM to keep the last N, LRANGE to read
- Reliable queue — LMOVE from pending to processing, acknowledge by deleting from processing
Do not use a List when:
- You need to look up elements by a specific value (no indexed lookup by content — use a Hash or Set)
- You need uniqueness (use a Set)
- You need ordering by score (use a Sorted Set)
- The list grows to millions of elements and you iterate it frequently (consider Streams)
Hashes
What a Redis Hash Is
A Redis Hash is a map of field names to values — exactly what you might call a dictionary, object, or associative array in your programming language. Both fields and values are strings.
HSET user:1001 name "Jatin" email "j@example.com" role "engineer"
HGET user:1001 name → "Jatin"
HGET user:1001 email → "j@example.com"
HGETALL user:1001 → ["name", "Jatin", "email", "j@example.com", "role", "engineer"]
Core Commands
Setting fields:
HSET key field value [field value ...] → set one or more fields, return count of new fields
HSETNX key field value → set field only if it does not exist
In Redis 4.0+, HSET accepts multiple field-value pairs. The old HMSET command (which did the same thing) is deprecated. Use HSET.
Getting fields:
HGET key field → return the value of field
HMGET key field [field ...] → return values of multiple fields (nil for missing)
HGETALL key → return all field-value pairs as a flat list
HKEYS key → return all field names
HVALS key → return all values
HLEN key → return the number of fields
HGETALL returns a flat alternating list: field1, value1, field2, value2, ... Client libraries typically convert this to a map for you. For large hashes, be careful with HGETALL — it returns everything in one call and can be slow for hashes with thousands of fields.
Checking and deleting:
HEXISTS key field → 1 if field exists, 0 if not
HDEL key field [field ...] → delete one or more fields, return count deleted
Incrementing numeric fields:
HINCRBY key field increment → atomically increment integer field
HINCRBYFLOAT key field increment
This is the right way to track per-user counters inside a Hash object:
HSET user:1001:stats posts 0 comments 0
HINCRBY user:1001:stats posts 1 → (integer) 1
HINCRBY user:1001:stats posts 1 → (integer) 2
HINCRBY user:1001:stats comments 5 → (integer) 5
HGETALL user:1001:stats
1) "posts"
2) "2"
3) "comments"
4) "5"
Scanning large hashes:
HSCAN key cursor [MATCH pattern] [COUNT count]
Like SCAN for top-level keys, HSCAN iterates over hash fields in batches. Use this instead of HGETALL for hashes with thousands of fields.
The Hash vs String JSON Trade-off
A common pattern is to store a user object as a JSON string:
SET user:1001 '{"name":"Jatin","email":"j@example.com","role":"engineer","posts":42}'
This works. But it has a critical flaw: every update requires reading the entire JSON, deserializing, modifying, reserializing, and writing it back. If two processes do this concurrently, one will overwrite the other's changes. You cannot atomically update a single field.
With a Hash:
HINCRBY user:1001 posts 1 → atomic, safe from concurrent updates
HSET user:1001 role "senior-engineer" → update one field, other fields untouched
Use a Hash when your object has fields you update independently. Use a String JSON blob only when you always read and write the entire object atomically (rare) or when the object is too complex for the flat Hash model.
When to Use a Hash
- Object/entity storage — user profiles, product metadata, session data
- Per-entity counters —
HINCRBY user:1001 page_views 1 - Configuration maps — feature flags, settings with multiple keys
- Grouping related counters — instead of 10 separate String keys, one Hash with 10 fields
Do not use a Hash when:
- You need TTL on individual fields (not supported — TTL is per key, not per field)
- The number of fields varies wildly and you need range queries (consider Sorted Set)
- You need to search by value (no indexed lookup by value in a Hash)
Sets
What a Redis Set Is
A Redis Set is an unordered collection of unique strings. It behaves like a mathematical set: no duplicates, no guaranteed order, and support for set-theoretic operations (union, intersection, difference).
SADD active:users "user:1001" "user:1002" "user:1003"
SADD active:users "user:1001" → returns 0 (already in set)
SMEMBERS active:users → {"user:1001", "user:1002", "user:1003"} (order not guaranteed)
Core Commands
Adding and removing:
SADD key member [member ...] → add members, return count of new members added
SREM key member [member ...] → remove members, return count removed
SPOP key [count] → remove and return random member(s)
SRANDMEMBER key [count] → return random member(s) without removing
Querying:
SMEMBERS key → return all members (use with care on large sets)
SCARD key → return the number of members (cardinality)
SISMEMBER key member → 1 if member exists, 0 if not
SMISMEMBER key member [member ...] → membership test for multiple members (Redis 6.2+)
Set operations:
SINTER key [key ...] → return members in all sets (intersection)
SUNION key [key ...] → return members in any set (union)
SDIFF key [key ...] → return members in first set not in any subsequent set (difference)
SINTERSTORE dest key [key ...] → store intersection result in dest
SUNIONSTORE dest key [key ...] → store union result in dest
SDIFFSTORE dest key [key ...] → store difference result in dest
The STORE variants write the result to a new key and return the count. Use these when you want to cache a set operation result rather than computing it repeatedly.
Scanning large sets:
SSCAN key cursor [MATCH pattern] [COUNT count]
127.0.0.1:6379> SADD user:1001:tags "postgres" "redis" "nodejs"
(integer) 3
127.0.0.1:6379> SADD user:1002:tags "redis" "python" "ml"
(integer) 3
127.0.0.1:6379> SINTER user:1001:tags user:1002:tags
1) "redis"
127.0.0.1:6379> SUNION user:1001:tags user:1002:tags
1) "postgres"
2) "redis"
3) "nodejs"
4) "python"
5) "ml"
When to Use a Set
- Tracking unique items — unique visitors today, users who have seen a notification
- Tags and categories — per-entity tag sets with intersection ("users who follow both X and Y")
- Social graph — followers, following (intersection = mutual followers)
- Deduplication — "have I processed this job ID before?" (SADD returns 0 if already exists)
- Random sampling — SPOP for lottery, SRANDMEMBER for random recommendations
Do not use a Set when:
- You need ordered results (use Sorted Set)
- You need to store values with associated data beyond the member string (use Sorted Set with score, or Hash)
- You need approximate uniqueness at extreme scale (use HyperLogLog, covered in F-5)
Sorted Sets
What a Redis Sorted Set Is
A Redis Sorted Set (also called a ZSet) is like a Set with an added requirement: every member has a score (a double-precision float), and members are ordered by score from lowest to highest. Within the same score, members are ordered lexicographically.
This single addition — the score — makes Sorted Sets one of the most versatile Redis types. You can implement leaderboards, rate limiters, priority queues, time-series indexes, and autocomplete with a Sorted Set.
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
ZADD leaderboard 1900 "player:charlie"
State (ordered by score):
player:alice 1500
player:charlie 1900
player:bob 2300
Core Commands
Adding and updating:
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]
The options make ZADD flexible:
NX— add only; never update existing membersXX— update only; never add new membersGT— only update if new score > current scoreLT— only update if new score < current scoreCH— return count of members changed (added + updated); default returns only count addedINCR— instead of setting the score, increment it; equivalent toZINCRBY
ZADD leaderboard GT 2500 "player:alice" → updates alice only if 2500 > current score
ZADD leaderboard NX 1000 "player:dave" → adds dave only if not already present
Querying by rank (position):
ZRANK key member → return rank (0 = lowest score); nil if not found
ZREVRANK key member → return rank from highest (0 = highest score)
ZRANGE key start stop [REV] [BYSCORE|BYLEX] [LIMIT offset count] [WITHSCORES]
As of Redis 6.2, ZRANGE is the unified range query command. Before 6.2, you used separate commands:
ZRANGE key start stop— by rank, ascendingZREVRANGE key start stop— by rank, descendingZRANGEBYSCORE key min max— by score, ascendingZREVRANGEBYSCORE key max min— by score, descending
All of these still work in Redis 7, but ZRANGE with REV and BYSCORE options is the modern approach.
# Get top 3 players (highest scores)
ZRANGE leaderboard 0 2 REV WITHSCORES
1) "player:bob"
2) "2300"
3) "player:charlie"
4) "1900"
5) "player:alice"
6) "1500"
# Get all players with score between 1000 and 2000
ZRANGE leaderboard 1000 2000 BYSCORE WITHSCORES
1) "player:alice"
2) "1500"
3) "player:charlie"
4) "1900"
Querying scores and cardinality:
ZSCORE key member → return the score of a member
ZCARD key → return total number of members
ZCOUNT key min max → count members with score between min and max
ZLEXCOUNT key min max → count members with lex range (same-score members)
Removing:
ZREM key member [member ...] → remove members
ZREMRANGEBYRANK key start stop → remove by rank range
ZREMRANGEBYSCORE key min max → remove by score range
ZPOPMIN key [count] → remove and return lowest-scored member(s)
ZPOPMAX key [count] → remove and return highest-scored member(s)
Incrementing scores:
ZINCRBY key increment member → atomically add increment to member's score, return new score
This is the safe way to update a leaderboard score without a read-modify-write cycle:
ZINCRBY leaderboard 150 "player:alice" → alice's score is now 1650 (was 1500)
Scanning:
ZSCAN key cursor [MATCH pattern] [COUNT count]
Building a Real-Time Leaderboard
The leaderboard is the canonical Sorted Set use case. Here is how it works in practice:
# A player completes a level and earns 500 points
ZINCRBY leaderboard:season:1 500 "user:1001"
# Get user's current rank (1-based, descending)
# ZREVRANK returns 0-based rank; add 1 for display
rank = ZREVRANK leaderboard:season:1 "user:1001" → 4 (5th place)
# Get top 10
ZRANGE leaderboard:season:1 0 9 REV WITHSCORES
# Get users near a specific user (their neighborhood in the leaderboard)
ZREVRANK leaderboard:season:1 "user:1001" → 4
ZRANGE leaderboard:season:1 2 6 REV WITHSCORES → positions 3-7
All operations are O(log N). The Sorted Set's skip list data structure makes rank queries, range queries, and insertions all logarithmic — the same asymptotic complexity as a balanced binary tree, but with better real-world constant factors for this access pattern.
Using Sorted Sets as Time-Series Indexes
Store the Unix timestamp as the score:
ZADD events:user:1001 1717000000 "event:login:abc123"
ZADD events:user:1001 1717003600 "event:purchase:def456"
ZADD events:user:1001 1717007200 "event:logout:ghi789"
# Events from the last hour (timestamps are seconds since epoch)
now = current_unix_timestamp
ZRANGEBYSCORE events:user:1001 (now-3600) now WITHSCORES
# Prune events older than 30 days
ZREMRANGEBYSCORE events:user:1001 0 (now - 2592000)
This is a common pattern for activity feeds, audit logs, and sliding-window rate limiting.
When to Use a Sorted Set
- Leaderboards — score as float, member as user ID
- Rate limiters — timestamp as score, request ID as member (count members in recent window)
- Priority queues — priority as score, job ID as member (ZPOPMIN = get highest priority)
- Time-series indexes — Unix timestamp as score, event ID as member
- Autocomplete — lex-ordered members with same score for prefix queries
- Scheduling — scheduled-at timestamp as score, task ID as member (poll with ZRANGE ... BYSCORE 0 now)
Internal Encodings
Understanding what Redis stores internally explains memory usage and performance cliffs.
Lists
| Size | Encoding | Description |
|---|---|---|
| ≤ 128 elements, each ≤ 64 bytes | listpack | Compact byte array, no pointers, cache-friendly |
| > 128 elements or any element > 64 bytes | quicklist | Doubly-linked list of listpack nodes |
Before Redis 7.0, the small-list encoding was ziplist. Redis 7.0 replaced it with listpack (more compact, same concept). The thresholds are controlled by list-max-listpack-size and list-max-ziplist-size config.
A quicklist is a doubly-linked list where each node contains a listpack (a compact byte array). This gives you the cache efficiency of contiguous memory for small sublists and the O(1) push/pop of a linked list.
Hashes
| Size | Encoding |
|---|---|
| ≤ 128 fields, each ≤ 64 bytes | listpack |
| > 128 fields or any field/value > 64 bytes | hashtable |
The listpack encoding stores field-value pairs sequentially in a byte array. For a small user profile object (< 128 fields), Redis uses essentially zero pointer overhead. The moment you add the 129th field — or a field with a value longer than 64 bytes — the entire Hash is converted to a hashtable with pointer-based buckets.
Config keys: hash-max-listpack-entries (default 128), hash-max-listpack-value (default 64).
Sets
| Condition | Encoding |
|---|---|
| All members are integers, ≤ 512 members | intset |
| ≤ 128 members, each ≤ 64 bytes | listpack |
| Otherwise | hashtable |
intset is a sorted array of integers stored in minimal bytes (2, 4, or 8 bytes per element depending on range). It is remarkably compact and fast to search (binary search). The moment you add a non-integer member, the entire set converts to listpack or hashtable.
Config keys: set-max-intset-entries (default 512), set-max-listpack-entries (default 128), set-max-listpack-value (default 64).
Sorted Sets
| Size | Encoding |
|---|---|
| ≤ 128 members, each ≤ 64 bytes | listpack |
| > 128 members or any member > 64 bytes | skiplist + hashtable |
The skiplist encoding uses a probabilistic skip list for the ordered-by-score structure and a hashtable for O(1) score lookup by member. Both structures point to the same member/score pairs in memory — the memory cost is one set of data with two indexes.
The listpack encoding is a sorted byte array: fast for small sets (no pointer overhead), but linear-time lookup. This is fine for ≤ 128 members.
Config keys: zset-max-listpack-entries (default 128), zset-max-listpack-value (default 64).
Inspecting Encodings
127.0.0.1:6379> RPUSH mylist "a" "b" "c"
(integer) 3
127.0.0.1:6379> OBJECT ENCODING mylist
"listpack"
127.0.0.1:6379> HSET user:1001 name "Jatin"
(integer) 1
127.0.0.1:6379> OBJECT ENCODING user:1001
"listpack"
127.0.0.1:6379> SADD nums 1 2 3 4 5
(integer) 5
127.0.0.1:6379> OBJECT ENCODING nums
"intset"
127.0.0.1:6379> ZADD scores 1.0 "alice" 2.0 "bob"
(integer) 2
127.0.0.1:6379> OBJECT ENCODING scores
"listpack"
Encoding Thresholds and Memory Implications
The default thresholds are good starting points, but they are not sacred. If your use case has objects that always stay small, you can raise the listpack thresholds to keep more objects in the compact encoding:
# In redis.conf
hash-max-listpack-entries 256 # was 128
hash-max-listpack-value 128 # was 64
The trade-off: larger listpack means more scanning per operation (linear time within the listpack). For objects you always HGETALL (read all fields), a larger listpack is fine. For objects where you HGET specific fields frequently, raising the threshold too high can make individual field lookups slower.
In practice: profile with OBJECT ENCODING and OBJECT IDLETIME. If your hashes are at hashtable encoding with only 10 fields, your encoding threshold is too low.
Choosing the Right Structure
Here is a decision framework you can internalize:
Does your data have a natural ordering by importance/priority/time?
→ Yes → Sorted Set (the score is the ordering)
Does your data need uniqueness with no associated metadata?
→ Yes, and order doesn't matter → Set
→ Yes, and order matters (by score) → Sorted Set
Does your data have named fields that you update independently?
→ Yes → Hash
Does your data need FIFO/LIFO access or blocking consumers?
→ Yes → List
Is your data a simple value that you cache, count, or atomically update?
→ String
One entity with multiple attributes + per-attribute counters + access by ID:
→ Hash for attributes, separate Sorted Set for ranking that entity
Summary
- Lists — ordered sequences with head/tail access. Use for queues (RPUSH/BLPOP), stacks (LPUSH/LPOP), activity feeds (LPUSH + LTRIM).
LMOVEfor reliable queues. - Hashes — field-value maps. Use for entity objects (user profiles, sessions).
HINCRBYfor atomic per-field counters. Prefer over JSON strings when you update individual fields. - Sets — unordered unique collections. Use for uniqueness tracking, social graphs (SINTER for mutual follows), deduplication. Set operations (SINTER/SUNION/SDIFF) are atomic.
- Sorted Sets — unique members ordered by float score. Use for leaderboards (ZINCRBY + ZREVRANK), time-series indexes (timestamp as score), priority queues (ZPOPMIN), rate limiters.
- Encodings — all four types start in a compact listpack/intset encoding and upgrade to pointer-based structures when they exceed configured thresholds. Know your thresholds; inspect with
OBJECT ENCODING.
Next: F-4 — TTL, Expiry, and Eviction — where we cover how Redis manages key lifetimes, the seven eviction policies, and how to design your keyspace so you never run out of memory unexpectedly.