I verified the "up to 20% reduction for strings under 128 bytes" in Amazon ElastiCache for Valkey 9.1
This page has been translated by machine translation. View original
Hello. I'm Takeda from the Service Development Department.
Amazon ElastiCache added support for Valkey 9.1 on June 23, 2026. The announcement states "reduces memory usage of strings smaller than 128 bytes by up to 20%."
I ran 9.0 and 9.1 clusters side by side to verify exactly what "smaller than 128 bytes" refers to. The conclusion: 128 bytes was not referring to the length of the value alone.
Conclusion First
- The embedding threshold is determined not by the value alone, but by the combined total of "robj + key + TTL + value." Longer keys lower the boundary.
- 9.0 uses a total of 64 bytes, 9.1 uses 128 bytes. Additionally, 9.1 reuses the 8 bytes of the
ptrfield, so the maximum allowable value length increases by up to 72 bytes. - Under certain conditions,
MEMORY USAGEper key shows the advertised 20% reduction (80→64 bytes). - However, when measuring
used_memorydelta after inserting 1 million keys, the reduction is 13.9%. This is because hash table and other overhead applies equally to both versions, diluting the reduction rate. - There is a range where 9.1 uses more memory (values of 90–92 bytes with a 16-byte key and no TTL).
Test Environment
I created one replication group each for Valkey 9.0 and 9.1 in the Tokyo region.
| Item | Setting |
|---|---|
| Node type | cache.m7g.large × 1 node |
| Cluster mode | Disabled |
| Replicas | None |
| Parameter group | Custom group shared between 9.0 and 9.1 |
maxmemory-policy |
noeviction |
activedefrag |
no |
Both 9.0 and 9.1 belong to the valkey9 parameter group family, so the same parameter group can be assigned to both. This allowed me to keep all conditions identical except for the engine version.
The allocator was the same for both.
9.0: valkey_version:9.0.0 / mem_allocator:jemalloc-5.3.0
9.1: valkey_version:9.1.0 / mem_allocator:jemalloc-5.3.0
The client is an EC2 instance in the same AZ.
What Changed
Valkey manages each key's value using a struct called robj. For small strings, it embeds the value directly into the same memory block as robj (shown as embstr via OBJECT ENCODING); for larger strings, it allocates separate memory and uses a pointer (raw). Embedding reduces memory allocations to a single call, lowering per-allocation management overhead. The logic for deciding whether to embed changed in 9.1.
Reading the announcement's "smaller than 128 bytes" literally, it appears to be determined solely by value length. However, testing on the 9.1 cluster showed that with a 16-byte key, the maximum value length that remains embstr is 97 bytes — short of 128. So it's not determined by value length alone.
Looking at the source makes the reason clear. Here is src/object.c from 9.0 (comments partially omitted):
robj *createStringObjectWithKeyAndExpire(const char *ptr, size_t len, const sds key, long long expire) {
/* When to embed? Embed when the sum is up to 64 bytes. ... */
size_t size = sizeof(robj);
if (key) {
size_t key_len = sdslen(key);
size += sdsReqSize(key_len, sdsReqType(key_len)) + 1;
}
size += (expire != -1) * sizeof(long long);
size += sdsReqSize(len, SDS_TYPE_8);
if (size <= 64) {
Here is the same location in 9.1. The judgment has been extracted into a function:
static bool shouldEmbedStringObject(size_t val_len, const_sds key, long long expire) {
/* When to embed? Embed when the sum is up to 128 bytes. (2 cache lines on most systems) */
size_t size = sizeof(robj) - sizeof(void *); /* reusing 'ptr' memory when embedding */
...
return size <= 128;
}
There are two changes:
- The threshold doubled from 64 bytes to 128 bytes.
- Since the
ptrfield is not used during embedding, those 8 bytes are reused.
Combined, for the same key length and TTL, 9.1 can embed values up to 72 bytes longer than 9.0. And in both versions, the judgment is based on the combined total of key, TTL, and value — not the value length alone.
Does the Boundary Really Depend on the Total?
If the total determines the boundary as the source suggests, then longer keys should reduce the maximum embeddable value length. I performed a binary search varying the key length to find the maximum value length that keeps OBJECT ENCODING at embstr.
| Key length | 9.0 upper limit | 9.1 upper limit |
|---|---|---|
| 8 | 33 | 105 |
| 16 | 25 | 97 |
| 32 | 7 | 79 |
| 64 | Cannot embed | 47 |
| 100 | Cannot embed | 11 |
Longer keys lowered the boundary, consistent with the source.
For key lengths of 8, 16, and 32, the difference between 9.0 and 9.1 was exactly 72 bytes in each case. This matches the calculated value from the source (64 bytes from the 64→128 threshold change, plus 8 bytes from ptr reuse).
For key lengths of 64 and above, 9.0 cannot embed at all because even a zero-length value pushes the total above 64 bytes. The "Cannot embed" entries do not mean embedding works for empty strings, so the difference in those rows is not an arithmetic increment.
The presence or absence of a TTL did not shift the boundary.
Measuring Memory Per Key
With the key fixed at 16 bytes, I varied the value length and observed OBJECT ENCODING and MEMORY USAGE.
Without TTL:
| Value length | 9.0 | 9.1 | Difference |
|---|---|---|---|
| 16 | embstr / 64 | embstr / 56 | 8 |
| 32 | raw / 88 | embstr / 80 | 8 |
| 40 | raw / 96 | embstr / 80 | 16 |
| 45 | raw / 104 | embstr / 96 | 8 |
| 60 | raw / 112 | embstr / 112 | 0 |
| 85〜89 | raw / 144 | embstr / 128 | 16 |
| 90〜92 | raw / 144 | embstr / 160 | -16 |
| 93〜97 | raw / 160 | embstr / 160 | 0 |
| 100 / 127 / 128 / 129 / 256 | raw | raw | 0 |
With TTL:
| Value length | 9.0 | 9.1 | Difference |
|---|---|---|---|
| 16 | 80 | 64 | 16 |
| 32 | 96 | 80 | 16 |
| 80 | 152 | 128 | 24 |
| 84〜90 | 152 | 160 | -8 |
| 94〜97 | 168 | 160 | 8 |
| 100 / 127 / 128 / 129 / 256 | Same | Same | 0 |
The case of a 16-byte value with TTL went from 80 bytes to 64 bytes — exactly 20%, matching the advertised figure.
There Is a Range Where 9.1 Uses More Memory
Measuring in 1-byte increments without TTL made the step locations clear.
| Value length | 9.0 | 9.1 | Difference |
|---|---|---|---|
| 89 | raw / 144 | embstr / 128 | 16 |
| 90 | raw / 144 | embstr / 160 | -16 |
| 91 | raw / 144 | embstr / 160 | -16 |
| 92 | raw / 144 | embstr / 160 | -16 |
| 93 | raw / 160 | embstr / 160 | 0 |
Between 89 and 90 bytes, 9.1 jumps from 128 to 160. Since 9.0 stays at 144, 9.1 is 16 bytes larger for the range of 90–92 bytes.
The reason lies in how allocations are consolidated. In 9.1, "object + key + value" is a single allocation, so exceeding a rounding boundary causes a full step up. In 9.0, the object and value string are separate allocations, each fitting within smaller rounding units, keeping the total at 144 bytes.
Consolidating into a single allocation does not always result in smaller usage. When a large single allocation crosses an allocator rounding boundary, it can end up larger than the sum of multiple separate allocations.
The step location shifts with different key lengths and TTL settings, so "90 bytes is always the boundary" is not correct. The rounding unit depends on the allocator, but it should remain constant within the same engine version on ElastiCache.
Why TTL Did Not Shift the Boundary
On the matter of TTL presence not affecting the embedding boundary:
SET key value PX ... is processed in this order in setGenericCommand() in t_string.c:
setKey(c, c->db, key, &val, setkey_flags);
if (expire) val = setExpire(c, c->db, key, milliseconds);
Object creation comes first; TTL assignment happens afterward. Since the TTL is not known at creation time, the TTL term in the embedding judgment is evaluated as 0.
However, this alone is not a complete explanation. 9.1 has preemptive TTL field reservation:
/* If the allocation has enough space for an expire field, add it even if we
* don't need it now. Then we don't need to realloc if it's needed later. */
if (!o->hasexpire && bufsize >= min_size + sizeof(long long)) {
o->hasexpire = 1;
min_size += sizeof(long long);
}
If the memory actually obtained from the allocator has 8 or more bytes of slack, TTL space is reserved even without a TTL. If already reserved, adding a TTL later does not trigger a reallocation. If not reserved, the judgment is re-evaluated including the TTL, which may cause a transition to raw.
I tested this using PEXPIRE:
| Value length | SET only |
PEXPIRE after SET |
SET ... PX |
|---|---|---|---|
| 90 / 94 / 96 / 97 (embstr) | 160 | 160 | 160 |
| 98 / 100 (raw) | 160 | 168 | 168 |
For embstr cases, adding a TTL does not increase memory usage because TTL space was preemptively reserved. For raw cases, there is no reservation, so memory increases by 8 bytes.
It is neither accurate to say "adding a TTL always adds 8 bytes" nor "TTL never shifts the boundary." Within my testing, no boundary shift due to TTL was observed, but since the internal hasexpire flag and actual allocated size are not externally observable, I cannot confirm that preemptive reservation occurred in every case. I also did not test paths where the TTL is known at creation time, such as RESTORE.
Measuring with 1 Million Keys
Having confirmed that differences appear at the per-key level, I looked at larger volumes.
With the key fixed at 16 bytes, I inserted 1 million keys and divided the used_memory delta by the number of keys. Values were non-numeric ASCII to avoid integer encoding. For each measurement, I flushed with FLUSHALL SYNC, took the used_memory baseline from 9 readings and used the median, then took 9 readings after insertion and used the median.
Confirmed Numbers
| Value length | TTL | 9.0 | 9.1 | Reduction |
|---|---|---|---|---|
| 16 | Yes | 114.9 | 98.9 | 13.9% reduction |
| 90 | No | 161.4 | 177.5 | ~10% increase |
Both are medians, n=3. I verified that DBSIZE was 1,000,000 and evicted_keys was 0 for all measurements.
The trends observed at the per-key level appeared directly in the results. At a value length of 90 bytes, 9.1 uses about 10% more memory.
For reference, I also measured other value lengths, each once only. The first measurement for 90-byte values (161.5) was within 0.1 of the ABBA repeated measurements (161.4–161.5), so these can be treated as warm-state measurements.
| Value length | TTL | 9.0 | 9.1 | Reduction |
|---|---|---|---|---|
| 40 | No | 113.5 | 97.5 | 14.1% |
| 64 | No | 145.5 | 129.5 | 11.0% |
| 256 | No | 385.5 | 385.5 | 0.0% |
Where Does the Gap Between the Advertised 20% and Measured 13.9% Come From?
For the 16-byte value with TTL case, here are the two numbers side by side:
| 9.0 | 9.1 | Difference | |
|---|---|---|---|
MEMORY USAGE (1 key) |
80 | 64 | 16 |
used_memory delta (bytes/key) |
114.9 | 98.9 | 16.0 |
| Remainder | 34.9 | 34.9 | 0 |
The difference in used_memory delta is 16.0 bytes, which matches the MEMORY USAGE difference of 16 bytes within the reported precision. No additional difference beyond the object representation was observed.
The remainder of 34.9 bytes is memory not reflected in MEMORY USAGE. Since bytes/key is derived from the pre/post used_memory delta, it includes not only the object itself but also memory that grows per key due to structures like the DB hash table. This remainder was 34.9 bytes in both versions.
In summary:
- Per key as seen via
MEMORY USAGE: 80→64, exactly 20% reduction. Matches the advertised figure. used_memorydelta after 1 million insertions: 114.9→98.9, a 13.9% reduction. The 34.9 bytes from hash tables and similar structures applies equally to both versions, diluting the reduction rate.
For capacity estimation, the latter figure is relevant. However, 13.9% applies specifically to this test's data shape: 16-byte keys, 16-byte values, with TTL, 1 million keys. In a real environment, measure using your own key lengths, value lengths, and TTL ratios.
How to Think About This in Production
9.1 reduces memory in the following cases:
- Small values. The combined total of key and value fits within 128 bytes.
- The 9.0 threshold of 64 bytes was already exceeded (i.e., the combined total is in the 65–128 byte range).
- Short keys. Longer keys reduce the maximum embeddable value length.
Conversely, memory does not decrease — or may even increase — in these cases:
- Large values. The 100, 127, 128, 129, and 256-byte values tested all became
rawin both versions, eliminating any difference. - A narrow range around allocator rounding boundaries. In this test, 9.1 was 16 bytes larger for value lengths of 90–92 bytes.
If you want to evaluate your own data, the fastest approach is to check OBJECT ENCODING and MEMORY USAGE for your actual key-value combinations. Measuring without accounting for key length is meaningless.
Summary
I traced the meaning behind the statement "reduces memory usage of strings smaller than 128 bytes by up to 20%." The threshold is based on the combined total including key and TTL, the reduction rate differs between individual object measurements and cluster-wide measurements, and for certain value lengths there are ranges where memory actually increases.
That said, these are fairly edge cases, so in general it's reasonable to understand this as an efficiency improvement.


