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 separate GET.
  • KEEPTTL — keep any existing TTL instead of clearing it (a plain SET without 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:

  1. 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.
  2. 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.
  3. Redis checks whether lock:job123 exists. It doesn’t, so because NX was 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.
  4. The reply OK is written back to client A. Only now does the event loop move on to client B’s already-queued command.
  5. Redis checks lock:job123 again, this time for client B’s command. It now exists (client A just created it), so the NX condition fails and Redis replies (nil) without touching the key.
  6. 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 seconds to 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/EXEC or a Lua script via EVAL rather 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

  1. 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 with GET. 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.
  2. Implement a simple lock for a job named job456 using SET ... 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?
  3. You have a naive stock-decrement pattern that does GET stock:sku99, subtracts the purchased quantity in application code, then issues a SET with 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 MSET or a ZADD with several members) is still one atomic unit of work, because it’s still a single command.
  • Atomicity stops at the command boundary: a GET followed by application logic followed by a SET is three separate steps with a race-condition window between them.
  • INCR/INCRBY/DECRBY, and SET with NX/XX/EX/GET, let you express common read-modify-write patterns as a single atomic call.
  • When one command isn’t enough, MULTI/EXEC and Lua scripting extend atomicity across multiple commands.
  • Atomic doesn’t mean free — a slow atomic command (like an unbounded KEYS or SORT) still blocks every other client on the single thread for its full duration.