The idea: one type, several layouts
A Redis key holds a string, a hash, a set, a sorted set or a list. That is the value's type, and it decides which commands work on it. How the bytes are laid out in memory is a separate choice, the encoding. Redis keeps small values in compact, pointer-free blocks and switches to bigger general structures only when a value grows. OBJECT ENCODING key tells you which one a key uses.
The page runs real commands against a few keys and draws, after each step, the key's header and the bytes behind it. Pick a command and press Run, or run a demo. The thresholds are scaled down (4 entries and 8 bytes instead of 128 and 64) so a conversion happens after a few clicks; the CONFIG box on the canvas lists the real defaults.
The redisObject
Every value in the keyspace is a redisObject (robj) of 16 bytes:
| Field | Size | What it holds |
|---|---|---|
type | 4 bits | OBJ_STRING, OBJ_LIST, OBJ_SET, OBJ_ZSET, OBJ_HASH (and streams, modules) |
encoding | 4 bits | OBJ_ENCODING_INT, EMBSTR, RAW, LISTPACK, INTSET, HT, SKIPLIST, QUICKLIST |
lru | 24 bits | the LRU clock when the key was last used, or LFU counter data (see expiry and eviction) |
refcount | 4 bytes | how many places point at it; shared objects use INT_MAX |
ptr | 8 bytes | the address of the data, or for the int encoding the number itself |
On top of that, each key costs a dictEntry (24 bytes) in the keyspace's hash table, a bucket pointer and an SDS string for the key name. The ≈ bytes on the canvas add these up.
Strings: int, embstr, raw
When a string is stored, tryObjectEncoding picks the smallest form (Demo: string int → embstr → raw):
- int: the value is a whole number that fits a C
long, like42or123456. The number goes intoptritself, so there is no second allocation. Numbers 0 to 9999 go further: Redis keeps one shared object for each (shared.integers,OBJ_SHARED_INTEGERS= 10000), and every key with that value points at it. - embstr: up to 44 bytes. The robj and the string are one allocation: 16 bytes of robj + 3 bytes of SDS header + 44 bytes + the trailing
\0= 64 bytes, exactly one jemalloc size class. One malloc, one free, and the bytes are in the same cache line as the header. - raw: longer strings, or any string that has been changed. The robj and the SDS are two allocations.
An SDS (simple dynamic string) is a small header followed by the bytes: len (bytes used), alloc (bytes reserved), flags (which header size: SDS_TYPE_8 uses 1-byte len and alloc for strings under 256 bytes), then buf and a trailing \0, so C functions can read it too. ptr points at buf; the header sits just before it.
An embstr is read-only. APPEND first copies an int or embstr value into a new raw SDS of exactly the right size, then sdsMakeRoomFor grows it to twice the new length (for strings under 1 MB; above that, by 1 MB at a time). The next APPEND fits into the spare room without a realloc. SETRANGE, INCRBYFLOAT and friends work the same way.
A listpack, byte by byte
A small hash, set, sorted set or list is one listpack: a single block of bytes with no pointers inside (Demo: hash listpack → hashtable).
- Header: the total size in bytes (4 bytes) and the number of entries (2 bytes).
- Entries: an encoding byte that says what follows, the data, and backlen, the entry's own length, so the listpack can also be walked from the end. Strings up to 63 bytes take 1 byte of encoding (
str6). Integers are stored as integers: 0 to 127 in a single byte (int7), then 13-bit, 16, 24, 32 and 64-bit forms. - End byte:
0xFF.
A hash stores field, value, field, value. A sorted set stores member, score, kept in score order. Finding something means walking the entries, which is fast while the listpack is short and stays in a few cache lines. That is why there is a size limit. (The listpack replaced the older ziplist in Redis 7; the ziplist had a "previous entry length" field that could set off cascading updates.)
The intset
A set of only integers starts as an intset: a sorted array of fixed-width numbers after an 8-byte header (encoding, length). Lookups are a binary search. All elements have the same width, int16, int32 or int64. When a number arrives that doesn't fit, like 70000 in an int16 set, intsetUpgradeAndAdd widens every element, working from the back so nothing is overwritten (Demo: set intset → listpack → hashtable). An intset never narrows again.
From small to big
| Type | Small | Big | Limits (real default · on the page) |
|---|---|---|---|
| hash | listpack | hashtable | hash-max-listpack-entries 128 · 4, hash-max-listpack-value 64 · 8 |
| set | intset, then listpack | hashtable | set-max-intset-entries 512 · 8, set-max-listpack-entries 128 · 4, set-max-listpack-value 64 · 8 (listpack sets: Redis 7.2) |
| sorted set | listpack | skiplist + dict | zset-max-listpack-entries 128 · 4, zset-max-listpack-value 64 · 8 |
| list | listpack | quicklist | list-max-listpack-size -2 (8 KB per node) · 4 entries per node |
The checks run on writes. HSET converts before the insert when a new field or value is too long, and after it when the count goes over the limit. ZADD converts before inserting the member that would be one too many (Demo: zset listpack → skiplist). A set holding integers turns into a listpack when a string member arrives, but only if it is still small enough; otherwise it goes straight to a hashtable.
The big encodings: a hashtable is a Redis dict where every field and value is its own SDS in a 24-byte dictEntry. A sorted set's skiplist encoding is a skiplist ordered by score plus a dict from member to score; see Redis sorted sets. The skiplist's header node alone has 32 levels.
Why the conversion is one-way
Delete fields from a hash that became a hashtable and it stays a hashtable, even with one field left (Demo: hash listpack → hashtable, the HDEL step). Redis does not check on deletes:
- Converting back costs O(N) work, done on the main thread inside a command.
- A key whose size moves up and down around the limit would be converted back and forth on every few writes.
- A key that was big once is likely to be big again.
A key gets its compact form back only when it is rebuilt: a restart or DEBUG RELOAD loads every key from the RDB file and picks the smallest encoding that fits (the last step of that demo), and so does a replica's full sync. Lists are the exception: since Redis 7.2 a quicklist that shrinks to well under the limit turns back into a single listpack.
Lists: a quicklist of listpacks
A small list is a single listpack. A bigger one is a quicklist: a doubly linked list whose nodes each hold a listpack (Demo: list grows into quicklist nodes). list-max-listpack-size limits each node: a negative value is a size in bytes (-2 = 8 KB, the default), a positive value a number of entries (the page uses 4). Pushes and pops at either end touch only the end node, and a long list is never one huge block that has to be copied when it grows. list-compress-depth can also compress the nodes in the middle with LZF.
Memory per key, and the small-hashes trick
The ≈ bytes on the canvas are approximate: 64-bit pointers, the main structs, no allocator rounding (jemalloc rounds every allocation up to a size class, so real numbers are higher). Still, the order of magnitude is right:
| Value | Encoding | ≈ bytes (with the key) |
|---|---|---|
counter = 42 | int (shared) | 43 |
counter = 123456 | int | 59 |
counter = "hello" | embstr | 68 |
user:1 with 3 short fields | listpack | 95 |
| the same 3 fields after one long value came and went | hashtable | 262 |
Each key has a fixed overhead (dictEntry, key name, robj). Many tiny keys pay it many times. The Redis memory guide suggests grouping them into small hashes: instead of user:1000 … user:1007, store hash users:0 with fields 1000 … 1003 and users:1 with the rest. Each hash stays a listpack as long as it has no more fields than hash-max-listpack-entries, so pick the bucket size below that limit. In Demo: small hashes vs separate keys 8 values take ≈ 543 bytes as string keys and ≈ 195 bytes as two hashes; one field too many turns a hash into a hashtable and most of the saving is gone. The trade-off: a hash field can't have its own TTL (before Redis 7.4's HEXPIRE), and very large listpacks get slow.
See also how Redis serves many connections with one thread (where these commands run), the Redis dict and incremental rehashing, Redis sorted sets and skiplists, expiry and eviction (the lru field), RDB and AOF persistence (what DEBUG RELOAD saves and loads), replication, streams (also built from listpacks), MULTI/EXEC, Redis Cluster, open hashing and Java's HashMap for chained hash tables, an LRU cache, and HyperLogLog, a Redis string with a sparse and a dense encoding of its own.
What the page leaves out
The real thresholds (128 entries, 64 bytes, 8 KB, 512 integers) and byte limits of a listpack (lpSafeToAdd, 1 GB); jemalloc size classes and the extra room sdsMakeRoomFor gets from them; the bigger SDS headers (sdshdr16, 32, 64); real hash functions (SipHash), incremental rehashing and dict shrinking; Redis 7.2's set dicts without values; skiplist levels drawn as towers and span counts; quicklist compression; the rule that shared integers are not used when maxmemory uses an LRU or LFU policy; the OBJ_ENCODING_STREAM encoding; Redis 8's new hash field expiry encodings (listpackex); and big-key costs like UNLINK freeing memory in a background thread.