Expiring Keys: EXPIRE and TTL

Most data you put in Redis shouldn’t live forever. Session tokens, rate-limit counters, one-time codes, and cached query results are all useful only for a limited window, and Redis lets you attach a countdown timer — a TTL (time to live) — directly to a key so it disappears on its own. This lesson covers EXPIRE, TTL, their millisecond and absolute-timestamp variants, PERSIST, and how SET interacts with expiration — plus the internals that explain exactly when an expired key actually vanishes.

Overview / How it works

Every key in Redis can optionally have an expiration time stored alongside it in a separate internal dictionary that maps key names to an absolute Unix timestamp (in milliseconds) at which the key should cease to exist. A key with no entry in that dictionary simply lives forever, or until it’s explicitly deleted or evicted. Setting a TTL doesn’t touch the value at all — it only adds or updates that timestamp entry, which is why EXPIRE is O(1) regardless of the size of the value it’s attached to.

Because Redis is single-threaded for command execution, checking whether a key is expired and removing it is always an atomic, uninterruptible step from the perspective of any client — you never observe a key in a half-expired state. But how does Redis actually notice that a key has expired, given that nothing is constantly watching the clock? It uses two complementary strategies:

  • Lazy expiration — whenever a key is accessed (by GET, EXISTS, TTL, and so on), Redis first checks its expiration timestamp. If it’s in the past, Redis deletes the key on the spot and behaves as if it never found it (returning (nil), 0, or -2 as appropriate) before doing anything else.
  • Active expiration — a background cycle runs several times per second, sampling a small batch of keys that have a TTL set, deleting any that have expired. If more than a quarter of the sample was expired, it immediately samples again, so a burst of simultaneously-expiring keys gets cleaned up quickly rather than trickling out. This is what reclaims memory from keys that expire but are never touched again by a client.

Together these guarantee two things: an expired key is never returned to a client even for a moment, and memory used by expired keys is eventually reclaimed even without lazy access. In a replicated setup, only the primary runs active expiration and lazy deletion drives the decision; it then propagates an explicit DEL to replicas, so replicas never expire a key on their own initiative.

Syntax

EXPIRE key seconds [NX | XX | GT | LT]
PEXPIRE key milliseconds [NX | XX | GT | LT]
EXPIREAT key unix-time-seconds [NX | XX | GT | LT]
PEXPIREAT key unix-time-milliseconds [NX | XX | GT | LT]
TTL key
PTTL key
PERSIST key
SET key value [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
  • key — the key to operate on.
  • seconds / milliseconds — a relative TTL from now.
  • unix-time-seconds / unix-time-milliseconds — an absolute point in time (used by EXPIREAT/PEXPIREAT and SET ... EXAT/PXAT).
  • NX — only set the expiry if the key has no TTL yet.
  • XX — only set the expiry if the key already has a TTL.
  • GT — only set the expiry if the new one is greater (later) than the current TTL.
  • LT — only set the expiry if the new one is less (sooner) than the current TTL.
  • KEEPTTL (on SET) — preserve whatever TTL the key already had instead of clearing it.
Command Purpose Time complexity
EXPIRE Set a relative TTL in seconds O(1)
PEXPIRE Set a relative TTL in milliseconds O(1)
EXPIREAT / PEXPIREAT Set an absolute expiration time O(1)
TTL / PTTL Read remaining time to live O(1)
PERSIST Remove a key’s TTL, making it permanent O(1)

The special return values of TTL/PTTL matter a lot: -1 means the key exists but has no expiration set, and -2 means the key doesn’t exist at all (which also covers a key that already expired).

Examples

Example 1: Setting and reading a TTL

SET session:abc123 "active"
TTL session:abc123
EXPIRE session:abc123 60
TTL session:abc123

Output:

OK
(integer) -1
(integer) 1
(integer) 60

The fresh key has no TTL, so TTL reports -1. After EXPIRE session:abc123 60, the key will be deleted in 60 seconds, and EXPIRE itself returns 1 to confirm the timeout was set (it returns 0 if the key didn’t exist).

Example 2: Millisecond precision with PEXPIRE and PTTL

SET otp:code:9988 "483920"
PEXPIRE otp:code:9988 120000
PTTL otp:code:9988
TTL otp:code:9988

Output:

OK
(integer) 1
(integer) 119999
(integer) 120

PEXPIRE sets a TTL of 120000 milliseconds (two minutes). Because a small amount of real time passes between commands, PTTL reports a number a little under 120000 rather than exactly 120000 — that’s expected, not a bug. TTL gives the same duration rounded to the nearest whole second. One-time codes like this are a classic use for millisecond-level control, since you often want a short, precise window.

Example 3: How a plain SET clears a TTL — and how to prevent it

SET cache:product:42 "widget-data-v1" EX 30
TTL cache:product:42
SET cache:product:42 "widget-data-v2"
TTL cache:product:42
SET cache:product:42 "widget-data-v3" EX 30
SET cache:product:42 "widget-data-v4" KEEPTTL
TTL cache:product:42

Output:

OK
(integer) 30
OK
(integer) -1
OK
OK
(integer) 30

The first SET ... EX 30 creates the key with a 30-second TTL. A plain SET that overwrites it — even though it doesn’t touch EXPIRE at all — wipes the TTL out, so the second TTL call returns -1. The last two lines show the fix: adding KEEPTTL to the overwriting SET preserves the countdown that was already running instead of resetting the key to “lives forever.”

Example 4: PERSIST, and EXPIRE on a key that doesn’t exist

SET user:1001:auth_token "xyz789"
EXPIRE user:1001:auth_token 3600
PERSIST user:1001:auth_token
TTL user:1001:auth_token
EXPIRE user:9999:auth_token 3600

Output:

OK
(integer) 1
(integer) 1
(integer) -1
(integer) 0

PERSIST strips the TTL from user:1001:auth_token and returns 1 to confirm a TTL was actually removed (it returns 0 if the key had none to begin with). The final line calls EXPIRE on a key that was never created; Redis has nothing to attach a timeout to, so it does nothing and returns 0 — it does not create the key or raise an error.

Example 5: Conditional expiry with NX / XX / GT / LT (Redis 7.0+)

SET promo:code:SUMMER "10PERCENT"
EXPIRE promo:code:SUMMER 100
EXPIRE promo:code:SUMMER 50 GT
TTL promo:code:SUMMER
EXPIRE promo:code:SUMMER 200 GT
TTL promo:code:SUMMER

Output:

OK
(integer) 1
(integer) 0
(integer) 100
(integer) 1
(integer) 200

After the TTL is set to 100 seconds, the attempt to shorten it to 50 with GT (“only if greater than the current TTL”) is rejected — EXPIRE returns 0 and the TTL stays at 100. Extending it to 200 with GT succeeds, since 200 is indeed greater than 100. These modifiers are useful when several parts of your code might try to touch the same TTL and you only want the timeout to move in one direction.

How it works step by step

  1. A client sends EXPIRE key seconds. Redis computes an absolute expiration timestamp (now + seconds * 1000 milliseconds) and stores it in its internal expires dictionary, keyed by the key name. This is an O(1) hash table write.
  2. Any later command that touches that key — a read like GET/TTL or a write — first checks the expires dictionary. If the stored timestamp is in the past, Redis deletes the key immediately, propagates the deletion to any replicas and the AOF log, and then proceeds as though the key had never existed.
  3. Independently, a background cycle runs roughly ten times per second: it randomly samples a batch of keys that carry a TTL, deletes any that have expired, and repeats immediately (instead of waiting for the next cycle) if a large share of that sample turned out to be expired. This is what cleans up memory for keys nobody ever reads again after they expire.
  4. Because all of this — the check, the delete, and the command itself — runs on the single command-processing thread, no other client can ever observe a key mid-expiration; it’s either fully present or fully gone.

Common Mistakes

Mistake 1: Setting a value and its TTL as two separate commands

SET rate:limit:user:55 "1"
EXPIRE rate:limit:user:55 60

Output:

OK
(integer) 1

This looks fine, but it’s two separate round trips to the server. If your application crashes, is killed, or loses its connection between the SET and the EXPIRE, the key is left behind with no TTL at all — a rate-limit key that should have cleared itself after a minute instead blocks that user forever. Set the value and its expiration in one atomic call instead:

SET rate:limit:user:55 "1" EX 60

Output:

OK

Since Redis commands are atomic and single-threaded, this single command can never be interrupted halfway — the key and its expiration are created together or not at all.

Mistake 2: Overwriting a key with plain SET and losing its TTL

As shown in Example 3 above, refreshing a cached value with a bare SET cache:product:42 "new-value" silently clears whatever TTL that key had, turning a temporary cache entry into a permanent one that never gets reclaimed. Always add KEEPTTL when you intend to update a value without touching its expiration, and reserve a bare SET for cases where you genuinely want the key to live forever from this point on.

Mistake 3: Ignoring EXPIRE’s return value

As Example 4 showed, calling EXPIRE on a mistyped or already-expired key name returns 0 and does nothing — it does not error and does not create the key. Code that fires EXPIRE and never checks the reply can end up with keys that were meant to be temporary but silently never got a TTL attached, quietly leaking memory over time. Always check for 0 when the timeout absolutely must be set.

Mistake 4: Passing a timestamp that’s already in the past

SET temp:job:55 "queued"
EXPIRE temp:job:55 -1
EXISTS temp:job:55

Output:

OK
(integer) 1
(integer) 0

A negative TTL (or an EXPIREAT/PEXPIREAT timestamp that’s already in the past) doesn’t error — Redis treats it as “this key should already be gone” and deletes it immediately, exactly like DEL. This becomes a real bug when a TTL is computed dynamically from a stored timestamp (say, a session’s original creation time plus a duration) and a clock skew, timezone bug, or stale cached time causes the computed value to land in the past: the key you meant to keep alive vanishes on the spot.

Best Practices

  • Set the TTL at creation time with SET key value EX seconds (or PX) instead of a separate EXPIRE call, so the value and its lifetime are established atomically.
  • Use KEEPTTL whenever you’re updating a value in place and want its existing expiration to keep counting down unchanged.
  • Reach for PERSIST only when you deliberately want to convert a temporary key into a permanent one — it’s an easy command to call by accident if you’re not paying attention to which key you’re touching.
  • Use the NX/XX/GT/LT modifiers on EXPIRE (Redis 7.0+) when multiple code paths might try to set a TTL on the same key, so a shorter or already-set timeout can’t be accidentally overwritten.
  • Never scan for keys with KEYS pattern in production to audit TTLs or anything else — it’s O(N) over the whole keyspace and blocks the single-threaded server for the entire scan. Use the cursor-based, non-blocking SCAN command instead.
  • Don’t rely on a maxmemory-policy eviction policy as a substitute for setting TTLs deliberately — eviction is a memory-pressure safety net, not a data-lifecycle strategy, and it can evict keys you actually still needed.
  • Remember that PTTL readings will always be a little less than the exact millisecond value you set, since real time has elapsed between the write and the read — don’t write logic that expects an exact match.

Practice Exercises

  1. Create a key cart:9001:items holding a string, give it a 5-minute TTL, then update its value without disturbing that TTL. Check with TTL that the countdown is unaffected before and after the update.
  2. Create a key with a short TTL (a few seconds), then use PERSIST to cancel that expiration before it fires. Confirm with TTL that the key is now permanent (-1).
  3. Set a TTL of 300 seconds on a key, then try to shorten it to 60 seconds using both a plain EXPIRE and an EXPIRE ... LT. Predict which one succeeds before you run it, then verify with TTL.

Summary

  • EXPIRE/PEXPIRE set a relative TTL in seconds/milliseconds; EXPIREAT/PEXPIREAT set an absolute expiration timestamp; all are O(1).
  • TTL/PTTL read the remaining time: -1 means no TTL is set, -2 means the key doesn’t exist.
  • A plain SET on an existing key clears its TTL unless you add KEEPTTL; use SET key value EX seconds to set a value and its TTL atomically in one step.
  • PERSIST removes a key’s TTL, making it permanent again.
  • EXPIRE on a nonexistent key returns 0 and does nothing; a negative or past-dated expiration deletes the key immediately, just like DEL.
  • Redis 7.0+ supports NX/XX/GT/LT modifiers on EXPIRE to conditionally set a TTL relative to whatever’s already there.
  • Expired keys are removed both lazily (on access) and actively (via a periodic background sampling cycle), and because Redis is single-threaded, a key is never observably “half expired.”
  • Avoid KEYS in production for any purpose, including TTL audits — use SCAN instead.