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 EXEC it refuses to run any of the queued commands at all, returning an EXECABORT error instead.
  • Run-time errors (e.g. calling INCR on a key that holds a non-numeric string): Redis can’t know about this until the command actually executes, so it only surfaces during EXEC. 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 an EXECABORT error — 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-WATCHMULTIEXEC 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 QUEUED before relying on EXEC — a stray typo silently poisons the whole batch.
  • Remember EXEC gives you no automatic rollback; only queue commands where a partial failure is acceptable, or validate inputs before queuing them.
  • Use WATCH plus 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 run EXEC/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 DECRBY or a Lua script is simpler than a full WATCH/MULTI/EXEC cycle.

Practice Exercises

  • Exercise 1: Using MULTI and EXEC, atomically set three keys, page:home:views, page:about:views, and page:contact:views, all to 0 in one transaction. Confirm with three separate GET calls afterward.
  • Exercise 2: Start a transaction with MULTI, queue an INCR on a key that doesn’t exist yet, then queue a deliberately malformed command (like GET with no key). Call EXEC and predict the reply before running it — then verify it against what you learned about queue-time errors.
  • Exercise 3: Set a key ticket:9001:remaining to 3. WATCH it, then in the same connection modify it directly with SET before running your MULTI/DECRBY/EXEC sequence. Confirm EXEC returns (nil) and that the decrement never applied.

Summary

  • MULTI starts queuing commands on a connection; each queued command replies QUEUED instead of running immediately.
  • EXEC runs every queued command atomically and uninterrupted, returning an array of replies in order.
  • DISCARD cancels a transaction, discarding the queue without running anything.
  • A malformed command poisons the whole transaction at queue time: EXEC then fails with EXECABORT and 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.
  • WATCH adds optimistic locking: if a watched key changes before EXEC, the whole transaction aborts and EXEC returns (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.