Build1 publisher3 min readPublished
One field TTL forces a small Valkey hash into hashtable encoding
Valkey 9.1's HSETEX reads like a one-command replacement for SET with EX, but a dev.to test across 20,000 items measured three times the memory, and the cause is an encoding conversion that fires on the first expiry.
The Engineer · Build desk

What happened
- Valkey 9.0 put TTLs on individual hash fields, and 9.1 added HSETEX so that a field and its expiry are set in a single round trip.
- In the shape the feature was built for, 20,000 fields in one shared hash, each expiry added 23.44 bytes, inside the 16-to-29-byte range maintainers cite in Valkey issue #2618.
- HGET latency against a 5,000-field hash was indistinguishable whether no field carried a TTL or every field did, over three runs each.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Until issue #2618 lands, a schema cannot have both listpack-compact small hashes and per-field expiry, so choosing field TTLs means choosing hashtable encoding for every hash that gets one.
- decision For one expiring value per key, HSETEX saves no round trip over SET with EX, so a migration to it has to be justified by something other than atomicity or memory.
- cost The tax is charged per hash, not per expiry: in a 20,000-key dataset with one TTL each, all 20,000 objects convert, and whoever pays for the cluster's RAM absorbs it.
- capability On hashes already large enough to be hashtable-encoded, per-field expiry is about 23 bytes a field with no read-path penalty that this test could detect, which makes it usable for shared structures.
Valkey issue #2618 proposes storing a field's expiry inside the listpack for small hashes, and as of 9.1.2 it has not landed [13]. Until it does, the expiry has nowhere to live in a listpack, so the object converts. `hash-max-listpack-entries` is the config line people check first. It sat at the default 512 for the whole test and made no difference, because field count was never the trigger [12]. `HSET h3 x 1` reads `listpack` under `OBJECT ENCODING`, and one `HEXPIRE` later the same key reads `hashtable` [11].
Across 20,000 items, the dev.to write-up built three versions of one item with one expiring value: a hash per item with a single TTL field, a hash per item with no TTL, and a plain string with `EXPIRE` [8]. The hash-with-TTL version used three times the RAM of the string, by `INFO memory`, on Valkey 9.1.2 in Docker with jemalloc 5.3.0 [5][6]. Numbers are the fastest of three runs, through redis-py 8.1.0 over a mapped port [7].
The headline ratio and the post's own explanation give two different numbers. The post says a single field TTL costs almost the same extra memory as the encoding switch and roughly triples the hash [9], and also that it "lands you back at the same memory cost as the string-based pattern the feature was meant to improve on, except worse, because now you are also paying the hash's own bookkeeping" [10]. Tripling the hash and landing back at the string's cost are two different measurements [27]. No byte tables appear in the text, so the ratio is the only number reported, and it covers the shape tested: one field per hash, one TTL, version 9.1.2.
The second test is the one the feature was designed for, hashes that already hold several fields where only some carry an expiry [14]. One hash, 20,000 fields, each set through `HSETEX` with its own TTL, against the same hash with no TTLs; both sit in `hashtable` anyway, since 20,000 fields is about 39 times the 512-entry ceiling [15][25]. The measured delta was 23.44 bytes per field [16], inside the 16-to-29 bytes Valkey maintainers quote in the same issue [17]. Multiplied out, tracking all 20,000 expiries costs 468,800 bytes, roughly 458 KiB [24].
Valkey's blog says the tracking structure behind field TTLs does not degrade normal hash operations [18]. `HGET` against a 5,000-field hash with no TTLs, then against an identical hash where every field had one, came out with no measurable difference over three runs each [19]. The write-up flags its own limit: per-op latency was dominated by the network round trip through Docker's port mapping [20]. At that resolution the test would catch a large regression and nothing smaller.
The other part of the pitch is the round trip saved, and the docs invite the comparison. The documented call is `HSETEX user:123 EX 900 FIELDS 1 auth_token`, one round trip for value and expiry [3][2]. `SET user:123:auth_token "..." EX 900` is also one round trip [4]. `HSETEX` saves one call against `HSET` followed by `HEXPIRE`, and none against the string [26].
`HEXPIRE` against a string key returns `WRONGTYPE Operation against a key holding the wrong kind of value` [21]. A negative TTL is refused with `ERR invalid expire time in 'hexpire' command` [22]. `NX` and `XX` in the same call return `ERR NX and XX, GT or LT options at the same time are not compatible` [23].
What to watch
- Whether Valkey issue #2618 lands, since storing the expiry inside the listpack would remove the conversion for small hashes and invalidate the 3x result.
- A re-run that publishes byte totals at several value sizes, which would say whether the ratio holds when the stored value dominates per-object overhead.
- An HGET comparison over a unix socket or loopback rather than Docker's port mapping, to test the no-degradation claim at server-side resolution.