skip to content

In Redis, a key was given an expiry and the application later updates its value; sometimes the expiry survives and sometimes it silently disappears. What is the rule, and what does the `KEEPTTL` option of the `SET` command do?

level: middleimportance: must knowfreq 60%

answer

  1. replace ⇒ TTL cleared; mutate ⇒ TTL kept
  2. plain SET makes a key immortal
  3. SET … KEEPTTL (6.0), GETEX (6.2)
  4. INCR/APPEND/LPUSH keep the countdown
  5. RENAME carries the source's TTL

basics

~20 s

Commands that replace the whole value — notably SET — clear the TTL, making the key permanent. Commands that modify a value in place (APPEND, INCR, LPUSH, HSET, SADD, ZADD) keep it. SET key value KEEPTTL (Redis 6.0+) replaces the value while retaining the existing expiry.

solid answer

~50 s

The rule follows from how Redis models the write: **replacing a key resets its expiry; mutating a key's value does not.** - `SET key value` overwrites the key as if it were new — the TTL is discarded and the key becomes persistent. Same for `GETSET`. - `INCR`, `APPEND`, `SETRANGE`, `LPUSH`, `HSET`, `SADD`, `ZADD`, `XADD` modify the existing value in place and leave the TTL untouched. - `DEL` removes key and TTL together; `RENAME` carries the source key's TTL to the destination, overwriting whatever the destination had. Since Redis 6.0, `SET key value KEEPTTL` performs the overwrite but retains the current expiry. Before that, the workarounds were to re-set the TTL explicitly (`SET key value EX 300`) or read `PTTL` first and re-apply it — which is racy. The classic production bug: a cached entry is refreshed with a plain `SET`, quietly becomes immortal, and the cache stops honoring its own TTL policy — often visible only as memory that never stops growing.

code

text · 21 lines
text
> SET session:7 "v1" EX 300
OK
> TTL session:7
(integer) 300

> APPEND session:7 "-extra"      # mutation: TTL preserved
(integer) 10
> TTL session:7
(integer) 297

> SET session:7 "v2"             # replacement: TTL discarded
OK
> TTL session:7
(integer) -1                     # key is now permanent

> SET session:7 "v3" EX 300
OK
> SET session:7 "v4" KEEPTTL     # replacement, expiry retained
OK
> TTL session:7
(integer) 298

go deeper

for a junior

Remember that a plain SET wipes the expiry and SET … KEEPTTL keeps it; know that TTL returning -1 means the key is permanent.

for a middle

State the replace-vs-mutate rule, list examples on both sides, and explain the cache-refresh bug it causes.

for a senior

Add the operational detection (volatile share in INFO keyspace, SCAN + TTL sampling), the interaction with volatile-* eviction pools, and why the pre-6.0 workaround was racy.

for a principal

Frame it as an API-design problem: make TTLs unforgeable in the shared cache client so the failure cannot be written, rather than relying on every call site to remember a flag.

## The mental model Redis stores expiries out of band: the main keyspace dictionary maps key → value, and a second `expires` dictionary maps key → absolute expiry timestamp. Whether an update preserves the TTL therefore comes down to a simple question: **does the command create the key afresh, or does it operate on an existing value?** - **Create/replace** ⇒ any old entry in `expires` is removed, so the key becomes persistent. - **Mutate in place** ⇒ `expires` is untouched, so the countdown continues. This is not an arbitrary list to memorize; it is one rule with a handful of consequences. ## Commands that clear the TTL - `SET key value` — the canonical case. Redis documents it explicitly: SET without KEEPTTL discards the previous time to live. `SET key value EX 60` replaces it with a new one; `SET key value XX` still discards the old TTL. - `GETSET key value` — same semantics (deprecated since 6.2 in favor of `SET … GET`). - `SET key value GET` — still an overwrite, so the TTL is cleared unless KEEPTTL is present. - `PERSIST key` — the explicit, intentional way to remove an expiry without touching the value. ## Commands that keep the TTL Everything that edits the existing value: `APPEND`, `SETRANGE`, `INCR`/`INCRBY`/`DECR`, `INCRBYFLOAT`, `LPUSH`/`RPUSH`/`LPOP`, `LSET`, `HSET`/`HINCRBY`/`HDEL` (as long as the hash survives), `SADD`/`SREM`, `ZADD`/`ZINCRBY`, `XADD`, `SETBIT`, `PFADD`. The countdown keeps running from wherever it was; the update does not renew it. This matters both ways: it is what makes a TTL'd rate-limit counter work with a bare `INCR`, and it means appending to a TTL'd list will *not* extend its lifetime. A special case worth stating: if a collection becomes empty (you `SREM` the last member, `LPOP` the last element), Redis deletes the key entirely and its expiry with it. Then a later write recreates it with no TTL. ## Copy and rename - `RENAME src dst` moves the value **and** `src`'s TTL onto `dst`, replacing anything `dst` had, TTL included. - `DEL`/`UNLINK` remove both entries; the key is simply gone. ## `KEEPTTL` and `GETEX` Redis 6.0 added `SET key value KEEPTTL`: overwrite the value, keep the existing expiry as-is. This is exactly what a cache-refresh path usually wants when the TTL represents "this entry is valid until T" rather than "this entry is valid for N seconds after the last write". Redis 6.2 added `GETEX`, which reads a value and manipulates its TTL in the same call: `GETEX key EX 300` (slide the window), `GETEX key PERSIST` (make it permanent), `GETEX key` (read, leave TTL alone). It is the natural tool for a sliding-expiration session read, replacing a racy GET-then-EXPIRE pair. ## Why the pre-6.0 workaround was racy Before KEEPTTL, refreshing a value while preserving its deadline meant reading `PTTL key`, then `SET key value PX <that value>`. Between the two commands the key could expire, or another client could change the TTL, so you would write a stale deadline or resurrect a key that should have died. Doing it atomically required a `MULTI`/`EXEC` with `WATCH` or a small Lua script. `KEEPTTL` collapses all of that into one flag, which is why any answer that still recommends the read-then-write dance for modern Redis is dated. ## The bug this causes in the wild The usual incident: a cache-aside helper writes with `SET key json EX 600` on a miss, but a separate "refresh" or "invalidate-and-repopulate" path writes with a plain `SET key json`. Every refreshed key loses its expiry and becomes permanent. Memory grows without bound; under a `volatile-*` eviction policy those keys also drop out of the eviction candidate pool, so the instance can end up unable to free memory at all. The symptom appears far from the cause — the fix is to make the TTL non-optional in whatever wrapper the application uses, and to spot-check with `TTL` on production keys that should be volatile.

  • Before Redis 6.0, how would you overwrite a value while keeping its remaining TTL, and why is that approach worse?
    You read `PTTL key` and then wrote `SET key value PX <remaining>`, or you used a small Lua script to do both atomically. The naive two-command version is racy: the key can expire between the read and the write, or another client can change the TTL, so you either write a stale deadline or resurrect a key that should have died. `KEEPTTL` makes it a single atomic operation with no read at all.
  • An application uses `INCR` on a TTL'd rate-limit counter. Does each increment extend the window?
    No — `INCR` mutates the existing value and leaves the expiry untouched, so the counter still dies at its original deadline. That is precisely what makes fixed-window rate limiting work: set the TTL once when the counter is created and let increments run against it. If you want a sliding window instead, you must renew the expiry explicitly, for example with `EXPIRE` (optionally with the `GT` option) or `GETEX … EX`.
  • How would you detect that a cache has been quietly accumulating permanent keys?
    Compare the volatile share of the keyspace: `INFO keyspace` reports `keys=` and `expires=` per database, and a cache where those diverge over time is losing TTLs somewhere. Sampling with `SCAN` plus `TTL` on keys matching the cache prefix pinpoints which prefixes return -1. The permanent fix is a client wrapper that makes the expiry a required argument so a TTL-less cache write cannot be expressed.

saying these in an interview costs you the question

  • Believing `SET` preserves an existing TTL because "the key already existed".
  • Thinking `INCR` or `LPUSH` renews the countdown as a side effect of writing.
  • Recommending the read-`PTTL`-then-`SET` dance on modern Redis instead of `KEEPTTL`.
  • Assuming `SET key value XX` keeps the TTL because it only touches existing keys — it does not.
  • Claiming `PERSIST` deletes the key rather than just removing its expiry.

context