Expiration, and the Two Ways a Key Dies

Topics Covered

How a TTL Is Stored and Read

It is an absolute timestamp, not a countdown

The commands that set one

Reading one back

The conditional forms from 7.0

Which commands preserve a TTL and which destroy it

Renames, copies and moves

GETEX, and reading with a side effect

The clock, and what happens when it moves

Expiry on a database with many keys

A time to live looks like the simplest feature in Redis and it is the one whose details catch the most people, because a TTL is not a countdown and the commands that preserve or destroy one do not follow an obvious rule.

It is an absolute timestamp, not a countdown

A TTL is stored as an absolute Unix timestamp in milliseconds, in a second dictionary called the expires dictionary, keyed by a pointer to the same key string the main dictionary holds. Nothing counts down. When EXPIRE key 60 is issued, Redis reads its own clock, adds sixty thousand milliseconds and stores the result, and from that moment the stored value is a point in time that has no further relationship to the command that created it.

This is why the RDB and AOF formats can store expiry at all: an absolute time survives a restart with its meaning intact, where a remaining duration would not. It also means a key whose expiry has passed while the server was down is already expired when the server comes back up, and will be reported as absent on the first access. A long outage therefore produces a keyspace that is logically much emptier than the file on disk suggests, which is correct and occasionally alarming.

A TTL is an absolute millisecond timestamp in a second dictionary, not a countdown. That is what lets expiry survive a restart with its meaning intact, and it is why a key is already expired when the server comes back up from a long outage.

The commands that set one

EXPIRE and PEXPIRE take a duration, EXPIREAT and PEXPIREAT take an absolute time, and all four are a separate round trip after the write. SET can carry EX, PX, EXAT or PXAT in the same command, which is both faster and atomic, and the atomicity matters more than the speed: a SET followed by a separate EXPIRE has a window in which the key exists with no expiry, and a crash or a failover in that window leaves it permanently.

That window is the source of the single most common expiry bug in production, which is a keyspace slowly accumulating immortal keys that were supposed to be temporary. The remedy is not retry logic, it is to set the expiry in the same command as the write, every time, and to treat a two-command write-then-expire in a code review as a defect rather than a style preference.

Reading one back

TTL returns the remaining seconds, PTTL the remaining milliseconds, and both have two sentinel returns worth memorising because they are easy to confuse. Minus one means the key exists and has no expiry. Minus two means the key does not exist, or is logically expired, which from a client's point of view is the same thing.

Conflating those two is a real bug: code that treats any negative return as absent will consider an immortal key missing, and code that treats minus two as present-without-expiry will try to read a key that is gone. EXISTS is the clearer check for presence, and PERSIST is the command that removes an expiry and returns whether there was one to remove.

The conditional forms from 7.0

EXPIRE gained four flags: NX to set an expiry only if none exists, XX only if one does, GT only if the new expiry is further in the future than the current one, and LT only if it is nearer. Before these, a conditional change to an expiry required a read, a decision and a write, with a race in the middle.

GT is the one that solves a real and common problem, which is extending a lease or a session without accidentally shortening it when two requests arrive out of order. The semantics with a key that has no expiry are worth knowing: a key with no TTL is treated as having an infinite one, so GT never sets an expiry on a persistent key and LT always does.

Which commands preserve a TTL and which destroy it

The rule is that a command which overwrites the value destroys the TTL, and a command which modifies the value in place keeps it. SET without KEEPTTL clears the expiry, and so does GETSET. INCR, APPEND, SETRANGE, HSET on an existing hash, LPUSH on an existing list and every other in-place modification leave it alone.

This catches people constantly, and the shape of the bug is always the same: a cached value is refreshed with a plain SET, the TTL silently disappears, and the entry becomes permanent. SET key value KEEPTTL exists precisely for the refresh-in-place case, and it is the flag I would reach for by default on any write to a key that is meant to be temporary.

Renames, copies and moves

RENAME carries the TTL to the new name, which is usually what is wanted and is worth knowing because the alternative would make the rename-into-place pattern unusable. If the destination key exists it is overwritten along with whatever expiry it had. COPY does not carry the TTL unless the source key's expiry is explicitly handled, and MOVE between databases carries it.

The rename-into-place pattern deserves a note because it is the right way to update a cached value atomically: build the new value under a temporary name, set its expiry, then RENAME it over the live name. Readers see either the old complete value or the new complete value and never a partial one, and the TTL arrives with the new value rather than being a second step that can fail.

GETEX, and reading with a side effect

GETEX from 6.2 reads a value and optionally changes its expiry in the same command, including PERSIST to remove one. It is the atomic form of the read-and-extend operation that sliding-window sessions need, and before it the same thing required a transaction or a script.

The sliding-window session is worth a sentence because it is such a common design: every request reads the session and pushes its expiry out, so an active user never gets logged out and an inactive one expires. GETEX key EX 1800 is that entire design in one command, and the alternative of a GET followed by an EXPIRE is two round trips with a window between them.

The clock, and what happens when it moves

Expiry compares against the server's own clock, which means a clock that jumps affects expiry. A jump backwards makes keys live longer than intended; a jump forwards expires a batch of keys early. Redis reads the clock via a cached value updated on each event loop iteration, so the resolution is the loop's, which is sub-millisecond under load and coarser on an idle server.

The practical concern is not NTP's gentle slewing, which is invisible here, but a virtual machine resuming from a snapshot or a host with a badly broken clock, where a jump of hours is possible. The consequence is a mass expiry in a single instant, which is exactly the shape of event the last section of this lesson is about, arriving without any change to the application.

Expiry on a database with many keys

Each logical database has its own expires dictionary, and the active cycle described later walks each one. INFO keyspace reports per-database key and expiry counts, which is the quickest way to see what fraction of a keyspace has a TTL at all.

That fraction is a diagnostic worth looking at: a keyspace where every key has a TTL is bounded by construction and will never surprise anybody on memory, and one where a quarter do is a keyspace with three quarters of its contents growing without limit. I have never looked at that figure on a long-lived instance and found it reassuring, which is why it belongs on the quarterly audit alongside the per-prefix memory ranking.

Common Pitfall

SET without KEEPTTL clears the key's expiry. A cached value refreshed with a plain SET silently becomes permanent, and this is the most common way a keyspace accumulates immortal keys that were meant to be temporary. Use SET key value KEEPTTL for a refresh in place, set the expiry in the same command as the original write rather than as a second round trip, and treat a write-then-EXPIRE pair in review as a defect, because a crash in the window between them leaves the key forever.