A plain Redis SET deletes the expiry you set earlier, and nothing tells you
30 July 2026
There is a tool for this on the siteTwo commands, run a minute apart:
SET session:abc "{...}" EX 3600
SET session:abc "{...updated...}"
The first stores a value with a one hour expiry. The second updates the value.
The second also removes the expiry. session:abc is now permanent, and
nothing in the return value, the logs or the metrics says so.
The rule, and why it follows from what SET is
A SET with neither an expiry option nor KEEPTTL clears any TTL the key
already had. Not “leaves it alone”. Clears it.
This is not an inconsistency. SET does not update a key, it replaces one,
and a replaced key has exactly the metadata the replacing command specified.
The second command specified no expiry, so the key has none.
You can watch it happen:
> SET session:abc "v1" EX 3600
OK
> TTL session:abc
(integer) 3597
> SET session:abc "v2"
OK
> TTL session:abc
(integer) -1 # no expiry. The key is now permanent.
-1 means the key exists with no TTL. -2 means it does not exist. Those two
being adjacent integers has caused its own share of bugs.
Worth knowing what does not do this: APPEND, INCR, HSET, LPUSH,
SETRANGE and the rest of the mutating commands all preserve the TTL. They
modify a key rather than replacing it. So this is specifically a SET problem,
which is part of why it surprises people who have used Redis for years.
How it shows up, weeks later
The key is still there and still correct. The application reads it, writes it, and behaves perfectly. What has changed is that the key will never be reclaimed by expiry, and the consequences arrive on a delay measured in weeks.
On noeviction, writes eventually fail with OOM command not allowed when used memory > 'maxmemory' on an instance whose data looks entirely legitimate.
On allkeys-lru, eviction starts earlier than the capacity model predicted
and the hit rate drops a few points for no visible reason.
On volatile-lru, volatile-ttl or volatile-random, it is much worse,
and this is the case worth understanding properly. Those policies only evict
keys that have a TTL. A key that lost its expiry is no longer a candidate.
So Redis evicts your well-behaved, correctly-expiring entries and keeps the
leaked ones, which means the proportion of un-evictable data climbs
monotonically. Past a certain ratio there is nothing left to evict, and writes
fail on an instance that appears to be half full of valid data.
The gap between the write that caused it and the incident is typically weeks, which is why nobody connects the two.
Confirming it on a live instance
> INFO keyspace
db0:keys=1482301,expires=214,avg_ttl=3600102
expires is the number of keys with a TTL. If your application is supposed to
set an expiry on everything and expires is a small fraction of keys, you
have this bug. That one line is the fastest diagnosis available, and it needs
no SCAN and no downtime.
For a sample of which keys, --scan plus a TTL check works and is safe on
production:
redis-cli --scan --pattern 'session:*' --count 500 \
| head -1000 \
| xargs -n1 -I{} sh -c 'echo "$(redis-cli TTL {}) {}"' \
| grep '^-1'
Never use KEYS for this. It blocks the single thread for the whole scan.
The fix, and the choice it forces on you
Two options, and they are not interchangeable:
SET session:abc "{...}" KEEPTTL # keep the original deadline
SET session:abc "{...}" EX 3600 # restart the clock
KEEPTTL preserves whatever expiry the key already had, so a session created
fifty minutes ago still expires in ten. EX 3600 resets it, so a session
touched every fifty minutes never expires at all.
KEEPTTL | EX on every write | |
|---|---|---|
| Semantics | Absolute deadline from creation | Sliding window from last touch |
| Right for | Cache entries with a freshness guarantee, rate limit windows, anything with a hard lifetime | Sessions that should stay alive while in use |
| Failure mode | A hot key expires mid-use | A key touched frequently never expires, which is the original bug with extra steps |
| Available since | Redis 6.0 | Always |
Pick deliberately. The current behaviour, silently discarding, is the one nobody chooses on purpose.
If you are on Redis 5 or older, KEEPTTL does not exist and you need either a
GETEX-style read-modify-write or a small Lua script that reads the TTL and
re-applies it atomically. That is the strongest single argument for upgrading
off 5.
The wrong instinct: “we will set the TTL somewhere else”
The common workaround is to leave the writes alone and add an EXPIRE call
after each one:
SET session:abc "{...}"
EXPIRE session:abc 3600
Two problems. The first is a race: between those two commands the key is permanent, and a process that crashes in between leaves it that way forever. Over enough writes, “rarely” becomes “continuously”.
The second is that it does not compose with the conditional forms. SET ... NX
plus EXPIRE is the classic broken distributed lock: acquire, crash before the
EXPIRE, and the lock is held permanently by a process that no longer exists.
That is precisely why SET key token NX EX 30 exists as a single atomic
command, and why SETNX followed by EXPIRE is the wrong recipe even though
every older tutorial shows it.
Keep the expiry in the SET. One command, no window.
Finding it in a codebase
The shape that produces this is almost always two code paths: one that creates the key with a TTL, and another that updates it without one. They are usually in different files, often written by different people, months apart. Neither is wrong in isolation, which is exactly why it survives review.
What to grep for:
- Every
SETagainst a key prefix that appears anywhere withEX,PX,EXATorPXAT. If any write to that prefix carries an expiry and another does not, you have it. - Client library defaults. Several ORMs and cache wrappers expose
set(key, value)andset(key, value, ttl=...)as separate calls, and the first quietly clears. - Anything doing read-modify-write. Fetch, mutate, store back is the canonical producer of this bug.
A cheap structural fix, if the codebase allows it: wrap SET so the TTL
argument is required, with an explicit KEEP sentinel for the preserve
case. Making the decision unskippable at the call site is more reliable than
finding every existing site once.
What changed recently
Two things worth knowing if your Redis knowledge predates 7.0:
SET with NX and GET together works from 7.0. Before that the
combination was a syntax error. It is genuinely useful, because it tells you the
existing value in the same round trip that declines to overwrite it, which
previously needed a Lua script for the same atomicity.
EXAT and PXAT arrived in 6.2. They set an absolute expiry rather than a
relative one, which avoids the drift you accumulate by recomputing a duration on
every write. For anything where the deadline is a real point in time, a token
expiry or an end-of-day cache, absolute is the better fit and removes a whole
class of clock-arithmetic error.
Also worth noting: SETEX, PSETEX and SETNX still work and are not going
away, but they are superseded. SET with options does everything they do and
can combine an expiry with a conditional set, which none of them can.
Fixing an instance that already has leaked keys
You cannot retroactively know what the TTL should have been. What you can do:
1. Stop the bleeding first. Deploy the KEEPTTL or explicit-EX fix. There
is no point cleaning up while the leak is open.
2. Measure the damage. INFO keyspace, comparing expires against keys
per database.
3. Apply a TTL to the existing keys, carefully. For a key space where a
uniform expiry is defensible, a scripted EXPIRE pass over --scan output
works. Batch it and rate limit it: a few hundred keys per call with a pause,
not a tight loop. Redis is single threaded, and a cleanup that pauses the
instance is a worse incident than the one you are fixing.
4. Consider whether the eviction policy is right. If you are on
volatile-* and the application does not reliably set TTLs, allkeys-lru is
more honest. It evicts under pressure rather than failing writes, which for a
cache is the correct behaviour. volatile-* is for instances holding a mix of
cache and durable data, and it assumes the TTLs are trustworthy.
When this advice is wrong
If the key is genuinely meant to be permanent, a plain SET is correct and
KEEPTTL would be the bug. Configuration values, feature flags and reference
data belong here. The problem is not SET clearing a TTL; it is SET clearing
a TTL you wanted kept.
If you are using Redis as a primary datastore rather than a cache, most of
this does not apply, because most keys should not expire. Watch instead for the
opposite error: a stray EX on data that needs to persist.
If every write to a key carries the same expiry anyway, the resetting
behaviour of EX is what you want and KEEPTTL adds nothing. Sessions are the
usual case: a sliding window is the intended semantics, and the bug only exists
for code paths that forget the option entirely.
The short version
SETwithoutKEEPTTLor an expiry clears the existing TTL. Silently.TTL keyreturning-1means it exists with no expiry.-2means gone.INFO keyspaceand comparingexpiresagainstkeysis the fastest diagnosis.- On
volatile-*policies the leaked keys become un-evictable, so Redis evicts your good data first. SET ... EXPIREas two commands has a crash window. Keep the expiry in theSET.KEEPTTLpreserves the deadline;EXrestarts it. They are different products and you have to choose.
The Redis SET command builder flags any SET
you describe that has neither an expiry nor KEEPTTL, along with the version
requirements for each option and the ownership check a lock release needs. It
runs entirely in your browser, which matters when the value you are pasting is
a real session.