skip to content

The Redis SET command accepts NX, XX, EX/PX, KEEPTTL and GET options. What does each of them do, and why is `SET key value NX EX 30` preferred over calling SETNX and then EXPIRE?

level: juniorimportance: must knowfreq 72%

answer

  1. NX = create-only, XX = update-only, never both
  2. plain SET wipes the TTL — KEEPTTL keeps it
  3. EX/PX relative, EXAT/PXAT absolute
  4. SETNX+EXPIRE = immortal key on client crash
  5. SET ... GET replaces GETSET; GETDEL/GETEX are 6.2

basics

~20 s

NX writes only if the key is absent, XX only if it already exists, EX/PX attach a TTL in seconds/milliseconds, KEEPTTL keeps the current TTL, GET returns the previous value. A single SET is one atomic command; SETNX then EXPIRE can leave a key with no TTL if the client dies between them.

solid answer

~50 s

`SET` overwrites unconditionally by default and **discards any existing TTL**. The options change that: - **NX** — set only if the key does not exist; **XX** — only if it does. They are mutually exclusive. - **EX s / PX ms** (and **EXAT / PXAT** for absolute unix times) — set value and expiry in one step. - **KEEPTTL** — write the new value but retain the remaining TTL. - **GET** — return the old value (or nil) alongside the write, replacing GETSET. When the condition fails, SET returns nil and writes nothing. `SET k v NX EX 30` is a single command, so it is atomic on Redis's execution loop: either the key is created *with* its TTL or nothing happens. `SETNX` + `EXPIRE` are two round trips; a crash, timeout or failover between them leaves an immortal key — the classic "my lock never expired" bug. The same reasoning is why blindly re-SETting a cached value without KEEPTTL silently makes it permanent.

code

text · 16 lines
text
> SET job:42 running NX EX 30
OK
> SET job:42 running NX EX 30      # already there → no write
(nil)
> TTL job:42
(integer) 27
> SET job:42 finished             # value replaced AND ttl dropped
OK
> TTL job:42
(integer) -1                       # -1 = no expiry, -2 = no key
> SET job:42 finished KEEPTTL      # would have preserved it
OK
> SET job:42 archived GET          # write + return previous value
"finished"
> GETEX job:42 EX 60               # read and (re)arm the TTL
"archived"

go deeper

for a junior

Be able to state what NX, XX, EX/PX and KEEPTTL each do and that a plain SET wipes the TTL.

for a middle

Explain the atomicity argument: one command executes to completion, so SETNX+EXPIRE has a crash window that SET NX EX does not.

for a senior

Connect it to real failure modes — immortal lock keys, caches that stop expiring after a refresh path was added — and to the modern GETEX/GETDEL/SET GET replacements for legacy commands.

for a principal

Frame it as an API-design rule: prefer server-side atomic primitives over client-side command sequences, and treat any multi-command read-modify-write as requiring a script or transaction by policy.

## The string is the default type A Redis string is just a binary-safe byte array up to 512 MB — text, JSON, a serialized object or raw bytes. `SET key value` stores it, `GET key` reads it back. Two behaviours of the bare form surprise people: it **overwrites any existing value regardless of type**, and it **removes any TTL the key had**. Everything below is about controlling those two behaviours in a single atomic command. ## The option matrix **Existence conditions.** `NX` ("not exists") performs the write only when the key is absent; `XX` performs it only when the key already exists. They cannot be combined — Redis replies with a syntax error. When the condition is not met, the command performs no write and replies with a nil, which is how you distinguish "I created it" from "someone else already had it". **Expiry.** `EX seconds` and `PX milliseconds` set a relative TTL; `EXAT` / `PXAT` set an absolute unix timestamp (seconds / milliseconds). Without any of them the key is written with no expiry. `KEEPTTL` means "replace the value but leave the existing expiry ticking" — useful when you refresh a cached payload and want the original deadline honoured rather than extended. **Reading while writing.** The `GET` option makes SET return the previous value (nil if there was none) while still applying the write, so a read-and-replace is one round trip. This supersedes the legacy `GETSET`. Its siblings are `GETDEL` (read and remove atomically) and `GETEX` (read and, in the same command, set/extend a TTL or `PERSIST` it away). ## Why one command beats two Redis executes each command to completion before starting the next, so any single command is atomic with respect to other clients. Two commands are not: another client's commands can interleave, and — more dangerously — **your own process may never issue the second one**. The failure sequence with `SETNX lock 1` followed by `EXPIRE lock 30` is: SETNX succeeds, the client crashes / the TCP connection drops / a GC pause outlives the socket timeout, and the key now exists forever with no expiry. Every subsequent attempt to acquire fails, and nothing ever cleans it up. `SET lock token NX EX 30` cannot land in that state: the key is created with the expiry or not created at all. The same atomicity argument applies to `GET` + `SET` used as read-modify-write: the read and write are separate, so two clients can both read the old value and both write. For that you either use a purpose-built atomic command (INCR family, SET with GET) or a WATCH/Lua construct — never a client-side round trip pair. ## The TTL-discard trap Because plain SET clears the TTL, a cache-refresh path written as `SET user:42 <fresh json>` turns an entry that used to expire into a permanent one. On a small key this is invisible; across millions of keys it is an unbounded memory growth that only stops when the eviction policy starts throwing things out (or the instance hits its limit and starts erroring). Either re-state the TTL (`EX 300`) or use `KEEPTTL` deliberately. Conversely, commands that only *modify* a value — INCR, APPEND, SETRANGE, GETSET's replacement `SET ... GET` — differ here: INCR and APPEND leave the TTL alone, while a full SET resets it. Knowing which commands preserve expiry is the practical takeaway. ## Return values worth memorising - `SET` without conditions → simple string `OK`. - `SET ... NX` / `XX` that did not fire → nil. - `SET ... GET` → old value or nil, whether or not the conditional write fired. - `SETNX` → 1 or 0 (integer), which is why old code reads awkwardly next to modern SET. ## Practical guidance Treat `SETNX`, `SETEX`, `PSETEX` and `GETSET` as legacy spellings kept for compatibility; write everything as one `SET` with options. Reach for `KEEPTTL` when refreshing content under a fixed deadline, for `EXAT` when the deadline comes from business time rather than from now, and for `SET ... GET` when you need the displaced value (for example to detect who held a slot before you). If your logic genuinely needs several keys changed together, that is a Lua script or a transaction — not a sequence of SETs.

  • What happens to an existing TTL when you SET the same key again without KEEPTTL?
    The TTL is discarded and the key becomes persistent. Redis treats a full SET as creating a brand-new value, so any expiry set earlier is dropped. This is a common cause of caches that quietly stop expiring; either restate EX on every write or pass KEEPTTL. Note that INCR, APPEND and SETRANGE do not clear the TTL — only a full SET does.
  • How do you atomically read a value and extend its lifetime in one round trip?
    Use GETEX: `GETEX session:abc EX 900` returns the value and re-arms the TTL to 900 seconds in a single command. `GETEX key PERSIST` removes the expiry instead, and `GETDEL key` reads and deletes atomically. Before Redis 6.2 you needed GET followed by EXPIRE, which is two commands and therefore not atomic.
  • How would you tell whether your SET with NX actually performed the write?
    By the reply: a conditional SET that did not fire returns nil, while a successful one returns OK. Do not re-read the key to check — between your write and your read another client may have changed it, so the reply is the only trustworthy signal. If you also need the value that was there before, add the GET option.

saying these in an interview costs you the question

  • Assuming SET preserves the key's existing TTL
  • Claiming SETNX followed by EXPIRE is safe because the two commands are fast
  • Combining NX and XX in one SET and expecting 'either' semantics
  • Reading EX as milliseconds and PX as seconds
  • Thinking a failed conditional SET raises an error rather than returning nil

context