What Is Redis TTL? Key Expiration Explained
TTL (time to live) in Redis is an expiration timer attached to a key. Once the timer runs out, Redis deletes the key automatically — no application code, no cleanup job. TTL is what turns Redis from a data store into a cache, and it underpins session expiry, rate limiting, lock leases, and one-time tokens.
Below we cover how to set a TTL, how to read one back, what the return values -1 and -2 actually mean, how Redis removes expired keys under the hood, and how to work with TTLs from Java.
What Is TTL in Redis?
TTL is a property of the key, not of the value stored inside it. Every key in Redis is in one of two states:
- Persistent — no TTL. The key lives until something explicitly deletes it or the server evicts it under memory pressure.
- Volatile — a TTL is set. Redis removes the key once the deadline passes.
Internally Redis does not store a countdown. It stores an absolute Unix timestamp in milliseconds and compares it against the clock. This matters in practice: a TTL does not pause when the server is idle, and it survives restarts, because timestamps are written into both RDB snapshots and the AOF log. A key with 30 seconds left before a restart still expires 30 seconds later, not 30 seconds after the server comes back up.
TTL is also independent of the data type. Strings, hashes, lists, sets, sorted sets, and streams all expire the same way, through the same commands.
How Do You Set a TTL in Redis?
There are two approaches: attach the TTL when you write the value, or apply it to a key that already exists.
Setting a TTL at write time is the safer option, because it is a single atomic operation:
SET session:8a41 "{...}" EX 3600 # expires in 3600 seconds
SET session:8a41 "{...}" PX 500 # expires in 500 milliseconds
SET session:8a41 "{...}" EXAT 1789000000 # expires at a Unix timestamp (seconds)
Setting a TTL on an existing key uses the EXPIRE family:
| Command | Argument | Effect |
|---|---|---|
EXPIRE key seconds | Relative, seconds | Expire after N seconds |
PEXPIRE key milliseconds | Relative, milliseconds | Expire after N milliseconds |
EXPIREAT key timestamp | Absolute, seconds | Expire at a Unix timestamp |
PEXPIREAT key timestamp | Absolute, milliseconds | Expire at a Unix timestamp |
Since Redis 7.0, all four accept a condition flag — NX (only if no TTL is set), XX (only if one already is), GT (only if the new deadline is later), and LT (only if it is earlier):
EXPIRE lock:orders 30 XX # extend an existing lease, never create one
EXPIRE cache:report 600 GT # push the deadline out, never pull it in
GT is the correct primitive for a sliding expiration that must never accidentally shorten a key's life, and XX is what you want when renewing a distributed lock — creating a TTL on a lock you no longer hold would be a bug.
Two edge cases worth knowing. Passing a negative TTL to EXPIRE, or a timestamp already in the past to EXPIREAT, deletes the key immediately rather than raising an error. And there is no practical ceiling on TTL length — Redis stores an absolute millisecond timestamp, so any realistic retention window fits comfortably — though absurdly large values are rejected with an "invalid expire time" error once the resulting timestamp would overflow. SETEX and PSETEX still work but are effectively superseded by SET ... EX, which does the same job with more options.
How Do You Check the TTL of a Key?
Use TTL for seconds and PTTL for milliseconds:
TTL session:8a41
(integer) 3540
PTTL session:8a41
(integer) 3540127
Both return a negative value when there is no timer to report, and the two negatives mean very different things:
| Return value | Meaning |
|---|---|
| Positive integer | Seconds (TTL) or milliseconds (PTTL) remaining |
-1 | The key exists, but has no expiration set |
-2 | The key does not exist — it expired, was deleted, or was never written |
This distinction is the single most common source of confusion around Redis TTL. -1 is not an error and does not mean "expired"; it means the key is persistent and will never expire on its own. If you are debugging a cache that never seems to clear, -1 is the answer: the TTL was never applied, or something stripped it.
Redis 7.0 added EXPIRETIME and PEXPIRETIME, which return the absolute expiration timestamp rather than the remaining time — useful for logging and for reasoning about clock skew across nodes.
Redis TTL vs EXPIRE. These are frequently confused, but they are not alternatives — they are opposite halves of the same feature. EXPIRE writes a deadline onto a key; TTL reads the deadline back. EXPIRE returns 1 or 0 depending on whether it managed to set the timer, while TTL returns the time remaining. You use EXPIRE to create the expiration and TTL to inspect it — there is no situation where you would choose one instead of the other. "TTL" is also used loosely as the name of the concept itself, which is where much of the confusion comes from.
Does Redis Have a Default TTL?
No. Redis has no default TTL and no global expiration setting. Every key you write is persistent unless you explicitly give it a TTL. There is no configuration directive that changes this.
Two things are sometimes mistaken for a default. The first is maxmemory-policy, which controls what Redis evicts when it runs out of memory — that is a memory-pressure response, not an expiration policy. The second is a framework default: Spring Cache, Hibernate's second-level cache, and similar layers often apply their own TTL to every entry they write, which can make it look as though Redis is doing it. It is not.
The practical consequence is that a cache built without explicit TTLs will grow until it hits maxmemory, at which point behavior depends entirely on your eviction policy — and if that policy is noeviction, writes start failing.
How Does Redis Actually Expire Keys?
Redis uses two mechanisms together, and neither guarantees deletion at the exact moment a TTL lapses.
Lazy expiration. When a key is accessed, Redis checks its timestamp first. If the deadline has passed, the key is deleted and the command behaves as though it never existed. This is precise from the client's point of view — you can never read an expired value — but it only triggers on access.
Active expiration. A background cycle runs roughly ten times per second, samples a batch of keys from the set that have TTLs, and deletes the expired ones. If more than a quarter of the sample turns out to be expired, it immediately repeats, which lets Redis catch up when many keys expire at once without blocking the main thread for long.
The consequence is that a key that expired but was never read may occupy memory for a short window before the background cycle reaches it. DBSIZE and memory metrics can therefore lag slightly behind logical state. Reads are always correct; accounting is eventually correct.
Replicas behave differently again: a replica never expires a key on its own. It waits for the DEL that the primary replicates, which keeps the dataset consistent. To avoid returning stale data in the meantime, replicas treat logically expired keys as missing when serving reads.
If you need to react to expiration, enable keyspace notifications (notify-keyspace-events including Ex) and subscribe to the expired event. Be aware that the event fires when the key is actually removed — which, for a key nobody reads, means whenever the active cycle gets to it, not the instant it lapsed.
How Do You Remove or Preserve a TTL?
PERSIST strips the TTL from a key and makes it persistent again, returning 1 if a timer was removed and 0 if there was none.
The subtler issue is TTLs disappearing when you did not intend it. A plain SET on an existing key discards its TTL, because SET replaces the key wholesale. This is a classic cause of caches that stop expiring after the first refresh.
Commands split into two groups:
-
Clear the TTL:
SET(without flags),GETSET -
Preserve the TTL:
INCR,DECR,APPEND,LPUSH,RPUSH,SADD,HSET,ZADD— any command that modifies a value in place rather than replacing the key
One qualification on that second group: it describes the key-level TTL. If you are also using per-field hash TTLs (see below), HSET and HDEL do clear the expiration on the specific fields they overwrite or remove, even though the hash key's own TTL is untouched.
Three tools solve the problem directly:
SET cache:report "{...}" KEEPTTL # rewrite the value, keep the deadline (Redis 6.0+)
GETEX session:8a41 EX 3600 # read and refresh in one call (Redis 6.2+)
GETEX session:8a41 PERSIST # read and clear the TTL (Redis 6.2+)
GETEX is the idiomatic way to implement a sliding session: read the value and push the deadline out atomically, with no read-then-write race.
One more inheritance rule: RENAME carries the source key's TTL to the destination, overwriting whatever TTL the destination had.
Can You Set a TTL on Individual Hash Fields?
Historically, no — TTL applied only to whole keys. Expiring one field of a hash meant restructuring the data so each field became its own key, which gave up the memory efficiency that made hashes attractive in the first place.
Redis 7.4 added field-level expiration for hashes: HEXPIRE, HPEXPIRE, HEXPIREAT, HPEXPIREAT, HTTL, HPTTL, and HPERSIST.
HSET user:42 name "Ada" token "xyz"
HEXPIRE user:42 300 FIELDS 1 token # expire only the token field
HTTL user:42 FIELDS 1 token
Redis 8.0 built on this with HGETEX, HSETEX, and HGETDEL — the hash equivalents of GETEX, SET ... EX, and GETDEL, letting you read or write a field and set its expiration in one call.
Two rules catch people out. Field TTLs are cleared by any command that overwrites or deletes the field, HSET and HDEL included, so use the NX flag if you need to avoid resetting an expiration you already set. And the key-level TTL always wins: if the hash key itself expires in ten minutes, a field with an hour-long TTL still disappears in ten minutes along with the rest of the key.
Sets, lists, and sorted sets still have no native per-element TTL. The usual workaround for sorted sets is to store a timestamp as the score and periodically trim with ZREMRANGEBYSCORE — effective, but it is manual bookkeeping rather than expiration.
Redis TTL vs Eviction Policies
TTL and eviction answer different questions, and conflating them causes real production incidents.
TTL is a per-key contract about time. The key expires when its deadline passes, whether memory is plentiful or scarce.
Eviction is a global response to memory pressure. When Redis hits maxmemory, it starts removing keys according to maxmemory-policy, regardless of their TTLs:
| Policy | Behavior |
|---|---|
noeviction | Reject writes with an error (default) |
allkeys-lru / allkeys-lfu | Evict least recently / frequently used, from all keys |
allkeys-random | Evict at random, from all keys |
volatile-lru / volatile-lfu | Same, but only among keys that have a TTL |
volatile-ttl | Evict the keys closest to expiring |
volatile-random | Evict at random, among keys with a TTL |
The critical point: a TTL does not bound memory. If you write faster than keys expire, you will reach maxmemory regardless. TTL controls freshness; maxmemory-policy controls what happens when you run out of room. A cache needs both.
The volatile-* policies have a trap worth calling out — if no keys have a TTL, they have nothing eligible to evict and behave like noeviction, so writes begin failing while memory sits full of persistent keys.
Common TTL Patterns and Pitfalls
Add jitter. Writing a thousand cache entries with an identical 3600-second TTL means a thousand simultaneous expirations and a thundering herd against your database. Randomize: 3600 + rand(0, 300).
Sliding expiration. Refresh on read with GETEX key EX n, or EXPIRE key n GT when you need the deadline to move only forward.
Lock leases. A distributed lock must always carry a TTL, otherwise a crashed client holds it forever. Renew with XX so you never resurrect a lock you have already lost.
Rate limiting. INCR then EXPIRE on first increment gives you a fixed window. INCR preserves the TTL, so the counter resets cleanly when the window lapses.
Don't use KEYS to find expiring keys. It blocks the server. Use SCAN, or track keys in a sorted set scored by expiry time.
Redis TTL in Java With Redisson
Working with TTLs through a low-level Redis client means hand-assembling commands and remembering which ones clear an expiration. Redisson exposes expiration as part of its distributed object API instead, so a TTL on a map works the same way as a TTL on a lock or a queue.
Every Redisson object implements RExpirable, which gives a single, uniform set of methods across all types:
RBucket<String> bucket = redisson.getBucket("session:8a41");
// SET key value PX — atomic write with expiration
bucket.set(sessionJson, Duration.ofHours(1));
// SET key value KEEPTTL — replace the value, keep the deadline
bucket.setAndKeepTTL(updatedJson);
// GETEX — read and slide the expiration in one round trip
String session = bucket.getAndExpire(Duration.ofHours(1));
// TTL introspection — returns milliseconds, with the same -1 / -2 semantics
long remaining = bucket.remainTimeToLive();
The Redis 7.0 conditional flags are first-class methods rather than string arguments, which makes lock renewal and sliding expiration hard to get wrong:
RMap<String, String> map = redisson.getMap("report:2026-07");
map.expire(Duration.ofMinutes(30));
map.expireIfGreater(Duration.ofHours(2)); // EXPIRE ... GT
map.expireIfSet(Duration.ofMinutes(45)); // EXPIRE ... XX
map.clearExpire(); // PERSIST
Where Redisson goes beyond the Redis command set is per-entry expiration. RMapCacheNative maps directly onto the 7.4 hash-field commands:
RMapCacheNative<String, Session> cache = redisson.getMapCacheNative("sessions");
cache.put("8a41", session, Duration.ofMinutes(30));
cache.expireEntryIfGreater("8a41", Duration.ofHours(1));
long ttl = cache.remainTimeToLive("8a41");
On Redis versions before 7.4, RMapCache provides the same per-entry model through Lua scripts, and adds max idle time — expiring an entry after a period without access — which has no equivalent in the native commands:
RMapCache<String, Session> cache = redisson.getMapCache("sessions");
// 30 minute TTL, but also expire after 10 minutes of inactivity
cache.put("8a41", session, 30, TimeUnit.MINUTES, 10, TimeUnit.MINUTES);
cache.setMaxSize(10_000); // bound the cache, LRU eviction
RSetCache extends the same idea to sets, giving each element its own TTL — something Redis still has no native command for:
RSetCache<String> tokens = redisson.getSetCache("active-tokens");
tokens.addIfAbsent(Duration.ofMinutes(15), "tok-91f3");
One caveat worth stating plainly: RMapCache and RSetCache are Lua-backed, so expired entries are filtered on read and removed by a background eviction task rather than by Redis itself. That adds overhead relative to a plain RMap or RSet. If you only need whole-key expiration, use the simpler type and call expire() on it. On Redis 7.4 and later, prefer RMapCacheNative, which delegates to the server.
Redisson also surfaces expiration events without manual pub/sub wiring, via ExpiredObjectListener (which requires notify-keyspace-events to include Ex), and applies the same TTL and max-idle model to its near-cache, Spring Cache, Hibernate, and JCache integrations. Redisson PRO adds data partitioning across cluster shards and performs read/write operations up to 4x faster.