String Expiration (EX, PX, EXAT)

Redis lets you attach a time-to-live (TTL) to any key so it disappears automatically after a set number of seconds, milliseconds, or at a specific moment in time. This is central to using Redis as a cache, a session store, or a rate limiter: instead of writing cleanup code, you tell Redis when a key should stop existing and it handles the rest. This lesson covers every way to set, read, and clear expiration on string keys — SET ... EX/PX/EXAT/PXAT, EXPIRE, TTL, PERSIST, and GETEX — along with how expiration actually works internally.

Overview / How Expiration Works

Every key in Redis lives in the main keyspace dictionary (a hash table mapping keys to values). Keys with a TTL are additionally tracked in a second internal dictionary that maps the key name to an absolute expiration timestamp in milliseconds. Setting a TTL doesn’t move or copy the value — it just adds an entry to this expires dictionary. This is why expiration is O(1): Redis is not scanning anything, it’s a direct hash table lookup and insert.

Redis removes expired keys in two complementary ways:

  • Lazy expiration: whenever a key is accessed (by GET, EXISTS, an internal command, anything), Redis first checks the expires dictionary. If the key’s TTL has passed, Redis deletes it on the spot and behaves as if the key never existed — the caller sees (nil) or an empty result. This guarantees correctness: you can never accidentally read a logically-expired value, even if the background sweep hasn’t gotten to it yet.
  • Active expiration: a background cycle runs roughly 10 times per second. Each pass, Redis samples a batch of keys from the expires dictionary, deletes any that have passed their TTL, and if more than 25% of the sample was expired, it immediately samples again. This reclaims memory for expired keys that are never read again (so lazy expiration alone would leave them sitting in RAM forever).

Because Redis is single-threaded, both mechanisms run without any risk of a command interleaving mid-check — a key can’t be read by one client while being expired by another at the exact same instant. Expiration is also propagated to replicas and the AOF log as an explicit DEL (or UNLINK), so replicas don’t independently decide when a key expires; they follow the primary’s decision, keeping all nodes consistent.

An important, frequently-missed rule: a plain SET key value on an existing key clears any TTL that key had, because SET is a full overwrite of the key’s metadata, not just its value. If you want to update a value while keeping its existing expiration, you must add the KEEPTTL option.

Syntax

The expiration options attach to SET directly, or can be applied afterward with the standalone EXPIRE family of commands.

SET <key> <value> [EX seconds | PX milliseconds | EXAT unix-seconds | PXAT unix-ms | KEEPTTL] [NX | XX] [GET]
EXPIRE <key> <seconds> [NX | XX | GT | LT]
PEXPIRE <key> <milliseconds> [NX | XX | GT | LT]
EXPIREAT <key> <unix-seconds> [NX | XX | GT | LT]
TTL <key>
PTTL <key>
PERSIST <key>
GETEX <key> [EX seconds | PX milliseconds | EXAT unix-seconds | PXAT unix-ms | PERSIST]
Option / Command Meaning Time Complexity
EX seconds Expire after N seconds from now O(1)
PX milliseconds Expire after N milliseconds from now O(1)
EXAT unix-seconds Expire at an absolute Unix timestamp (seconds) O(1)
PXAT unix-ms Expire at an absolute Unix timestamp (milliseconds) O(1)
KEEPTTL Keep the key’s existing TTL instead of clearing it O(1)
EXPIRE / PEXPIRE Attach or replace a TTL on an existing key O(1)
NX (on EXPIRE) Only set the TTL if the key has none
GT / LT (on EXPIRE) Only set the TTL if it’s greater/less than the current one
TTL / PTTL Read remaining time to live (seconds / ms) O(1)
PERSIST Remove a key’s TTL, making it permanent O(1)
GETEX Read a value and atomically set/clear its TTL in one round trip O(1)

TTL/PTTL return -1 if the key exists but has no expiration, and -2 if the key doesn’t exist at all (which also covers a key that has already expired).

Examples

Example 1: A session token with a relative TTL (EX)

The most common case: expire a key some number of seconds from now.

SET session:abc123 "active" EX 60
TTL session:abc123
GET session:abc123

Output:

OK
(integer) 60
"active"

SET ... EX 60 creates the key and, in the same atomic operation, registers a 60-second TTL. TTL confirms 60 seconds remain, and GET reads the value normally — expiration is invisible to reads until the clock actually runs out.

Example 2: A cached page with a millisecond TTL (PX)

PX is useful when you need sub-second precision, such as short-lived caches or fine-grained rate limiting.

SET cache:page:home "rendered_home_page" PX 5000
PTTL cache:page:home
TTL cache:page:home

Output:

OK
(integer) 5000
(integer) 5

PTTL reports the remaining time in milliseconds (5000), while TTL reports the same remaining time rounded to whole seconds (5). Internally Redis always stores the expiration as a millisecond timestamp regardless of whether you set it with EX or PXEX is just seconds multiplied by 1000 under the hood.

Example 3: Expiring at an absolute time (EXAT)

EXAT takes a Unix timestamp in seconds rather than a duration — useful when the expiration moment is a known deadline (a password reset link, a promotional discount code) rather than “N seconds from creation”.

SET user:1001:reset_token "9f8ac2" EXAT 1919600000
TTL user:1001:reset_token

Output:

OK
(integer) 133193600

The exact integer depends on the current time when you run it, since TTL always reports remaining seconds, but because 1919600000 is a timestamp several years in the future, the result is a large positive number. If you instead pass a timestamp already in the past, Redis will still return OK, but the key is deleted immediately (it’s created and instantly recognized as expired). PXAT works identically but takes milliseconds.

Example 4: Reading and refreshing a TTL atomically (GETEX)

GETEX (added in Redis 6.2) reads a value and changes its TTL in a single atomic round trip — useful for “sliding” expirations, like keeping a session alive only while it’s actively used, without a separate GET + EXPIRE pair.

SET api:key:xyz "sk_live_abc" EX 3600
GETEX api:key:xyz PERSIST
TTL api:key:xyz

Output:

OK
"sk_live_abc"
(integer) -1

Here GETEX ... PERSIST returns the value and strips the TTL in the same call, so the key becomes permanent. Passing EX/PX/EXAT/PXAT instead would reset the TTL to a new value while returning the current one.

How It Works Step by Step

When you run SET key value EX 60, Redis performs these steps as one atomic unit on the single command-processing thread:

  1. Look up (or create) key in the main keyspace dictionary and write value into it, discarding any previous value and, unless KEEPTTL was given, any previous TTL entry.
  2. Compute the absolute expiration time: current time in milliseconds plus 60 * 1000.
  3. Insert (or overwrite) that absolute timestamp in the expires dictionary, keyed by the same key name.
  4. Reply OK to the client.

On every subsequent read of that key, Redis first checks the expires dictionary before touching the value: if the stored timestamp is in the past, it deletes both entries and treats the key as absent (lazy expiration), regardless of whether the background active-expiration cycle has run yet.

Common Mistakes

Mistake 1: Assuming EXPIRE on a nonexistent key does something

EXPIRE product:9999:views 3600
TTL product:9999:views

Output:

(integer) 0
(integer) -2

EXPIRE returns 0 (not an error) when the target key doesn’t exist — there’s nothing to attach a TTL to. Always check the return value if the key’s existence isn’t guaranteed; silently ignoring a 0 here is a common source of “my TTL never worked” bugs.

Mistake 2: Forgetting that a plain SET clears the existing TTL

SET order:555:status "pending" EX 120
TTL order:555:status
SET order:555:status "shipped"
TTL order:555:status
SET order:555:status "shipped_v2" EX 120
TTL order:555:status
SET order:555:status "delivered" KEEPTTL
TTL order:555:status

Output:

OK
(integer) 120
OK
(integer) -1
OK
(integer) 120
OK
(integer) 120

The second SET (without any TTL option) silently wipes the 120-second expiration — TTL drops to -1, meaning “no expiration”, which for a cache entry means it now lives forever unless something else removes it. After re-establishing the TTL, the final SET ... KEEPTTL updates the value while leaving the countdown untouched. If your application updates values in place and expects the original expiration to survive, you almost always want KEEPTTL.

Mistake 3: Passing a non-numeric value to EXPIRE

EXPIRE session:abc123 "soon"

Output:

(error) ERR value is not an integer or out of range

EXPIRE and PEXPIRE require an integer number of seconds/milliseconds — there’s no relative syntax like “soon” or “1h”. Compute the integer in your application code before sending the command.

Best Practices

  • Set a TTL on every cache-style key at creation time (via SET ... EX) rather than as a separate follow-up EXPIRE call — it’s one round trip instead of two, and you can’t forget the second call under load or after a crash between the two commands.
  • Use KEEPTTL whenever you update a value in place and want the original expiration to keep counting down.
  • Prefer PX/PXAT over EX/EXAT only when you genuinely need sub-second precision (short locks, fine-grained rate windows); otherwise seconds are easier to reason about and log.
  • Use EXAT/PXAT for deadline-based expirations (a coupon valid until a known date) instead of computing “seconds from now” in application code every time — it removes clock-drift bugs between your app server and Redis.
  • Use the Redis 7 EXPIRE ... NX|XX|GT|LT flags when you need conditional TTL updates — for example EXPIRE key 300 GT only extends a TTL, never shortens one, which is exactly what a “keep this session alive for at least 5 more minutes” pattern needs.
  • Never rely on a key “probably” being gone by a certain time for correctness-critical logic; always check the actual return value of GET/EXISTS, since lazy expiration only triggers on access.
  • Avoid setting a TTL on keys that must never disappear (primary records, not caches) — use PERSIST to explicitly remove a TTL if one was mistakenly applied.

Practice Exercises

  1. Create a key promo:code:SUMMER26 with the value "10percent" that expires in exactly 30 minutes using seconds. Confirm the TTL, then use GETEX to extend it by another 30 minutes in a single command without a separate GET.
  2. Create a key with a short TTL (a few seconds), let it sit, then try GETting it after the TTL has elapsed. What does Redis return, and what happened internally to the expires dictionary entry when you ran that GET?
  3. Set a key with EX 100, then try to shorten it to 50 seconds using EXPIRE key 50 GT. Check the TTL afterward — did it change? Now try EXPIRE key 200 GT and check again. Explain the difference using the GT flag’s semantics.

Summary

  • SET key value EX seconds, PX ms, EXAT unix-secs, and PXAT unix-ms all attach a TTL in the same atomic call that writes the value.
  • A plain SET on an existing key clears any previous TTL — use KEEPTTL to preserve it.
  • TTL/PTTL return -1 for “no expiration” and -2 for “key doesn’t exist” (including already-expired keys).
  • Redis expires keys two ways: lazily on access (guarantees correctness) and actively via a background sampling cycle (reclaims memory for untouched keys).
  • EXPIRE‘s NX/XX/GT/LT flags (Redis 7.0+) let you set a TTL conditionally without reading it first.
  • GETEX reads a value and updates (or removes, with PERSIST) its TTL atomically in one round trip.
  • All expiration-related commands — SET, EXPIRE family, TTL/PTTL, PERSIST, GETEX — run in O(1) time, regardless of keyspace size.