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 site

Two 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.

KEEPTTLEX on every write
SemanticsAbsolute deadline from creationSliding window from last touch
Right forCache entries with a freshness guarantee, rate limit windows, anything with a hard lifetimeSessions that should stay alive while in use
Failure modeA hot key expires mid-useA key touched frequently never expires, which is the original bug with extra steps
Available sinceRedis 6.0Always

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 SET against a key prefix that appears anywhere with EX, PX, EXAT or PXAT. 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) and set(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

  • SET without KEEPTTL or an expiry clears the existing TTL. Silently.
  • TTL key returning -1 means it exists with no expiry. -2 means gone.
  • INFO keyspace and comparing expires against keys is the fastest diagnosis.
  • On volatile-* policies the leaked keys become un-evictable, so Redis evicts your good data first.
  • SET ... EXPIRE as two commands has a crash window. Keep the expiry in the SET.
  • KEEPTTL preserves the deadline; EX restarts 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.