Valkey 9.0 lets a single hash field carry its own TTL, so a session hash can drop its one-time code after five minutes while the rest of the session lives for thirty. The commands are short (HSETEX, HGETEX, HEXPIRE, and friends), but the behaviour behind them surprises teams in production. An expired field disappears from reads yet still counts in HLEN and memory until a background job reaps it. A plain HSET silently makes a volatile field permanent. Small hashes leave the compact listpack encoding the moment one field gets a TTL. And the 9.0 and 9.1 patch trains kept fixing field-expiration bugs until October 2026. This guide covers the design choice, three patterns, the memory bill, replication, and the patch you need first.
- Hash field expiration arrived in Valkey 9.0.0.
HGETDELand the key-levelNX/XXoptions ofHSETEXarrived in 9.1.0. - Run 9.0.7 or 9.1.3 or later before you write field TTLs. Earlier patches can let replicas and AOF reloads diverge from the primary, and before 9.0.6 and 9.1.2 a read-only ACL user could run
HGETEX. - Expired fields are hidden on read but only deleted by active expiry.
HLENandMEMORY USAGEcan be wrong until then, so do not build counts on them. HSET,HMSET, andHSETEXwithoutKEEPTTLremove a field's TTL.HINCRBYkeeps it.- The first field TTL you write is your rollback point: Valkey 7.2 and 8.x cannot load or full sync a hash with field TTLs.
Scope and review date: November 2026. This post covers self-managed Valkey 9.0 (latest patch 9.0.7) and 9.1 (latest patch 9.1.3), with notes on the 9.2 release candidates. Commands, defaults, and version numbers were checked against the Valkey documentation, release notes, and the 9.1.3 source on 10 November 2026. They were not run in a lab, so rehearse them on staging.
Which hash field expiration commands does Valkey 9 have?
Twelve commands touch field TTLs. The since values below come from the command definitions in the Valkey 9.1.3 source and match the HSETEX, HGETEX, and HEXPIRE pages on valkey.io. The feature landed through PR #2089 in 9.0.0-rc1, and 9.0.0 went GA on 21 October 2025.
| Command | Since | What it does |
|---|---|---|
HSETEX | 9.0.0 | Sets fields and their TTL in one call. FNX/FXX test the fields, KEEPTTL keeps existing TTLs. Key-level NX/XX added in 9.1.0 (#2668). |
HGETEX | 9.0.0 | Reads fields and changes their TTL (EX, PX, EXAT, PXAT) or removes it (PERSIST). A write command. |
HEXPIRE, HPEXPIRE, HEXPIREAT, HPEXPIREAT | 9.0.0 | Set a relative or absolute TTL on existing fields, with NX, XX, GT, or LT conditions. |
HTTL, HPTTL, HEXPIRETIME, HPEXPIRETIME | 9.0.0 | Read the remaining TTL or the absolute expiry time per field. Read-only. |
HPERSIST | 9.0.0 | Removes the TTL from fields. |
HGETDEL | 9.1.0 | Reads fields and deletes them atomically. |
The syntax follows the Redis 8.0 API for HSETEX and HGETEX and the Redis 7.4 API for HEXPIRE, which is why Redis clients can call them. The key-level NX/XX on HSETEX is Valkey-only. For the wider product comparison, see Redis vs Valkey.
valkey-cli HSETEX session:u42 EX 1800 FIELDS 2 cart c-981 csrf 7f3a
valkey-cli HTTL session:u42 FIELDS 3 cart csrf user_id
valkey-cli HEXPIRE session:u42 600 GT FIELDS 1 cartInterpretation: HSETEX returns 1 when every field was set and 0 when an FNX or FXX condition blocked all of them. HTTL returns one integer per field: seconds remaining, -1 for a field with no TTL, or -2 for a field that does not exist. HEXPIRE returns 1 when applied, 0 when the condition failed (here, 600 is not greater than the current TTL), 2 when the time was 0 or in the past and the field was deleted, and -2 for a missing field.
A session hash with one short-lived field
Illustrative output, shaped like valkey-cli on 9.1: the otp field is checked after its 500 ms TTL
Hidden, not gone: HTTL already treats otp as missing, but HLEN counts it until active expiry reaps it. On an idle server that can happen within a second, so you may see 2.
Should you use a whole-key TTL or field TTLs?
Before 9.0, different lifetimes meant one key per attribute. Field TTLs remove that, but change how eviction, encoding, and counting behave. Pick per data set.
Whole-key TTL and field TTLs side by side
Switch designs to see what eviction, memory, and counting see on Valkey 9.0 and 9.1
Whole-key TTL
| Concern | Whole-key TTL |
|---|---|
| What expires | The whole hash, every field at once |
| Different lifetimes per attribute | Needs one key per lifetime |
| Small-hash encoding | listpack up to 512 fields of 64 bytes (defaults) |
Seen by volatile-* eviction | Yes, the key has an expire set |
| Expired item on read | Key deleted on access or by active expiry |
HLEN and MEMORY USAGE | Accurate while the key exists |
| Rollback to 8.x | No format change |
Field TTLs
| Concern | Field TTLs |
|---|---|
| What expires | Single fields; the key goes when its last field is reaped |
| Different lifetimes per attribute | One hash, one TTL per field |
| Small-hash encoding | Converts to hashtable when the first field gets a TTL (listpack only in the 9.2 release candidates) |
Seen by volatile-* eviction | No, unless the key also has its own TTL |
| Expired item on read | Field hidden, deleted only by active expiry |
HLEN and MEMORY USAGE | HLEN counts unreaped fields; MEMORY USAGE under-reports until 9.2 |
| Rollback to 8.x | Blocked once any field TTL exists |
Eviction trap: a hash with only field TTLs has no key expire, so volatile-lru, volatile-lfu, volatile-random, and volatile-ttl never pick it. Under those policies it behaves like a persistent key.
The eviction row comes from evict.c in 9.1.3: volatile-* policies sample only the per-database expires table, which field TTLs never enter. Move sessions from key TTLs to field TTLs under volatile-lru, and they stop being eviction candidates.
valkey-cli CONFIG GET maxmemory-policy
valkey-cli CONFIG GET hash-max-listpack-*Interpretation: if the policy starts with volatile-, either give the hash a key-level TTL as well (for example, the longest field lifetime) or accept that it is never evicted. The listpack limits tell you which hashes currently stay compact and would convert.
How do you model sessions, feature flags, and rate counters with field TTLs?
All three patterns depend on one rule from the hashes documentation: commands that overwrite a field, such as HSET and HMSET, remove its TTL. HSETEX without an expiry option behaves like HSET and also clears it. Only KEEPTTL preserves it.
Sessions with per-attribute expiry
valkey-cli HSETEX session:u42 EX 1800 FIELDS 2 user_id 42 cart c-981
valkey-cli HSETEX session:u42 PX 300000 FIELDS 1 otp 913204
valkey-cli HGETEX session:u42 EX 1800 FIELDS 2 user_id cart
valkey-cli HSETEX session:u42 FXX KEEPTTL FIELDS 1 cart c-982Interpretation: the first two lines give the session a 30-minute life and the one-time code 5 minutes. The HGETEX line reads and slides the 30-minute window on each request. The last line updates the cart only if the field still exists (FXX) and keeps its remaining TTL. Written as plain HSET, that update would make the cart permanent.
Feature flags with expiring experiments
valkey-cli HSET flags:cust:1001 dark_mode on billing_v3 on
valkey-cli HSETEX flags:cust:1001 EX 86400 FIELDS 1 exp_checkout_v2 on
valkey-cli HGETALL flags:cust:1001Interpretation: long-lived rollouts are plain fields; the experiment switches itself off after 24 hours, and HGETALL stops returning it as soon as the TTL passes. Check your flag service's write path: if it re-saves every flag with HSET, it removes the experiment's TTL on the first edit.
Per-user rate counters
valkey-cli <<'EOF'
MULTI
HINCRBY rl:u42 api:202611100915 1
HEXPIRE rl:u42 120 NX FIELDS 1 api:202611100915
EXEC
EOFInterpretation: one field per user per minute window, so a user's counters share one key. HINCRBY keeps an existing field TTL (it passes the old expiry through in t_hash.c), and HEXPIRE ... NX sets the TTL only on the first hit of the window, returning 1 then 0. If you send the two commands without MULTI and a client dies between them, the next request's NX still sets the TTL. This is a fixed window. 9.0.2 fixed HINCRBY not updating volatile-key tracking (#2974), one more reason for the patch floor below.
A field TTL survives only as long as every writer uses HSETEX ... KEEPTTL, HINCRBY, or the expire commands. Search the code base for HSET and HMSET on the affected key patterns before rollout, including ORMs and admin scripts.
What happens to a field after its TTL passes?
Valkey expires keys two ways: lazily, when a command touches an expired key, and actively, from a background cycle. The Valkey team's design post says field expiration deliberately has no lazy path. A read treats an expired field as nonexistent, but only the active expiry cycle deletes it.
The life of a volatile hash field
Reads hide the field at step 1; memory comes back only at step 2
No lazy delete: reading an expired field never frees it. If active expiry falls behind, logically expired fields keep using memory.
Fields are grouped into time buckets (a "volatile set" per hash), and the cycle visits only buckets whose time has passed. In 9.1.3 it alternates between keys and fields and is capped by ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC, 25% of CPU time at the default active-expire-effort 1 (expire.c).
valkey-cli INFO stats | grep -E '^(expired_fields|expired_keys_with_volatile_items_stale_perc|expired_time_cap_reached_count|expire_cycle_cpu_milliseconds):'
valkey-cli INFO keyspaceInterpretation: expired_fields counts reaped fields since start. expired_keys_with_volatile_items_stale_perc estimates how much of the sampled volatile data was already expired; a value that stays high means the cycle is not keeping up. A rising expired_time_cap_reached_count shows the cycle hitting its CPU budget. In the keyspace section, each dbN line ends with keys_with_volatile_items, the number of hashes holding at least one field TTL. All four fields are present from 9.0.0.
Keyspace notifications follow the same timing. The notifications page says hexpired fires when the server deletes the field, not when its TTL reaches zero, so do not drive time-critical work from it.
How much memory do volatile fields cost?
The design post measured the per-field cost on 9.0: an 8-byte expiry timestamp plus tracking in the volatile set, "between 16 and 29 bytes per field" in total depending on how many fields have TTLs and how spread out the times are. The higher end hits small hashes, because on 9.0 and 9.1 a hash with any volatile field cannot stay in listpack encoding. The 9.1.3 source converts a listpack hash to a hashtable as soon as a field gets an expiry. PR #3212 keeps small volatile hashes in listpack, but it shipped only in 9.2.0-rc1.
valkey-cli HSET demo:h a 1 b 2
valkey-cli OBJECT ENCODING demo:h
valkey-cli MEMORY USAGE demo:h
valkey-cli HEXPIRE demo:h 600 FIELDS 1 a
valkey-cli OBJECT ENCODING demo:h
valkey-cli MEMORY USAGE demo:hInterpretation: on 9.0 or 9.1, expect listpack before the HEXPIRE and hashtable after it, with a jump in MEMORY USAGE. Multiply that jump by your count of small hashes, and measure on staging.
For steady state, the design post gives a sizing rule: memory equals insert rate times average TTL times average item size. Its own test inserted 300,000 fields per second with a 10-second TTL and about 80 bytes per item (61 base plus 19 for expiry), and predicted about 230 MB, which matched what it observed.
# fields/s x TTL seconds x bytes per field
echo $(( 300000 * 10 * 80 )) # 240000000 bytes, about 229 MiBInterpretation: plug in your own write rate, TTL, and per-field size. The rule holds only while active expiry keeps up. Note that MEMORY USAGE on 9.0 and 9.1 does not count expired-but-unreaped fields; 9.2.0-rc2 fixes that (#5092).
How do field TTLs replicate and persist?
The primary owns expiry. In 9.1.3, t_hash.c rewrites relative times before they reach replicas and the AOF, so a replica or a reload sees the same absolute deadline:
HSETEX ... EXorPXis propagated asHSETEX ... PXATwith a millisecond timestamp.HEXPIRE,HPEXPIRE, andHEXPIREATare propagated asHPEXPIREAT.HGETEXis never propagated as itself, only asHPEXPIREAT,HPERSIST, orHDEL.- Each field the active cycle reaps is propagated as
HDEL, followed byDELwhen the hash empties.
Replicas do not reap fields themselves. They hide expired fields on read, judged by their own clock, and wait for the primary's HDEL. So a replica's HLEN can briefly exceed the primary's, and clock skew between nodes shifts the moment a field disappears from replica reads. RDB files store a hash with field TTLs as the RDB_TYPE_HASH_2 type, which needs RDB version 80.
for h in valkey-primary valkey-replica-1; do
printf '%s ' "$h"
valkey-cli -h "$h" HTTL session:u42 FIELDS 2 cart otp | tr '\n' ' '
echo
doneInterpretation: both nodes should agree within a second or two. A field that reads -2 on the primary but keeps a TTL on the replica matches the divergence fixed in PR #4772: before 9.0.7 and 9.1.3, HDEL, HGETDEL, HPERSIST, and HEXPIRE propagated fields the primary already treated as expired. An AOF reload diverged the same way.
Which Valkey patch do you need before adopting field TTLs?
These are the field-expiration fixes in the 9.0 and 9.1 release notes:
| Fixed in | Bug | Impact |
|---|---|---|
| 9.0.2 (Feb 2026) | HINCRBY/HINCRBYFLOAT not updating volatile tracking (#2974); assertion in the expiry job after a volatile hash was overwritten (#3003, #3007); HEXPIRE deleting fields when validation failed (#3023, #3048) | Crash or wrong data on common write paths |
| 9.0.5, 9.1.1 (Jul 2026) | HRANDFIELD with a count looping forever when fewer live fields exist than requested (#4047); crash when active expiry leaves a single-entry bucket (#3950); dual-channel full sync failing with field TTLs (#4105) | Hung server thread, crash, replicas that cannot resync |
| 9.0.6, 9.1.2 (Sep 2026) | HGETEX allowed with read-only key permission (#4576); crash on expiry times near the maximum timestamp (#4312) | ACL bypass, crash |
| 9.0.7, 9.1.3 (Oct 2026) | Expired fields propagated by HDEL, HGETDEL, HPERSIST, HEXPIRE (#4772); COPY dropping unreaped fields (#4939); HSETEX/HGETEX failing on a key named fields (#4945) | Replica and AOF divergence |
The floor is 9.0.7 or 9.1.3, released on 27 and 26 October 2026. Those releases also carry a cluster Pub/Sub security fix, covered with the rest of the version policy in our Valkey patch floor guide. Check every node, because the redis_version field in INFO does not report the Valkey release.
valkey-cli INFO server | grep -E '^(valkey_version|redis_version):'Interpretation: read valkey_version; redis_version is a fixed compatibility value. Every primary and replica must be at the floor.
Who can run HGETEX?
HGETEX reads values, but it is a write command: an EX changes TTLs, and a past EXAT deletes fields. Before PR #4576, its key specification asked only for read access, so a user granted %R~ on a key pattern could change TTLs or delete fields it was only allowed to read.
valkey-cli ACL DRYRUN reporting HGETEX session:u42 EX 60 FIELDS 1 cart
valkey-cli ACL DRYRUN reporting HGET session:u42 cartInterpretation: on 9.0.6, 9.1.2, or later, a user whose key permission is only %R~session:* gets OK for HGET and a permission error naming the key for HGETEX. If HGETEX returns OK for a read-only user, the node is below the fix. Read paths that need values should use HGET or HMGET.
What do field TTLs do to an upgrade rollback?
Upgrading to 9.x does not, by itself, stop you going back. Writing the first field TTL does. Valkey 7.2 and 8.0 cannot load a 9.x RDB file, and 8.1 can try only with rdb-version-check relaxed, which stops at a hash with field TTLs. A 9.1 or later primary can send an older replica a full sync in RDB 11 (PR #2735), but per the PR, "if any new features that the replica can't handle (such as hash-field expiration and atomic slot migration) are in use, the full sync is aborted". The details are in our Valkey RDB compatibility and rollback guide.
valkey-cli INFO keyspace | grep -o 'keys_with_volatile_items=[0-9]*'Interpretation: any value above 0 means a full sync to a 7.2 or 8.x replica will fail and a downgrade needs a logical copy instead of an RDB file. Keep this check in your rollback runbook.
Adopting field TTLs safely
Seven steps; the rollback point is step 6, and none of them needs downtime
-
Reach the patch floor on every node
9.0.7 or 9.1.3 or later, primaries and replicas alike.
valkey-cli INFO server | grep valkey_version -
Retire the old-version fallback
After the first field TTL, rollback to 7.2 or 8.x means a logical copy.
-
Audit ACL users for HGETEX
Read-only roles should be denied on 9.0.6 and 9.1.2 or later.
valkey-cli ACL DRYRUN reporting \ HGETEX session:u42 EX 60 FIELDS 1 cart -
Check every client and writer
Confirm the client exposes the commands, and replace HSET on volatile fields with HSETEX KEEPTTL.
-
Size memory and check eviction
Measure the listpack-to-hashtable jump on staging. Under a volatile-* policy, add a key TTL or plan headroom.
valkey-cli CONFIG GET maxmemory-policy -
Roll out on one key pattern
Start with a low-risk data set behind an application switch.
-
Watch expiry and replica parity
Track expired_fields, the stale percentage, and the CPU cap counter, and compare HTTL on primary and replicas.
valkey-cli INFO stats | grep -E \ 'expired_fields|stale_perc|time_cap'
Rollback point: step 6. The first field TTL written makes RDB files and full syncs unusable by 7.2 and 8.x, so finish steps 1 to 5 first.
Which client libraries support the field TTL commands?
Because the syntax matches Redis, most Redis clients can send the 9.0 commands; the 9.1 additions are patchier. From changelogs and source read on 10 November 2026:
| Client | HEXPIRE family | HSETEX and HGETEX | 9.1 additions (HGETDEL, HSETEX NX/XX) |
|---|---|---|---|
| Valkey GLIDE (Java, Python, Node.js, Go) | 2.1 | 2.1 | Listed under "Pending 2.6" in the GLIDE changelog |
| valkey-py | No wrapper; use execute_command | hsetex in 6.2.0 (Oct 2026), including nx/xx; no hgetex wrapper | HSETEX NX/XX only |
| redis-py | 5.0.8 | 6.0.0 | hgetdel yes; no key-level NX/XX on hsetex |
| go-redis | 9.6.1 | 9.8.0 | HGetDel yes; key-level NX/XX not checked |
With valkey-py 6.2.0, send HTTL, HEXPIRE, and HGETEX through execute_command, which returns the raw reply list. Whatever the client, test the exact calls against a 9.1.3 staging node.
Frequently asked questions
Which Valkey version supports hash field expiration?
Valkey 9.0.0, released 21 October 2025, added HSETEX, HGETEX, HEXPIRE, HPEXPIRE, HEXPIREAT, HPEXPIREAT, HTTL, HPTTL, HEXPIRETIME, HPEXPIRETIME, and HPERSIST. Valkey 9.1.0 added HGETDEL and key-level NX and XX on HSETEX. For production, run 9.0.7 or 9.1.3 or later, because earlier patches had replica divergence, crash, and ACL bugs in this feature.
Why does HLEN still count an expired hash field?
Valkey has no lazy deletion for hash fields. A read treats an expired field as missing, but the field stays in memory until the active expiry cycle reaps it, and HLEN returns the stored count. The Valkey hashes documentation states this. Count live fields from the application or with HGETALL on small hashes, and do not alert on HLEN.
Does HSET remove a hash field's TTL in Valkey?
Yes. HSET, HMSET, and HSETEX without an expiry option overwrite the field and remove its TTL, so the field becomes permanent. Use HSETEX with KEEPTTL to change a value and keep its remaining time. HINCRBY and HINCRBYFLOAT keep an existing field TTL. Search every writer for plain HSET before you rely on field TTLs.
Are hashes with field TTLs evicted under volatile-lru?
No, not by their field TTLs. The volatile eviction policies only consider keys with a key-level expire, and a field TTL does not create one. Under volatile-lru, volatile-lfu, volatile-random, or volatile-ttl, such a hash behaves like a persistent key. Give the hash its own key TTL as well, or use an allkeys policy.
Can I roll back to Valkey 8 after using hash field TTLs?
Not with an RDB file or a full sync. Valkey 7.2 and 8.x cannot load a hash with field TTLs, and a 9.1 primary aborts an RDB 11 full sync to an older replica when field TTLs exist. Plan a logical copy for that rollback, or finish validating 9.x before the first field TTL is written.
Key takeaways
- Hash field TTLs are a Valkey 9.0 feature, but the safe floor is 9.0.7 or 9.1.3 because of replica divergence, crash, and ACL fixes.
- Expired fields are hidden on read and deleted only by active expiry, so
HLEN,MEMORY USAGE, andhexpiredevents lag the TTL. - Every
HSETon a volatile field makes it permanent; useHSETEX ... KEEPTTLand audit every writer. - On 9.0 and 9.1, one field TTL turns a small listpack hash into a hashtable, and
volatile-*eviction policies ignore hashes with only field TTLs. - The first field TTL written is the rollback point for 7.2 and 8.x; check
keys_with_volatile_itemsbefore any downgrade.
Review your Valkey field TTL design with JusDB
Field TTLs touch data modelling, memory sizing, eviction, ACLs, and your upgrade plan at once, and the first one written fixes your rollback point.
JusDB's Valkey consulting team can review your hash design and client code, size memory for volatile fields, check your patch level and ACLs, and plan the rollout and rollback points.