Redis 7.4 added the HEXPIRE command family. What problem does expiring individual hash fields solve, how do you use those commands, and how did teams handle the same requirement before they existed?
answer
- pre-7.4: TTL was key-level only
- HEXPIRE key ttl FIELDS numfields f… (FIELDS clause mandatory)
- replies: -2 no field, 0 condition unmet, 1 set, 2 deleted now
- HTTL / HPERSIST; last field expires ⇒ key gone
- old workarounds: key-per-field, timestamp zset + sweeper
basics
~20 sBefore 7.4, TTL applied only to a whole key, so one stale attribute forced you to expire the entire object. HEXPIRE key ttl FIELDS numfields f1 f2 sets a TTL per field; HTTL reads it, HPERSIST clears it, and the key is deleted when its last field expires. Older workarounds: one key per field, or a timestamp index swept by a job.
solid answer
~60 s**The problem**: classic Redis expiry is a key-level property. If a session hash holds a long-lived profile plus a short-lived MFA challenge, you either expire the whole object too early or leak the challenge. **The commands** (7.4+): `HEXPIRE key seconds [NX|XX|GT|LT] FIELDS numfields field [field …]`, plus `HPEXPIRE`, `HEXPIREAT`, `HPEXPIREAT`, the readers `HTTL`/`HPTTL`, and `HPERSIST` to remove a field TTL. The mandatory `FIELDS numfields` clause is unusual syntax that trips people up. The reply is an array of per-field codes: `-2` no such field, `0` a condition like `NX` was not met, `1` TTL set, `2` the field was deleted immediately because the TTL was already in the past. **Semantics**: fields are removed lazily on access and by the active expiry cycle, exactly like keys; expiry fires on the primary and is propagated to replicas as a deletion; when the final field expires the key itself disappears. **Before 7.4**: one top-level key per attribute with `EXPIRE`, or a companion sorted set scored by expiry timestamp swept with `ZRANGEBYSCORE` + `HDEL`, or storing the deadline inside the value and filtering on read.
code
text · 15 linesHSET session:abc user_id 42 mfa_code 123456
# expire only the one-time code in 60 seconds
HEXPIRE session:abc 60 FIELDS 1 mfa_code
# 1) (integer) 1
HTTL session:abc FIELDS 2 mfa_code user_id
# 1) (integer) 58 <- seconds remaining
# 2) (integer) -1 <- exists, no TTL
HPERSIST session:abc FIELDS 1 mfa_code # remove the field TTL again
# a TTL already in the past deletes the field immediately (code 2)
HEXPIRE session:abc 0 FIELDS 1 mfa_code
# 1) (integer) 2go deeper
Know that TTL was historically per key and that Redis 7.4 added HEXPIRE so individual hash fields can expire.
Get the syntax right — the FIELDS numfields clause, the NX/XX/GT/LT conditions, HTTL and HPERSIST — and the per-field reply codes.
Cover lazy plus active expiry, primary-driven propagation to replicas, the key disappearing with its last field, the hexpired notification, and the pre-7.4 workarounds with their costs.
Weigh it as a design option: per-field TTL removes a sweeper and a whole class of key sprawl, but adds per-field bookkeeping and a hard version floor, so decide against rolling keys and key-per-attribute on the actual lifetime spread of the attributes.
## The gap that existed until 7.4 For most of Redis's life, time-to-live was a property of a **key**. `EXPIRE user:42 3600` expires the entire hash. That is fine when every attribute shares a lifetime and painful when they do not: - A session hash where `user_id` should persist for the session but a one-time code must die in 60 seconds. - A per-user rate-limit hash where each endpoint's counter has its own window. - A cache object where one expensive-to-compute attribute is refreshed hourly and the rest daily. - Consent or feature-flag records where individual grants expire on their own schedule. The old choices were all bad in some direction: expire the whole object too aggressively and you throw away good data; expire it lazily and you serve stale attributes; or abandon the hash and explode the object into many top-level keys, losing the grouping and the memory efficiency that made a hash attractive. ## The 7.4 command family ``` HEXPIRE key seconds [NX|XX|GT|LT] FIELDS numfields field [field ...] HPEXPIRE key milliseconds [NX|XX|GT|LT] FIELDS numfields field [field ...] HEXPIREAT key unix-seconds [NX|XX|GT|LT] FIELDS numfields field [field ...] HPEXPIREAT key unix-millis [NX|XX|GT|LT] FIELDS numfields field [field ...] HTTL key FIELDS numfields field [field ...] HPTTL key FIELDS numfields field [field ...] HPERSIST key FIELDS numfields field [field ...] ``` The `FIELDS numfields …` clause is mandatory and the count must match the number of fields listed — a mismatch is a syntax error, and it is the single most common mistake when writing these by hand. The design mirrors the key-level `EXPIRE` family exactly, including the conditional flags: - `NX` — set only if the field has no TTL. - `XX` — set only if it already has one. - `GT` / `LT` — set only if the new expiry is greater / less than the current one (a field with no TTL is treated as infinite, so `GT` never sets on it and `LT` always does). Each command returns an **array with one code per requested field**: - `-2` — the field does not exist (or the key does not exist). - `0` — the condition (`NX`/`XX`/`GT`/`LT`) was not satisfied, so nothing changed. - `1` — the TTL was set or cleared as asked. - `2` — the field was deleted immediately, because the requested TTL was zero or in the past. `HTTL` similarly reports `-1` for a field that exists with no TTL and `-2` for a field that does not exist. ## Expiry semantics Field expiry follows the same model as key expiry: - **Lazy**: an expired field is filtered out when something touches it, and removed at that point. - **Active**: a background cycle samples and reclaims expired fields so memory is not held by data nobody reads. - **Primary-driven**: replicas do not expire fields on their own clock. The primary decides and propagates an explicit deletion, which keeps a replica's view consistent with the primary rather than drifting on clock skew. - **Key lifecycle**: when the last field of a hash expires, the key itself is deleted — the same rule as `HDEL`ing the last field, since Redis keeps no empty containers. - **Notifications**: keyspace notifications include a `hexpired` event, so consumers can react to field-level expiry the way they do to key expiry. A field TTL and a key TTL coexist: whichever fires first wins, and a key-level `EXPIRE` firing removes everything regardless of per-field deadlines. ## What people did before 7.4 **One key per attribute.** `session:abc:code` with its own `EXPIRE`, alongside `session:abc` for the rest. Correct and simple, but it multiplies key count (each key carries its own overhead), loses the compact small-hash encoding, and breaks the ability to fetch the object in one `HGETALL`. In a cluster you also need a hash tag to keep the pieces in the same slot. **A companion expiry index.** A sorted set per hash (or per shard of hashes) scored by the field's expiry timestamp. A sweeper runs `ZRANGEBYSCORE idx -inf <now>` in bounded batches and issues `HDEL` plus `ZREM`. This preserves the hash but adds a moving part: the sweeper must be scheduled, must be idempotent, must not run unbounded batches that stall the server, and must be owned by exactly one process (or be safe to run concurrently). **Deadline in the value.** Store `value|expires_at` and filter on read, discarding — and optionally deleting — anything past its deadline. Zero infrastructure, but memory is never reclaimed for fields nobody reads again, and every reader must implement the filter identically, so one forgetful call site serves stale data. **Rolling keys.** For window-shaped data, encode the window in the key name (`limits:user:42:2026-08-14T10`) and expire the whole key. Still the simplest answer when all fields genuinely share a schedule — `HEXPIRE` does not replace it. ## Operational notes Field TTLs are not free: the server must track a deadline per field, which adds memory per expiring field and work to the active-expiry cycle. Setting TTLs on millions of fields is a different proposition from setting them on a handful, so measure. And the feature is version-gated — a deployment with any pre-7.4 node, or a managed service on an older engine version, will reject `HEXPIRE` as an unknown command, so a rollout upgrades servers before clients start issuing it.
- What happens when the last remaining field of a hash expires?The key is deleted, exactly as if you had `HDEL`ed the final field — Redis does not keep empty containers. `EXISTS` then returns 0 and any key-level TTL is gone. Code that recreates the hash afterwards must re-apply both the key TTL and any field TTLs it relied on.
- How do replicas handle expired hash fields?They do not expire fields independently. The primary decides that a field has expired — lazily on access or via the active expiry cycle — and propagates an explicit deletion to replicas, the same model as key expiry. This keeps a replica's dataset consistent with the primary instead of drifting on clock differences, so a replica may briefly still hold a field whose deadline has passed until the primary acts on it.
- You are on Redis 7.0 and need per-field expiry today. What do you do?Either split the short-lived attribute into its own top-level key with `EXPIRE` (co-located with a hash tag if you are in a cluster), or keep a companion sorted set scored by expiry timestamp and run a bounded sweeper that does `ZRANGEBYSCORE` followed by `HDEL`/`ZREM`. If reads are rare, storing the deadline in the value and filtering on read works, but it never reclaims memory for fields nobody reads again.
saying these in an interview costs you the question
- Thinking EXPIRE has always been able to target a hash field
- Omitting the mandatory FIELDS numfields clause, or giving a count that does not match the field list
- Assuming HEXPIRE exists on any Redis version — it is 7.4+
- Believing replicas expire fields on their own clock rather than acting on the primary's deletion
- Expecting the hash key to survive as an empty hash once its last field expires