Why Redis Commands Are Atomic
In Redis, every single command you run — a SET, an INCR, an LPUSH — either fully executes or doesn’t run at all; there’s no in-between state where it’s half-applied. This property, called atomicity, is one of the main reasons developers reach for Redis to build counters, rate limiters, and distributed locks without wrapping every operation in application-level locking code. Understanding exactly what Redis does and doesn’t guarantee here matters, because atomicity applies to individual commands, not to sequences of commands you happen to send one after another — and that distinction is where most concurrency bugs in Redis-backed applications actually come from.
Overview: How Redis Achieves Atomicity
Redis’s command execution engine is single-threaded: one main thread reads a command off the socket, executes it completely — including any internal work over list elements, hash fields, or set members — and only then writes the reply and moves on to the next command, possibly from a completely different client. Because there is only one thread doing this work, it is physically impossible for two commands to interleave partway through: command B cannot start running while command A is still in the middle of updating a data structure. That’s the entire mechanism behind Redis atomicity — not a lock, not a transaction log, just the fact that only one thing is ever happening on the server at any given instant.
This has two important consequences. First, every individual command — no matter how much internal work it does — is atomic. MSET setting five keys at once, SINTERSTORE computing the intersection of three sets and storing the result, ZADD adding ten members to a sorted set: each of these is a single command from the server’s point of view, so it either completes entirely before any other client’s command runs, or it doesn’t take effect at all. No other client can ever observe a partially-updated key.
Second, and just as important: atomicity stops at the command boundary. If your application does a GET, computes something in its own code, and then does a SET, those are two separate commands with an arbitrarily large gap between them (a network round trip, application logic, a garbage-collection pause) during which another client can run its own GET/SET pair against the same key. Each of those four commands is individually atomic, but the four-command sequence as a whole is not — this is the classic “lost update” race condition, and it’s the single most common atomicity mistake developers make with Redis.
Redis 7’s data structures are built so the operations you actually need day to day (increment, append, push, add-if-absent) are exposed as single atomic commands specifically so you rarely need to fall back to read-modify-write in your own code. When one command genuinely can’t express what you need, Redis offers two escape hatches that extend the same single-threaded guarantee across multiple commands: MULTI/EXEC transactions, which queue a block of commands and run them back-to-back with no other client’s commands interleaved, and Lua scripting via EVAL, which runs an entire script as one atomic unit. Both are covered in depth elsewhere in this section — this lesson focuses on the atomicity of individual commands, since understanding that is the prerequisite for knowing when you even need a transaction or a script.
One caveat worth internalizing early: atomic does not mean fast, and it does not mean “safe at any scale.” A command like KEYS * or an unbounded SORT is atomic in the sense that no other command interleaves with it, but because it can take a long time to run, it blocks every other client on the server for its entire duration — atomicity and blocking are two sides of the same single-threaded coin. In a Redis Cluster, atomicity also only applies within a single node’s hash slot: a command touching keys that hash to different slots is rejected outright with a CROSSSLOT error, because there’s no way to guarantee atomicity across independent nodes.
Syntax
There’s no single “atomicity command” — atomicity is a property every Redis command has by default. The table below lists the commands used in this lesson’s examples along with their time complexity, since knowing the complexity matters for a command that’s about to hold up every other client on the server:
| Command | Purpose | Time Complexity |
|---|---|---|
SET key value [NX|XX] [EX sec|PX ms] [GET] [KEEPTTL] |
Set a string value, optionally only if absent/present, with an optional expiry, atomically in one call | O(1) |
GET key |
Read a string value | O(1) |
INCR key |
Atomically increment an integer value by 1 | O(1) |
DECRBY key n |
Atomically decrement an integer value by n | O(1) |
EXPIRE key seconds |
Set a TTL on an existing key | O(1) |
TTL key |
Read remaining seconds on a key’s TTL | O(1) |
MULTI / EXEC |
Queue a block of commands and run them as one atomic unit | O(1) to queue; total cost is the sum of the queued commands |
The general form of the command most central to this lesson, SET, looks like this:
SET key value [NX | XX] [EX seconds | PX milliseconds] [GET] [KEEPTTL]
key/value— the key to write and the string to store.NX— only set the key if it does not already exist (an atomic “acquire if free” — the basis for locks).XX— only set the key if it already exists.EX seconds/PX milliseconds— attach an expiry in the same atomic call as the write.GET— return the previous value as part of the same atomic call instead of issuing a separateGET.KEEPTTL— keep any existing TTL instead of clearing it (a plainSETwithout this flag always clears the TTL).
Examples
Example 1: An atomic counter
A page-view counter is a natural fit for INCR: every increment is a single, indivisible command.
SET counter:visits 0
INCR counter:visits
INCR counter:visits
GET counter:visits
Output:
OK
(integer) 1
(integer) 2
"2"
Each INCR reads the current integer, adds 1, and writes the result back, all inside one command execution. No matter how many other clients are hammering counter:visits at the same time, each INCR still sees a consistent prior value and produces a correct next value — there’s no window where two increments could both read the same starting number.
Example 2: Atomic inventory decrement
The same idea applies to stock levels, where losing an update means overselling a product.
SET inventory:sku42 10
DECRBY inventory:sku42 1
DECRBY inventory:sku42 1
GET inventory:sku42
Output:
OK
(integer) 9
(integer) 8
"8"
Two purchases each atomically subtract 1. Even if these two DECRBY calls came from two different web servers handling two different customers’ checkouts at the same instant, Redis still processes them one at a time on its single thread, so the final count is always correct — contrast this with the read-modify-write mistake covered later in this lesson.
Example 3: Using SET NX EX as a distributed lock
A very common atomicity-dependent pattern is a lock: only one worker should be allowed to claim a job.
SET lock:job123 "worker-1" NX EX 30
SET lock:job123 "worker-2" NX EX 30
GET lock:job123
TTL lock:job123
Output:
OK
(nil)
"worker-1"
(integer) 30
The first SET ... NX succeeds because lock:job123 doesn’t exist yet, so it creates the key with a 30-second TTL and returns OK. The second, identical-looking call from “worker-2” fails and returns (nil), because by the time it runs the key already exists. Both the existence check and the write happen inside one atomic command, so there’s no gap in which two workers could both see “key doesn’t exist” and both proceed to set it — which is exactly what a lock requires.
How It Works Step by Step
Walk through what happens when two clients, A and B, send that same SET lock:job123 ... NX EX 30 command at almost the same instant:
- Both commands arrive over the network and sit in the kernel’s socket buffers; Redis’s single event-loop thread will get to them one at a time, in whatever order the OS reports the sockets as readable — this order isn’t guaranteed to match “whoever sent first,” which is exactly why you can’t rely on send order for correctness.
- The event loop picks one connection, say client A’s, and dispatches its full command to the command execution path. No other client’s command can run until this one finishes completely.
- Redis checks whether
lock:job123exists. It doesn’t, so becauseNXwas given, the write proceeds: the key is created, its value is set, and a 30-second TTL is attached, all as part of this single command’s execution. - The reply
OKis written back to client A. Only now does the event loop move on to client B’s already-queued command. - Redis checks
lock:job123again, this time for client B’s command. It now exists (client A just created it), so theNXcondition fails and Redis replies(nil)without touching the key. - If Redis is configured with AOF persistence, the write from step 3 is appended to the log as part of processing that same command, before the next command begins — so durability bookkeeping doesn’t create an extra window for interleaving either.
The key point: steps 3–4 for client A are indivisible from client B’s perspective — B’s command is never interleaved with the middle of A’s command execution — and that’s exactly what guarantees only one of the two clients can ever win the lock.
Common Mistakes
Mistake 1: Read-modify-write instead of an atomic command
It’s tempting to read a counter, add to it in your own code, and write it back:
SET pageviews:home 41
GET pageviews:home
SET pageviews:home 42
Output:
OK
"41"
OK
This looks completely fine when you run it alone in redis-cli. The problem shows up under concurrency: if two application processes both run GET pageviews:home at nearly the same moment, both see 41, both compute 42 in their own code, and both issue SET pageviews:home 42 — one increment is silently lost, and the true count should have been 43. Each command is atomic, but the read-compute-write sequence around it is not. The fix is to let Redis do the arithmetic in one atomic call:
SET pageviews:home 41
INCR pageviews:home
GET pageviews:home
Output:
OK
(integer) 42
"42"
INCR reads and writes inside a single command, so two concurrent INCR calls can never both read the same starting value — the lost-update window disappears entirely.
Mistake 2: Setting a value and its expiry as two separate commands
Attaching a TTL after the fact seems harmless:
SET session:abc123 "user:42"
EXPIRE session:abc123 3600
TTL session:abc123
Output:
OK
(integer) 1
(integer) 3600
The risk here isn’t a race with another client so much as a reliability gap: if your process crashes, loses its connection, or simply never reaches the second line for any reason, session:abc123 is left in the keyspace with no TTL at all — a session key that should have expired in an hour now lives forever, quietly leaking memory. Combine the write and the expiry into one atomic command instead:
SET session:abc123 "user:42" EX 3600
TTL session:abc123
Output:
OK
(integer) 3600
Now the value and its TTL are set together, inside a single atomic command — there is no intermediate state where the key exists without an expiry.
Mistake 3: Assuming a sequence of commands you type is atomic as a group
It’s easy to reason “I sent these commands one after another, so nothing else could have happened in between” — but that’s only true if you actually group them. If you send INCR counter:orders twice as two independent commands from a busy application, Redis’s single thread will happily run someone else’s command between them; each INCR is atomic on its own, but nothing stops a different client’s command from being processed in the gap. When you need several commands guaranteed to run back-to-back with nothing else interleaved, wrap them in MULTI/EXEC:
MULTI
INCR counter:orders
INCR counter:orders
EXEC
Output:
OK
QUEUED
QUEUED
1) (integer) 1
2) (integer) 2
MULTI starts queuing commands on that connection instead of running them immediately; each queued command replies QUEUED; EXEC then runs the whole queued block as one atomic unit and returns an array of the individual replies. A full treatment of transactions, including WATCH for optimistic locking, is its own lesson in this section — the point here is simply that grouping, not typing order, is what atomicity across multiple commands requires.
Best Practices
- Prefer built-in atomic commands (
INCR,INCRBY,HINCRBY,LPUSH,SADD) over reading a value into your application, modifying it, and writing it back. - Use
SET key value NX EX secondsto combine “write only if absent” and “attach a TTL” into a single atomic call instead of two separate commands. - When you genuinely need multiple commands to succeed or fail together, use
MULTI/EXECor a Lua script viaEVALrather than hoping no other client’s command lands in between. - Remember atomicity doesn’t mean cheap: check a command’s time complexity before relying on it in a hot path, since a slow atomic command still blocks every other client for its full duration.
- In Redis Cluster, design key names (using hash tags like
{user:42}:profile) so keys you need to operate on together land in the same hash slot, since cross-slot multi-key commands aren’t allowed. - Don’t confuse “this command is atomic” with “my business logic is safe” — a sequence of individually atomic commands can still have a race condition between them.
Practice Exercises
- Build a “likes” counter for a blog post keyed as
post:501:likes. Using only atomic commands, increment it three times and confirm the final value withGET. Then work out what would go wrong if, instead, you had read the value, added 1 in your application code, and written it back — twice, from two different processes, at nearly the same moment. - Implement a simple lock for a job named
job456usingSET ... NX EX. Try to “acquire” it twice in a row from the same redis-cli session and confirm only the first attempt succeeds. What TTL would you pick for a job that normally finishes in 5 seconds but occasionally takes up to 20, and why does the lock need a TTL at all? - You have a naive stock-decrement pattern that does
GET stock:sku99, subtracts the purchased quantity in application code, then issues aSETwith the result. Rewrite it as a single atomic command, and explain in your own words why the atomic version can never oversell stock under heavy concurrent traffic, while the original version can.
Summary
- Redis is single-threaded, so every individual command runs to completion before the next one starts — this is the actual mechanism behind command atomicity, not a separate locking system.
- A command touching multiple keys or elements (like
MSETor aZADDwith several members) is still one atomic unit of work, because it’s still a single command. - Atomicity stops at the command boundary: a
GETfollowed by application logic followed by aSETis three separate steps with a race-condition window between them. INCR/INCRBY/DECRBY, andSETwithNX/XX/EX/GET, let you express common read-modify-write patterns as a single atomic call.- When one command isn’t enough,
MULTI/EXECand Lua scripting extend atomicity across multiple commands. - Atomic doesn’t mean free — a slow atomic command (like an unbounded
KEYSorSORT) still blocks every other client on the single thread for its full duration.
