Persisting Keys: PERSIST
In Redis, a key can be volatile (it has a time-to-live, or TTL, after which it disappears) or persistent (it lives forever, until you delete it yourself). PERSIST is the command that converts a volatile key into a persistent one: it strips the expiration timer off a key without touching its value, its type, or anything else about it. It is the direct counterpart to EXPIRE, and understanding it well means understanding exactly how Redis tracks expiration internally.
Overview / How it works
Every key you write with SET, HSET, LPUSH, and so on starts out with no expiration at all — it is persistent by default. You make a key volatile by attaching a TTL to it, either at write time (SET key value EX seconds) or afterward with EXPIRE, PEXPIRE, EXPIREAT, or PEXPIREAT. Internally, Redis does not store the TTL alongside the value in the main keyspace. Instead, each Redis database keeps a second, separate dictionary — often called the expires dictionary — that maps key names to the absolute Unix timestamp (in milliseconds) at which they should expire. The main keyspace dictionary only ever holds the key-to-value mapping.
When you run PERSIST key, Redis simply deletes that key’s entry from the expires dictionary, if one exists. The entry in the main keyspace dictionary — the actual value, whatever type it is (string, hash, list, set, sorted set, stream) — is completely untouched. This is why PERSIST is type-agnostic: it works identically on a string, a hash, or any other type, because it never looks at the value at all, only at the expiration table.
This separation also explains why expiration checking has two mechanisms in Redis. Passive (lazy) expiration happens when a client accesses a key: before returning any value, Redis checks the expires dictionary, and if the key’s time has passed, it deletes the key on the spot and behaves as if the key never existed. Active expiration happens in the background: Redis periodically samples random keys from the expires dictionary and proactively removes any that have passed their TTL, so that memory used by long-forgotten volatile keys doesn’t sit around forever waiting for someone to read it. Because Redis is single-threaded, both of these checks — and the deletion that follows — happen atomically with respect to every other command; nothing can interleave and observe a half-expired key. When you call PERSIST, you are removing the key from consideration by both of these mechanisms in one atomic, O(1) step.
PERSIST has no effect, and simply returns a value telling you so, in two situations: the key does not exist at all, or the key exists but never had a TTL in the first place (it is already persistent). In both cases nothing changes in the keyspace — there’s no error, just a return value of 0.
Syntax
PERSIST key
| Argument | Description |
|---|---|
key |
The name of the key whose TTL, if any, should be removed. |
PERSIST takes exactly one argument and returns nothing else — there is no option to persist “all keys matching a pattern” in a single call; you’d loop over keys found via SCAN and call PERSIST on each one individually.
| Return value | Meaning |
|---|---|
(integer) 1 |
The key existed and had a TTL, which was successfully removed. |
(integer) 0 |
The key does not exist, or it exists but had no TTL to begin with. |
Time complexity: O(1) — it’s a single hash table deletion in the expires dictionary, regardless of the size or type of the value stored at the key.
Examples
Example 1: Removing a TTL from a session key
SET session:abc123 "user:1001"
EXPIRE session:abc123 3600
TTL session:abc123
PERSIST session:abc123
TTL session:abc123
Output:
OK
(integer) 1
(integer) 3600
(integer) 1
(integer) -1
The key is created, given a one-hour TTL, and TTL confirms 3600 seconds remain. PERSIST returns 1, meaning it found and removed a TTL. The final TTL call now returns -1, which is Redis’s way of saying “this key exists but has no expiration set.” The value "user:1001" was never touched.
Example 2: Calling PERSIST on a key that already has no TTL
SET config:site_name "My App"
PERSIST config:site_name
TTL config:site_name
Output:
OK
(integer) 0
(integer) -1
Because config:site_name was created with a plain SET and never had EXPIRE applied to it, it was already persistent. PERSIST finds nothing to remove and returns 0 — this is not an error, just an accurate “no-op” signal. This is the same return value you’ll see if the key doesn’t exist at all, so PERSIST‘s return value alone can’t tell you which of those two cases you hit; use EXISTS first if you need to distinguish them.
Example 3: A realistic “remember me” session upgrade
A common real-world use of PERSIST is a login session that starts with a short TTL, but gets upgraded to permanent when the user checks “remember me.” The session is stored as a hash, and PERSIST works on it exactly the same way it worked on the plain string key above:
HSET session:xyz789 user_id "1042" login_ip "203.0.113.5"
EXPIRE session:xyz789 1800
TTL session:xyz789
PERSIST session:xyz789
TTL session:xyz789
HGETALL session:xyz789
Output:
(integer) 2
(integer) 1
(integer) 1800
(integer) 1
(integer) -1
1) "user_id"
2) "1042"
3) "login_ip"
4) "203.0.113.5"
The hash starts with a 30-minute TTL (typical for a short-lived login session). When the user opts to stay logged in, the application calls PERSIST session:xyz789 instead of re-issuing a new, longer EXPIRE, since the intent is “never expire this session automatically.” The final HGETALL proves the hash’s fields survived completely untouched — PERSIST only ever modifies the expires dictionary.
How it works step by step
When you send PERSIST key, Redis performs the following, all within its single-threaded event loop:
- It looks up
keyin the main keyspace dictionary. If not found, it returns0immediately. - If the key exists, it looks up
keyin the expires dictionary for that database. If no entry is found there, the key is already persistent, so it returns0. - If an entry is found in the expires dictionary, that entry is deleted. The key remains in the main keyspace dictionary, value and type unchanged.
- It returns
1to signal that a TTL was actively removed.
Because both dictionary lookups and the deletion are O(1) hash table operations, and because no other command can run concurrently on the same Redis instance, this whole sequence is atomic and fast regardless of how large the value at key is — persisting a 10-field hash costs exactly the same as persisting a tiny string.
Common Mistakes
Mistake 1: Using SET to “clear” a TTL, forgetting it also overwrites the value. A common assumption is that re-running SET is a safe way to remove expiration from a key. It does remove the TTL — but only because it replaces the entire key, including its value:
SET cart:5001 "3 items" EX 600
SET cart:5001 "3 items"
TTL cart:5001
Output:
OK
OK
(integer) -1
This “worked” here only because the second SET happened to write the same value back. In real code, if you don’t have the exact current value on hand, you’d overwrite it with something wrong just to strip the TTL. Use PERSIST cart:5001 instead — it removes the TTL without needing to know or restate the value. (Conversely, if you actually want to update the value while keeping the existing TTL, use SET key value KEEPTTL, not a bare SET, since a plain SET on an existing key always clears any TTL.)
Mistake 2: Assuming PERSIST errors on a missing key. Some developers wrap PERSIST in error handling expecting a Redis error reply when the key is absent. It never errors for a missing key — it just returns (integer) 0, the same as it does for an existing key with no TTL:
PERSIST report:2026:q3
Output:
(integer) 0
If your application logic needs to tell “key doesn’t exist” apart from “key exists but was already persistent,” check with EXISTS key first rather than relying on PERSIST‘s return value.
Mistake 3: Calling PERSIST with the wrong number of arguments. PERSIST takes exactly one key and nothing else — it does not accept a pattern, and it does not accept multiple keys in one call:
PERSIST
Output:
(error) ERR wrong number of arguments for 'persist' command
To persist several keys, call PERSIST once per key (typically after discovering them safely with SCAN, never with KEYS in production — KEYS * is O(N) and blocks the single-threaded server for the entire scan, while SCAN walks the keyspace incrementally without blocking).
Best Practices
- Prefer
PERSISTover re-SETting a key just to drop its TTL — it’s atomic, O(1), and can’t accidentally corrupt or overwrite the value. - If you want to update a value while preserving its existing TTL (the opposite goal from
PERSIST), useSET key value KEEPTTLrather than a bareSET. - Check
TTL keybefore and after callingPERSISTin scripts or during debugging — remember-1means “no TTL” and-2means “key doesn’t exist,” andPERSIST‘s own return value (0or1) can’t distinguish those two cases on its own. - When you need to persist many keys matching a pattern, iterate with
SCANand callPERSISTper key — never useKEYS patternin production, since it blocks the whole server while it runs. - Be deliberate about which keys should truly be permanent. A key that never expires and is never explicitly deleted will sit in memory forever; only call
PERSISTon data that genuinely needs to outlive any TTL, not as a quick fix to silence expiration-related bugs. - Document or comment (in your application code, not in Redis) why a given key was made persistent — six months later, “why doesn’t this session ever expire” is a hard question to answer without that context.
Practice Exercises
- Exercise 1: Create a key
promo:code:SUMMERwith the value"20OFF"and a 120-second TTL usingEXPIRE. Confirm the TTL withTTL, then decide the promo should never expire and use the correct single command to make that true. Confirm your result withTTLagain — it should read-1. - Exercise 2: Run
PERSISTagainst a key name you’re confident does not exist in a fresh database. Predict the return value before you run it, then verify you were right. - Exercise 3: Create a hash with
HSET inventory:sku42 qty "10", then attempt to distinguish, using onlyEXISTS,TTL, andPERSIST, between a key that never had a TTL and a key that had one removed. Write out the sequence of commands and expected replies for both scenarios.
Summary
PERSIST keyremoves any TTL fromkey, turning a volatile key into a permanent one, without touching its value or type.- It returns
(integer) 1if a TTL was actually removed, and(integer) 0if the key had no TTL or didn’t exist — never an error for either case. - Internally, TTLs live in a separate expires dictionary from the main keyspace;
PERSISTis just an O(1) deletion from that dictionary. - Use
PERSISTinstead of re-runningSETto clear a TTL — a bareSETalso clears TTLs, but only by replacing the whole value, which is risky if you don’t have the current value on hand. - Use
SET key value KEEPTTLwhen you want the opposite: update a value but keep its existing expiration. TTLreturns-1for “no expiration” and-2for “key does not exist” — know the difference when interpreting results aroundPERSIST.
