Redis Transactions: MULTI and EXEC
A Redis transaction lets you queue up a batch of commands and run them all at once, back to back, with no other client’s commands able to squeeze in between them. You wrap the batch in MULTI and EXEC. This is how Redis gives you atomic, all-at-once execution of multiple operations — useful for things like transferring a value between two counters, or updating several related keys so no other client ever observes a half-finished update.
Overview / How It Works
Redis transactions work differently from SQL transactions, and understanding that difference is the key to using them correctly. When a client sends MULTI, the connection enters a special queuing state. Every command sent after that is not executed immediately — it is checked for basic validity (does the command exist, is the number of arguments plausible) and then appended to a queue tied to that connection. Redis replies QUEUED to each one instead of running it. Nothing has actually happened to your data yet.
When the client sends EXEC, Redis executes every queued command in order, one after another, and returns an array with one reply per command. Because Redis is single-threaded, once EXEC begins, no other client’s command can interleave between the queued commands — they run as an uninterrupted block. That guarantee (isolation from other clients) is really what a Redis transaction buys you. It is not the same as a SQL transaction’s rollback guarantee: if one queued command fails at run time, Redis does not undo the commands that already succeeded, and it still runs the commands that come after the failing one. There is no rollback in Redis transactions by design — the trade-off keeps the server simple and fast.
There are two very different moments where something can go wrong, and Redis treats them very differently:
- Queue-time errors (a command doesn’t exist, or has the wrong number of arguments): Redis detects this immediately while queuing, flags the whole transaction as bad, and when you call
EXECit refuses to run any of the queued commands at all, returning anEXECABORTerror instead. - Run-time errors (e.g. calling
INCRon a key that holds a non-numeric string): Redis can’t know about this until the command actually executes, so it only surfaces duringEXEC. The failing command’s reply is an error, but every other queued command still runs normally.
Redis also supports DISCARD, which throws away all queued commands and takes the connection out of the transaction state without running anything, and WATCH, which adds optimistic locking on top of MULTI/EXEC so you can safely implement check-and-set style logic (covered in its own section below).
Syntax
MULTI
<command 1>
<command 2>
...
EXEC
| Command | Description | Time Complexity |
|---|---|---|
MULTI |
Marks the start of a transaction block; subsequent commands on this connection are queued instead of executed. | O(1) |
EXEC |
Executes all commands queued since MULTI, atomically and in order, and returns an array of their replies. |
O(N), where N is the combined complexity of all queued commands |
DISCARD |
Cancels the transaction, discarding all queued commands without running them. | O(N), where N is the number of queued commands |
WATCH key [key ...] |
Watches one or more keys; if any is modified before EXEC, the transaction aborts. |
O(1) per watched key |
UNWATCH |
Clears all keys watched by the current connection. | O(1) |
Examples
Example 1: Queuing and executing commands
MULTI
SET account:alice:balance 100
SET account:bob:balance 50
EXEC
Output:
OK
QUEUED
QUEUED
1) OK
2) OK
MULTI replies OK and starts queuing. Each SET replies QUEUED instead of running. When EXEC is sent, both commands run back to back and their real replies come back as a two-element array.
Example 2: An atomic balance transfer
SET account:alice:balance 100
SET account:bob:balance 50
MULTI
DECRBY account:alice:balance 30
INCRBY account:bob:balance 30
EXEC
GET account:alice:balance
GET account:bob:balance
Output:
OK
OK
OK
QUEUED
QUEUED
1) (integer) 70
2) (integer) 80
"70"
"80"
This is the classic use case: moving a value from one key to another with no window where a third client could see the debit applied but not the credit. Because Redis is single-threaded and the two updates run inside one EXEC, any other client’s GET will only ever see the balances before the transfer or fully after it — never a half-applied state.
Example 3: Canceling a transaction with DISCARD
MULTI
SET session:abc123 active
DISCARD
GET session:abc123
Output:
OK
QUEUED
OK
(nil)
DISCARD throws away the queued SET entirely — it never runs. The final GET confirms the key was never written; the connection is now out of the transaction state and back to normal command execution.
How It Works Step by Step
- 1. Client sends
MULTI. The server marks this connection as \”in a transaction\” and creates an empty command queue for it. - 2. Client sends further commands. Each one is parsed and checked for arity/existence errors. If it looks structurally valid, it’s appended to the queue and the server replies
QUEUED. If it’s malformed, the server flags the transaction as dirty but still keeps queuing (it doesn’t execute the bad command). - 3. Client sends
EXEC. If the transaction was flagged dirty in step 2, Redis aborts immediately, discards the whole queue, and returns anEXECABORTerror — nothing runs. - 4. Otherwise, Redis runs every queued command in order, uninterrupted by any other client’s commands, because the single-threaded event loop won’t process another connection’s request until this whole batch finishes.
- 5. Redis returns one reply per queued command, in an array, in the same order they were queued — including any run-time errors, which appear in place rather than aborting the remaining commands.
WATCH: Optimistic Locking
MULTI/EXEC alone doesn’t let you make a decision based on a key’s current value before committing — by the time you queue a command, you can’t change your mind. WATCH fills that gap. You WATCH one or more keys before starting MULTI; if any watched key is modified by anyone (including the same connection, outside the transaction) between the WATCH and the EXEC, Redis aborts the transaction and EXEC returns (nil) instead of an array — none of the queued commands run. This gives you a compare-and-swap pattern: read a value, decide what to write, then commit only if nothing changed in between.
SET inventory:1001 50
WATCH inventory:1001
MULTI
DECRBY inventory:1001 5
EXEC
Output:
OK
OK
OK
QUEUED
1) (integer) 45
Nothing touched inventory:1001 between the WATCH and the EXEC, so the transaction commits normally.
SET inventory:1002 50
WATCH inventory:1002
SET inventory:1002 40
MULTI
DECRBY inventory:1002 5
EXEC
Output:
OK
OK
OK
OK
QUEUED
(nil)
Here, the plain SET inventory:1002 40 modifies the watched key after the WATCH but before EXEC (standing in for a change made by another client in a real application). Redis notices and aborts the transaction: EXEC returns (nil), and the queued DECRBY never runs. The correct pattern on the client side is to detect the nil reply and retry the whole read-decide-WATCH–MULTI–EXEC sequence.
Common Mistakes
Mistake 1: A malformed command aborts the entire transaction
MULTI
SET foo
EXEC
Output:
OK
(error) ERR wrong number of arguments for 'set' command
(error) EXECABORT Transaction discarded because of previous errors.
SET foo is missing its value, so Redis rejects it at queue time. That alone doesn’t kill the connection, but it poisons the transaction: calling EXEC now fails outright with EXECABORT, and nothing queued runs, even commands that were perfectly valid. Always check for a QUEUED reply on every command before assuming EXEC will succeed.
Mistake 2: Calling WATCH after MULTI has started
MULTI
WATCH foo
EXEC
Output:
OK
(error) ERR WATCH inside MULTI is not allowed
(error) EXECABORT Transaction discarded because of previous errors.
WATCH only makes sense before the transaction starts queuing — Redis rejects it once you’re already inside MULTI. Always call WATCH first, then MULTI, then queue your commands.
Mistake 3: Assuming EXEC rolls back on a failed command
SET user:1002:name "Ada"
MULTI
INCR user:1002:name
SET user:1002:status active
EXEC
Output:
OK
OK
QUEUED
QUEUED
1) (error) ERR value is not an integer or out of range
2) OK
INCR on a non-numeric string queues fine (it’s syntactically valid) but fails at run time with a WRONGTYPE-style error. Unlike a SQL transaction, Redis does not roll back: the second command, SET user:1002:status active, still runs and succeeds. Design your commands so a run-time failure in one doesn’t leave the others in an inconsistent state, or check each reply in the returned array yourself.
Best Practices
- Always check that every command inside a transaction replied
QUEUEDbefore relying onEXEC— a stray typo silently poisons the whole batch. - Remember
EXECgives you no automatic rollback; only queue commands where a partial failure is acceptable, or validate inputs before queuing them. - Use
WATCHplus a client-side retry loop whenever a transaction’s outcome depends on a key’s current value — that’s the standard Redis pattern for compare-and-swap logic. - Keep transactions short. Because the whole batch runs uninterrupted on a single thread, a long transaction blocks every other client on the server for its full duration.
- Call
UNWATCH(or just runEXEC/DISCARD, both of which clear watches automatically) if you decide not to proceed with a transaction, so you don’t leave stale watches open on the connection. - For simple atomic read-modify-write operations on a single key (like an atomic decrement-if-enough-stock), consider whether a single command such as
DECRBYor a Lua script is simpler than a fullWATCH/MULTI/EXECcycle.
Practice Exercises
- Exercise 1: Using
MULTIandEXEC, atomically set three keys,page:home:views,page:about:views, andpage:contact:views, all to0in one transaction. Confirm with three separateGETcalls afterward. - Exercise 2: Start a transaction with
MULTI, queue anINCRon a key that doesn’t exist yet, then queue a deliberately malformed command (likeGETwith no key). CallEXECand predict the reply before running it — then verify it against what you learned about queue-time errors. - Exercise 3: Set a key
ticket:9001:remainingto3.WATCHit, then in the same connection modify it directly withSETbefore running yourMULTI/DECRBY/EXECsequence. ConfirmEXECreturns(nil)and that the decrement never applied.
Summary
MULTIstarts queuing commands on a connection; each queued command repliesQUEUEDinstead of running immediately.EXECruns every queued command atomically and uninterrupted, returning an array of replies in order.DISCARDcancels a transaction, discarding the queue without running anything.- A malformed command poisons the whole transaction at queue time:
EXECthen fails withEXECABORTand nothing runs. - A command that’s valid but fails at run time (like
WRONGTYPE) does not stop the rest of the transaction — Redis has no rollback. WATCHadds optimistic locking: if a watched key changes beforeEXEC, the whole transaction aborts andEXECreturns(nil).- Transactions are isolated from other clients because Redis is single-threaded, but they should stay short since they block the server for their full duration.
