GETSET and Conditional Set (NX, XX)

Sometimes you don’t just want to write a value — you want to write it and read back whatever was there before, in one uninterruptible step. Other times you want a write to happen only if a key is missing (so you don’t clobber existing data) or only if it already exists (so you don’t accidentally create something new). Redis solves both problems with GETSET and the NX/XX flags on SET — small tools that quietly prevent some of the nastiest race conditions in distributed systems.

Overview / How it works

GETSET key value atomically sets key to value and returns the value that was stored there immediately beforehand (or (nil) if the key didn’t exist). Because every single Redis command runs to completion on Redis’s one execution thread before the next command starts, no other client can sneak a write in between the "read the old value" and "write the new value" steps — the whole operation is indivisible. This is the same reason INCR is safe for concurrent counters: single-threaded command execution turns what would be a two-step race in most languages into one atomic step in Redis.

As of Redis 6.2, the general-purpose SET command grew a GET option that does everything GETSET does (return the old value) while also letting you combine it with expiration flags and, since Redis 7.0, with NX/XX. Because of this, GETSET is considered a legacy command — it still works, is still fully supported, and you’ll still see it in existing codebases, but new code is usually better served by SET key value GET.

NX and XX are conditional flags on SET: NX means "only perform this SET if the key does not already exist", and XX means "only perform this SET if the key already exists". If the condition isn’t met, SET does nothing and replies with (nil) — not an error. Internally, the check and the write happen inside the same atomic command: Redis looks up the key in its main hash table, decides whether the condition passes, and only then creates or overwrites the value — all before any other client’s command can run. That’s exactly what makes SET ... NX the standard building block for simple distributed locks and idempotent "create if absent" initialization, and it’s why naive check-then-set code written with separate EXISTS and SET calls is unsafe in concurrent environments.

One detail that trips people up constantly: a plain SET — and by extension GETSET, which behaves like a SET internally — clears any TTL that was previously set on the key, unless you explicitly add KEEPTTL. If you swap a session token with GETSET expecting the original expiration to still apply, it won’t; the key becomes persistent.

Syntax

GETSET key value

SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | KEEPTTL]
Part Meaning
key The key to read/write. Must currently hold a string (or not exist) — using these commands on a list, hash, set, or other type raises WRONGTYPE.
value The new string value to store.
NX Only set if key does not exist. Replies (nil) and does nothing if it already exists.
XX Only set if key already exists. Replies (nil) and does nothing if it’s missing.
GET (Redis 6.2+, combinable with NX/XX since 7.0) Return the previous value instead of OK, whether or not the condition let the write happen.
EX / PX Attach a TTL in seconds/milliseconds as part of the same atomic command.
KEEPTTL Preserve any existing TTL on the key instead of clearing it.

Examples

Example 1: Basic GETSET — swap a status and see what it was.

SET session:1001:status "active"
GETSET session:1001:status "expired"
GET session:1001:status
OK
"active"
"expired"

The first command creates the key. GETSET then returns "active" — the value that was there a moment ago — while simultaneously overwriting it with "expired". The final GET confirms the new value stuck. No other command could have observed session:1001:status between the read and the write.

Example 2: SET NX as a simple distributed lock.

SET lock:order:5001 "worker-a" NX
SET lock:order:5001 "worker-b" NX
GET lock:order:5001
OK
(nil)
"worker-a"

worker-a acquires the lock because the key didn’t exist yet, so its SET ... NX succeeds and returns OK. When worker-b tries the same command, the key already exists, so the condition fails, nothing is written, and the reply is (nil)worker-b knows immediately it did not get the lock, without needing a separate EXISTS check.

Example 3: SET XX to update a feature flag only if it’s already configured.

SET config:feature:dark_mode "on" XX
SET config:feature:dark_mode "off"
SET config:feature:dark_mode "on" XX
GET config:feature:dark_mode
(nil)
OK
OK
"on"

The first attempt fails (returns (nil)) because config:feature:dark_mode doesn’t exist yet, so nothing is created. Once the key is initialized with a plain SET, the second XX attempt succeeds because the key now exists. This pattern is handy when you want writes to only ever update pre-provisioned configuration, never accidentally create new keys from a typo.

Example 4: Atomic swap-with-condition using SET … NX GET (Redis 7+).

SET session:3003:status "pending"
SET session:3003:status "active" NX GET
GET session:3003:status
OK
"pending"
"pending"

Because session:3003:status already holds "pending", the NX condition fails and the write is skipped — but the GET flag still returns the current value, "pending". The final GET proves the value was untouched. This combo lets a client discover the current value and whether its conditional write happened, in a single round trip.

How it works step by step

For SET key value NX (and equivalently XX), Redis performs these steps atomically within one command execution:

  • Parse the options (NX/XX/GET/expiration flags) from the command.
  • Look up key in the database’s main hash table.
  • If NX was given and the key exists, abort the write; if GET was also given, reply with the existing value, otherwise reply (nil).
  • If XX was given and the key does not exist, abort the write; reply (nil) (or the old value is undefined since there is none).
  • Otherwise, create or overwrite the string object for key. Unless KEEPTTL was passed, any existing expiration is removed.
  • Apply any EX/PX expiration passed in the same command.
  • Send the reply (OK, the old value, or (nil)) back to the client.

Because no other command can execute on the single command-processing thread partway through these steps, there is no window in which a second client’s command can observe stale state or interleave its own write.

Common Mistakes

Mistake 1: check-then-set instead of an atomic conditional command.

EXISTS inventory:sku100
SET inventory:sku100 "50"
(integer) 0
OK

This looks harmless run alone, but under concurrency it’s dangerous: two clients can both run EXISTS and both see 0, then both run SET, with the second silently overwriting the first’s data — exactly the race NX exists to prevent. Replace the pair with one atomic command:

SET inventory:sku100 "50" NX
OK

Mistake 2: assuming GETSET (or a plain SET) preserves the key’s TTL.

SET session:2002:token "abc123" EX 60
TTL session:2002:token
GETSET session:2002:token "def456"
TTL session:2002:token
OK
(integer) 60
"abc123"
(integer) -1

The token had a 60-second TTL, but GETSET behaves like a plain SET and clears it — the final TTL reports -1 (no expiration at all). If you need to swap a value without disturbing its expiration, use SET key value KEEPTTL (or SET key value GET KEEPTTL if you also need the old value back) instead of GETSET.

Mistake 3: using GETSET or SET on a key that holds a non-string type. Every Redis key has exactly one type. Calling a string command on a key created with RPUSH, SADD, HSET, etc. raises WRONGTYPE Operation against a key holding the wrong kind of value:

RPUSH queue:tasks "task1"
GETSET queue:tasks "not-a-string"
(integer) 1
(error) WRONGTYPE Operation against a key holding the wrong kind of value

Mistake 4: treating a failed NX/XX condition as an error. When the condition isn’t met, SET returns (nil), not an error reply. Code that only checks for exceptions/errors and ignores (nil) will silently believe a lock was acquired or a flag was updated when it wasn’t — always branch explicitly on a (nil) reply from a conditional SET.

Best Practices

  • Prefer SET key value NX over the legacy standalone SETNX and over any GET-then-SET pair whenever a write should only happen if the key is absent.
  • When using SET ... NX as a lock, always attach an expiration in the same command (SET lock:x owner NX EX 30) so a crashed holder doesn’t leave the lock stuck forever.
  • Reach for the modern SET key value GET (optionally with NX/XX/KEEPTTL) over legacy GETSET when you also need TTL control or a conditional guard — GETSET alone can’t express either.
  • Use KEEPTTL whenever you’re updating a value but the expiration policy on that key shouldn’t reset.
  • Always check for a (nil) reply from a conditional SET in your application code — it’s a meaningful "the condition wasn’t met", not an absence of data.
  • Don’t rely on SET ... NX alone for high-stakes distributed locking across multiple Redis nodes; it’s solid for single-instance, low-stakes coordination, but robust multi-node locking needs additional safeguards beyond the scope of a single command.

Practice Exercises

  • Model a "first request wins" idempotency key: a webhook handler should process a given webhook:<id> event only once. Write the single command that records the event as processed only if it hasn’t been seen before, and figure out how you’d detect a duplicate delivery from the reply.
  • You’re rotating an API key stored at apikey:acct:77 which currently has a 3600-second TTL you must not disturb. Write the command sequence that swaps in a new key value, keeps the existing TTL intact, and also returns the old key value so you can log it.
  • Simulate two workers racing to claim a job at job:queue:42: issue the same conditional acquire command twice in a row and predict which reply each attempt gets and why, before checking with GET.

Summary

  • GETSET key value atomically overwrites a string and returns its previous value in one round trip; it’s legacy but still fully supported.
  • SET key value GET (Redis 6.2+) does the same job as GETSET and can also combine with NX/XX/EX/PX/KEEPTTL.
  • NX only writes if the key is absent; XX only writes if the key already exists; an unmet condition replies (nil), not an error.
  • Conditional SET is atomic end-to-end, eliminating the check-then-act race that plain EXISTS-then-SET code suffers from under concurrency.
  • Both GETSET and plain SET clear any existing TTL unless you add KEEPTTL.
  • GETSET/SET raise WRONGTYPE if the key holds a non-string type.
  • Both commands run in O(1) time regardless of the value’s length or which flags are combined.