In short. In Redis, choosing a data structure means choosing three things at once: how much memory you pay, which operation costs a single command, and which one forces you to pull the data into the client. The measurements are from 8 November 2026, on Redis 8.0.5 and 7.4.7. A four-field hash takes 136 bytes as long as it stays small and 336 if you force it into the large representation. Counting 100,000 unique visitors with a HyperLogLog costs 14,392 bytes against 4,772,976 for a Set, and it is off by 0.425%. A leaderboard of 100,000 players in a Sorted Set weighs 9.58 MB, but MEMORY USAGE on its own tells you anything between 9.0 and 11.0 depending on the instance: on a nested structure it samples five elements and multiplies, and the exact count only comes with SAMPLES 0. And the real case in my backend, one hash per article with a 1,536-byte vector, is never small: it exceeds the 64-byte limit and lives in the wide representation.
Method. Every code block below was actually run, with the version written next to it: Redis 8.0.5 native on throwaway instances (port 6391) and Redis 7.4.7 in a container. The sampling section also uses the official images 7.4.9, 7.4.12 and 8.10.3, named where they matter. The only reads on the site's development instance are marked as such. I don't know the production configuration and I don't claim anything about it.
Revision of 13 November 2026. In this revision I did not start any server: the outputs of the blocks are those of 8 November and I did not reproduce them. I rechecked what can be verified without a server. The pages of the official documentation still say the same things (the current version of the project is 8.10.3, according to the list of releases). Two calculations of mine check out: the HyperLogLog registers are 16,384 of 6 bits, that is 12,288 bytes, with standard error 1.04/√16,384 = 0.81%; the Rome-Milan distance with the haversine formula gives 477.02 km, against Redis's 477.0191, which rounds the coordinates in the geohash.
The problem: a single key is not enough to understand what you are paying for
When Redis arrives in a backend, the first question is almost always "which key do I use?". The useful question comes one step later: "which structure, and what does it cost me?". A user object can live in a JSON string, in a hash or in three separate keys; a leaderboard can be a list you sort in the client or a set that Redis already keeps sorted. You don't see the difference in development with ten keys: you see it when the keys are a hundred thousand, or when memory runs out (the topic of a later article in the series).
Redis also chooses on its own, behind the scenes, how to represent each structure. It does so based on size, and the command that shows it to you is called OBJECT ENCODING. Understanding when that choice kicks in is the part this article hands you.
Hash: one object, many fields, one round trip
What it is and what it does in a single command
A hash is a map of field-value pairs under a single key. You read or write a field with HGET and HSET, and the documentation gives their cost as O(1); HGETALL, on the other hand, is O(N) in the number of fields, so on a large hash it is the command to watch. The advantage over a JSON string is that you update one field without reading, deserializing and rewriting everything, and that HINCRBY is atomic.
$ redis-cli -p 6391 hset utente:1001 nome "Mario Rossi" email mario@example.com eta 35 citta Milano
4
$ redis-cli -p 6391 object encoding utente:1001
listpack
$ redis-cli -p 6391 memory usage utente:1001
136
The result (Redis 8.0.5) is a four-field hash in 136 bytes, represented as a listpack: a compact sequence of bytes, without the pointers of a hash table. This is the small representation.
How much the large representation weighs
To see what happens when the hash leaves the small form, I set the field-count threshold to zero (only on my throwaway instance) and rewrite the same hash:
$ redis-cli -p 6391 config set hash-max-listpack-entries 0
OK
$ redis-cli -p 6391 hset utente:ht nome "Mario Rossi" email mario@example.com eta 35 citta Milano
4
$ redis-cli -p 6391 object encoding utente:ht
hashtable
$ redis-cli -p 6391 memory usage utente:ht
336
$ redis-cli -p 6391 config set hash-max-listpack-entries 512
OK
Same data, 336 bytes instead of 136: 2.5 times. On Redis 7.4.7 the wide form gives 328 bytes and the compact one 136. The memory optimization page says small structures can use "up to 10 times less memory", 5 on average, and that it is a trade-off between CPU and memory. My 2.5 is on a tiny hash: the factor depends on how small it is, and has to be re-measured on your data.
The two limits that trigger the change
The switch from listpack to hashtable happens when the hash exceeds one of two thresholds. The starting values are, according to the documentation (and to CONFIG GET on my Redis 8.0.5), 512 fields and 64 bytes per value:
hash-max-listpack-entries: maximum number of fields (default 512).hash-max-listpack-value: maximum length in bytes of a single field or value (default 64).
For the hash-max-listpack-value property, here are the answers to the questions you will ask: what it is (a per-element size limit), what it accepts (an integer number of bytes), what it is worth if you don't write it (64, confirmed with CONFIG GET on my server), why 64 here (it is the documentation's default and I left it; the same page says the compact form is a trade-off between CPU and memory, so raising it shifts the bill onto the CPU) and what goes wrong (a single longer value is enough to move the whole hash into the wide form, for good in that key). The proof is a single one-hundred-character field:
$ redis-cli -p 6391 hset lungo f "$(python3 -I -c "print('x'*100)")"
1
$ redis-cli -p 6391 object encoding lungo
hashtable
The JSON string is no worse, at equal size
A check that removes a prejudice. The same object serialized as JSON and stored in a string:
$ redis-cli -p 6391 set utente:json '{"nome":"Mario Rossi","email":"mario@example.com","eta":35,"citta":"Milano"}'
OK
$ redis-cli -p 6391 memory usage utente:json
136
The same 136 bytes as the compact hash. You don't choose the hash "to save memory" compared with a small string: you choose it because you touch one field without rewriting the others and because counters become atomic. If you always read the whole object, the string is simpler.
The real case: one hash per article, with a vector inside
In this site's ml-service, every indexed article is a hash article:<id>:<language> with six fields: article_id, title, slug, lang, excerpt and embedding (index_article in ml-service/app/storage/redis_vectors.py, which calls hset). The embedding is a vector of 384 float32 numbers, that is 384 × 4 = 1,536 bytes. I read it, read-only, from the development instance (Redis 8.0.5):
$ redis-cli -p 6379 object encoding article:1416:en
hashtable
$ redis-cli -p 6379 hstrlen article:1416:en embedding
1536
$ redis-cli -p 6379 memory usage article:1416:en
2880
$ redis-cli -p 6379 memory usage article:1416:en samples 0
2472
$ redis-cli -p 6379 --scan --pattern 'article:*' --count 1000 | wc -l
2426
The hash is hashtable, as you would expect: a 1,536-byte field is far above the 64-byte threshold. It weighs 2,472 bytes, of which 1,536 are vector. The 2,880 on the line before is the estimate that MEMORY USAGE returns without arguments, and why the two figures differ is explained in the section "MEMORY USAGE is an estimate". On the first fifty keys the exact average is 2,500 bytes, against 2,916 for the estimate:
$ redis-cli -p 6379 --scan --pattern 'article:*' --count 1000 | head -50 \
| while read k; do redis-cli -p 6379 memory usage "$k" samples 0; done \
| awk '{s+=$1} END {printf "media %.2f byte su %d chiavi\n", s/NR, NR}'
media 2500.48 byte su 50 chiavi
For 2,426 articles that is about 6.1 MB: not much, but the proportion matters if the documents become hundreds of thousands. The vector weighs more than half of the key: the memory arithmetic of a vector index starts there. How these hashes become a search index is told in the article on the RAG setup with RediSearch.
Sorted Set: the leaderboard Redis already keeps sorted
A leaderboard without sorting in the client
A Sorted Set is a set of unique members, each with a score, always sorted. The documentation explains that the implementation combines a skip list and a hash table, and that for this reason every insertion is O(log N) and reading in order costs nothing: the order already exists. A leaderboard of four players:
$ redis-cli -p 6391 zadd classifica 1500 anna 2300 bruno 1800 carla 900 dino
4
$ redis-cli -p 6391 zrange classifica 0 2 rev withscores
bruno
2300
carla
1800
anna
1500
$ redis-cli -p 6391 zrevrank classifica carla
1
ZRANGE ... REV returns the top three from the highest and ZREVRANK says that Carla is second (the position starts from 0). The ZREVRANGE of the old tutorials does the same, but its page declares it deprecated since 6.2.0 and points to ZRANGE with REV.
Changing a score and querying by range
$ redis-cli -p 6391 zincrby classifica 600 carla
2400
$ redis-cli -p 6391 zrevrank classifica carla
0
$ redis-cli -p 6391 zrangebyscore classifica 1800 +inf withscores
bruno
2300
carla
2400
$ redis-cli -p 6391 object encoding classifica
listpack
Carla gains 600 points and moves to first place without you reordering anything: one command, atomic. Note the last line: even the small Sorted Set is a listpack, with its own thresholds (zset-max-listpack-entries 128 and zset-max-listpack-value 64, according to the documentation). Beyond 128 elements it becomes a skiplist, and that is where the cost grows.
HyperLogLog: counting the unique ones without remembering them
The declared trade-off
A HyperLogLog estimates how many distinct elements a set contains, trading precision for space. The documentation says that Redis's implementation uses "up to 12 KB" with a standard error of 0.81%. You add with PFADD, read with PFCOUNT, merge with PFMERGE. It doesn't keep the elements: you can't ask "is user X already there?".
The measurement: a hundred thousand visitors, three structures
I load the same 100,000 identifiers into three different structures, with a shell loop and redis-cli --pipe (Redis 8.0.5):
$ for i in $(seq 1 100000); do echo "PFADD visitatori u$i"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 100000
$ for i in $(seq 1 100000); do echo "SADD visitatori:set u$i"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 100000
$ for i in $(seq 1 100000); do echo "ZADD punti $i u$i"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 100000
$ redis-cli -p 6391 pfcount visitatori
100425
$ redis-cli -p 6391 memory usage visitatori
14392
$ redis-cli -p 6391 scard visitatori:set
100000
$ redis-cli -p 6391 memory usage visitatori:set
4772976
$ redis-cli -p 6391 object encoding punti
skiplist
$ redis-cli -p 6391 memory usage punti
9689368
The HyperLogLog's estimate is 100,425 instead of 100,000: an error of 0.425%, within the declared 0.81%. The cost is 14,392 bytes against the Set's 4,772,976, that is a factor of 330 between two structures that saw the same 100,000 strings. On Redis 7.4.7 the HyperLogLog and the Set give the same bytes. The Sorted Set's 9,689,368, instead, is the number of this instance and would not be the one of another: it is the subject of the section "MEMORY USAGE is an estimate", and it is the most useful thing this article has to tell you.
Why 14,392 bytes if the documentation says 12 KB
The two numbers don't contradict each other: they don't measure the same thing. A HyperLogLog in Redis is a string, and STRLEN lets you see it:
$ redis-cli -p 6391 type visitatori
string
$ redis-cli -p 6391 strlen visitatori
12304
These are the 12,288 bytes that make 12 KB, plus 16 of header. The other 2,088 that bring it to 14,392 are what the MEMORY USAGE page calls the key and value administrative overhead, allocator rounding included: the second figure includes the key, the first doesn't.
When it is not the right choice
- You need to know who visited, not how many: the HyperLogLog doesn't keep the elements.
- An error of 0.81% is not acceptable (billing, quotas): use a Set or a database table.
- You have a few thousand elements: a Set costs a few tens of KB (about 48 bytes per element in my measurement) and gives you exact numbers.
The three items answer the same question: what did the HyperLogLog give you in exchange? Fixed memory and a number, at the price of the identity of the elements and of an error you cannot zero out. If you need the "who", or if the 0.81% has an economic consequence, you chose the wrong tool. The third item is the easiest to overlook: with a few thousand elements the absolute saving is small and a Set gives you exactness as a gift.
MEMORY USAGE is an estimate, not a count
Five samples out of a hundred thousand elements
All the memory numbers in this article come out of MEMORY USAGE, and on a nested structure that command doesn't count: it samples. Its page presents it as a command that estimates, gives its complexity as O(N) in the number of samples and explains the option: SAMPLES sets how many elements to look at, the samples "are averaged to estimate the total size", the default is 5 and with SAMPLES 0 it looks at all of them. Five elements out of a hundred thousand: if they don't represent the average, the error is multiplied by a hundred thousand.
$ redis-cli -p 6391 memory usage punti samples 5
9049368
$ redis-cli -p 6391 memory usage punti samples 50
9561368
$ redis-cli -p 6391 memory usage punti samples 100
9593368
$ redis-cli -p 6391 memory usage punti samples 1000
9593368
$ redis-cli -p 6391 memory usage punti samples 0
9580496
Half a megabyte between the default and the exact count, on the same key at the same instant. And at a hundred samples the value has already arrived: on a hundred thousand elements, a hundred reads cost nothing.
The estimate changes at every restart, and repeating it gives no warning
What deceives you is that the estimate is stable within an instance: you ask for it ten times and get the same figure ten times, and that is exactly what makes you believe you have a count. It changes between instances. prova-stima.sh rebuilds the same leaderboard five times, each time on a new process:
#!/bin/bash
for giro in 1 2 3 4 5; do
redis-server --port 6391 --save '' --appendonly no --dir /tmp --daemonize yes 2>/dev/null
sleep 1
for i in $(seq 1 100000); do echo "ZADD punti $i u$i"; done | redis-cli -p 6391 --pipe >/dev/null
echo "giro $giro stima $(redis-cli -p 6391 memory usage punti) esatto $(redis-cli -p 6391 memory usage punti samples 0)"
redis-cli -p 6391 shutdown nosave 2>/dev/null
sleep 1
done
$ bash prova-stima.sh | grep ^giro
giro 1 stima 9689368 esatto 9582336
giro 2 stima 10009368 esatto 9586160
giro 3 stima 9049368 esatto 9589936
giro 4 stima 9689368 esatto 9584960
giro 5 stima 9369368 esatto 9581584
Look at round 2: 10,009,368, exactly the number that the first version of this article published as "the weight of the key". It is one of the possible values, not the weight. The exact count, in the same round, stays within a 0.09% window; the estimate, on 8.0.5, jumps by 320,000 bytes at a time, which is what a sample that weighs 16 bytes more moves, multiplied by a hundred thousand elements and divided by five samples (on the 7.4 line the step I measured is half). Over twenty-two new instances the estimate went from 9,049,368 to 10,969,368 - a width of 21%, and only six distinct values, all separated by a multiple of 320,000 - while the exact figure never left 9,579,072-9,589,936.
It is not the length of the data, it is the structure
The cause can be isolated. If the error came from members of different lengths (u1 is two characters, u100000 seven), with names all of the same length it should disappear. prova-fissi.sh is the same script with fixed-width names, from u000001 to u100000, and one more SADD loop that builds a Set with the same elements:
$ bash prova-fissi.sh | grep ^giro
giro 1 zset stima 9849368 esatto 10385448 | set stima 5572968 esatto 5572968
giro 2 zset stima 9849368 esatto 10388376 | set stima 5572968 esatto 5572968
giro 3 zset stima 9849368 esatto 10390136 | set stima 5572968 esatto 5572968
On the Set, estimate and count coincide, three times out of three: the elements all have the same shape, and five are enough. On the Sorted Set they don't: three times the same 9,849,368 for a key that weighs 10.39 MB, that is 5% too low, and in other rounds the same measurement gave me up to 12,089,368. Three identical answers, all wrong, are the best illustration of the earlier point: repeating does not validate. The difference between the two structures is that a Sorted Set is a skip list, and in a skip list the height of each element is drawn at random: five elements do not represent the average height. The error doesn't come from your data, it comes from the structure.
The comparison between versions that the estimate doesn't allow
The first version of this article put 10,009,368 bytes on 8.0.5 next to 8,249,368 on 7.4.7, and concluded that 7.4.7 takes less. Those two numbers don't allow it: the same 7.4 also gave me 8,569,368, 9,049,368, 9,209,368 and 9,689,368, and 9,689,368 is among the values that 8.0.5 also gave me. With the default estimate the two versions can't be told apart, and I removed that comparison.
With the exact count they can be told apart, and the figure is a different one: an average of 9,583,695 bytes over twenty-two instances of 8.0.5, 8,833,562 over ten measurements of the 7.4 line (7.4.7 in a container, plus the 7.4.9 and 7.4.12 images), intervals that don't overlap by a single byte. That is 7.8% less on 7.4. Why, I can't say: system, architecture and allocator are the same (jemalloc-5.3.0, from INFO memory), so it lies inside Redis, but I haven't read the code.
- For a number that ends up in a document,
SAMPLES 0. - Repeating the measurement doesn't validate it: it changes between processes, not between calls. Restart the instance.
- A byte count holds for that version: on Redis 8.10.3 the same key measures 7,755,056 bytes, and there
SAMPLESchanges nothing - 1, 3, 5, 100 and 0 give the same figure.
The three items say the same thing from different angles: MEMORY USAGE returns a figure that is precise in appearance and approximate in substance. The first item explains how to turn it into a count, the second why repeating it isn't enough to trust it, the third why you can't carry it from one version to another. When a number ends up in a document, write next to it the command, the option and the Redis version: without them, whoever rereads it can't redo it.
Geospatial: a Sorted Set with a different name
Distance and radius
The geospatial commands store longitude and latitude and let you ask for distances and neighbors. I add three Italian cities and measure:
$ redis-cli -p 6391 geoadd sedi 12.4964 41.9028 roma 9.1900 45.4642 milano 18.1696 40.3515 lecce
3
$ redis-cli -p 6391 type sedi
zset
$ redis-cli -p 6391 geodist sedi roma milano km
477.0191
$ redis-cli -p 6391 geosearch sedi fromlonlat 12.5 42.0 byradius 700 km asc withdist
roma
10.8153
milano
468.1267
lecce
508.7101
The TYPE is zset. The GEOADD page explains that the score is a 52-bit integer obtained by interleaving the bits of latitude and longitude (geohash), and that for this reason there is no GEODEL: you remove a place with ZREM. The GEOSEARCH result is sorted by distance from the point.
GEORADIUS is deprecated
Many tutorials still use GEORADIUS. The command's page flags it as deprecated since 6.2.0 and points to GEOSEARCH with BYRADIUS as the replacement. I tried it on both versions and it still works, but in new code it has no reason to be there.
$ redis-cli -p 6391 georadius sedi 12.5 42.0 700 km asc withdist | head -3
roma
10.8153
milano
What to look at on a key you don't know
TYPE: the structure (hash, zset, string).OBJECT ENCODING: the representation Redis chose (listpack,hashtable,skiplist,raw).MEMORY USAGE: the bytes of key and value, overhead included - but on hashes, Sets, Sorted Sets and lists it is an estimate on five elements, andSAMPLES 0turns it into a count.HLEN,ZCARD,SCARD,HSTRLEN: how many elements and how long the most suspect one is.
The four checks are read in order because each one narrows the next: TYPE tells you which commands make sense, OBJECT ENCODING whether you are below or above the compaction threshold, MEMORY USAGE how much it costs, and only then do the length commands show which element explains the cost. They are all read-only: you can run them on an instance that isn't yours without changing anything, and that is why they are the first things I do.
How to choose: the table I would use
Three questions in this order: do you need the single element or only a number? Do you need an order? Is the object modified in pieces?
- An object with fields that change independently: hash. Read cost O(1) per field.
- An object always read whole: string (JSON or binary). 136 bytes, like the compact hash.
- A leaderboard or a time window: Sorted Set, O(log N) per write.
- "How many?" and not "who?": HyperLogLog, 12 KB of registers (14,392 bytes of key, measured) and about 0.8% error.
- "Who is within X km?": geospatial, that is a specialized Sorted Set.
The table doesn't compare technologies, it matches the shape of your access to the structure that makes it cheap. Hash and string, for example, cost the same in my measurement (136 bytes): what separates them is the way you read, by field or whole. If two rows both seem true, choose the one that makes the command you will run most often simple, and let memory be the tie-breaker, not the first criterion.
The trade-off, and when not to use them
For the article vectors the hash is not a memory choice: with small data a string weighs the same. The vectors documentation says that vectors and metadata live in hashes or JSON objects, and the hash is the simplest: a binary field next to the text ones, with nothing to serialize. Whether JSON was considered at the time I don't know, and I don't write it as fact. Where the list of visitors is needed I pay for the Set. On a Sorted Set of a hundred thousand members I accept its 9.58 MB, because the already sorted leaderboard is what I am buying: if you don't need the order, you pay for something you don't use.
An honest note: the site uses no leaderboards, no HyperLogLog and no geospatial commands. I show them because the question "which structure?" is the same one you will have to ask for the uses the site really has. The work Redis does inside it, from embeddings to the lock, is in the rest of the series.
What you can do now
You can look at a key with TYPE, OBJECT ENCODING and MEMORY USAGE and say how much it weighs and why. You know that a single value above 64 bytes moves the whole hash into a representation that, for the same data, in my experiments costs 2.5 times as much. You know that a HyperLogLog counts a hundred thousand elements in 14 KB with less than 1% error, and that GEORADIUS is deprecated. And you know the thing that cost me a number to correct: that on a nested structure MEMORY USAGE samples five elements, that you need SAMPLES 0 when the number matters, and that two versions of Redis cannot be compared with an estimate. The next step is knowing what happens to those keys when Redis restarts, the topic of the article on RDB, AOF and restart, while what happens when memory runs out is in maxmemory, eviction and OOM. For messaging between services there is Pub/Sub or Streams, for atomic operations Lua with EVAL, and for measuring memory and latency of an instance you don't know latency, SLOWLOG and memory per key.
Do you have a data structure you don't know how to model? Book a call →
Frequently asked questions
How much memory does a small Redis hash take?
On Redis 8.0.5 a four-field hash takes 136 bytes in the listpack representation and 336 bytes if it is forced into a hashtable. On 7.4.7 it is 136 and 328 bytes. The switch happens beyond 512 fields or with a value longer than 64 bytes.
Is HyperLogLog accurate?
It is not exact: the Redis documentation declares a standard error of 0.81% with at most 12 KB. In a measurement with 100,000 unique elements it estimated 100,425, an error of 0.425%, using 14,392 bytes against the 4,772,976 bytes of a Set.
Can I still use GEORADIUS?
It still works on Redis 8.0.5 and 7.4.7, but it has been deprecated since 6.2.0. The documentation points to GEOSEARCH with BYRADIUS as the replacement to use in new code.
Sources
- Redis, HyperLogLog - up to 12 KB and a standard error of 0.81%, consulted on 2026-11-13.
- Redis, Sorted sets - skip list plus hash table and O(log N) insertion, consulted on 2026-11-08.
- Redis, Memory optimization - the
hash-max-listpack-*thresholds and the "up to 10 times" saving factor, consulted on 2026-11-08. - Redis, GEOADD - 52-bit score (geohash) and removal with
ZREM, consulted on 2026-11-08. - Redis, ZREVRANGE - deprecated since 6.2.0, consulted on 2026-11-13.
- Redis, Vectors - vectors and metadata in hashes or JSON, consulted on 2026-11-08.
- Redis, GEORADIUS - deprecated since 6.2.0, replaced by GEOSEARCH, consulted on 2026-11-13.
- Redis, MEMORY USAGE - the command "estimates" memory, complexity O(N) in the number of samples,
SAMPLESdefaulting to 5 andSAMPLES 0to look at them all, consulted on 2026-11-13. - Redis, OBJECT ENCODING - the representations of hashes and Sorted Sets, consulted on 2026-11-08.
- Redis, releases on GitHub - the current version (8.10.3, published on 2026-10-26), consulted on 2026-11-13 with the GitHub programming interface.
- Measurements: the commands in the blocks above, run on 2026-11-08 on Redis 8.0.5 (native) and 7.4.7 (
redis/redis-stack-server:latest), and the reading ofml-service/app/storage/redis_vectors.py, functionindex_article.
The sources in the list are pages of the official documentation, and for this reason they carry the consultation date: if you can't find a sentence, it is the page that has moved and not the citation. The last item is of a different nature, because they are commands and lines of code you can rerun yourself. Where the two families should contradict each other, the command run on your version of Redis prevails.

Commenti
Caricamento commenti...
Sign in to comment