Skip to main content
Redis · lesson 5 of 14
Redis intermediate Lesson 5 of 14

MULTI/EXEC Transactions and the WATCH-Based Optimistic Lock

Queue commands with MULTI, run them atomically with EXEC — then watch a real transaction abort mid-flight when another client changes a WATCHed key first.

By SkillByExample · Published

redis transactions multi watch optimistic-locking

MULTI and EXEC make several commands run as one atomic unit — and WATCH is what happens when a transaction’s logic depends on a value that might not be there anymore by the time it runs.

MULTI and EXEC: queue, then run atomically

printf "MULTI\nSET balance 100\nINCRBY balance 50\nEXEC\n" | docker exec -i redis-lesson redis-cli
OK
QUEUED
QUEUED
OK
150

MULTI starts queuing — each command after it returns QUEUED instead of actually running. EXEC runs the whole queue back-to-back, and the results (OK from SET, 150 from INCRBY) come back together, in order. No other client’s command can interleave between the two queued commands once EXEC starts.

The bug: a transaction that aborts mid-flight

WATCH ties a transaction to a key’s state. Start one that reads a balance, then pauses before committing an update — and have another client change the balance in that window:

docker exec redis-lesson redis-cli SET balance 100

# "Session A" watches balance, then pauses before committing
(printf "WATCH balance\n"; sleep 2; printf "MULTI\nINCRBY balance 10\nEXEC\n") \
  | docker exec -i redis-lesson redis-cli &

sleep 1
# "Session B" changes the balance while session A is paused
docker exec redis-lesson redis-cli SET balance 999
wait
OK
OK
QUEUED

Read this closely: WATCH balance returns OK, MULTI returns OK, INCRBY balance 10 returns QUEUED — and then EXEC returns nothing at all. Session A’s transaction never ran. INCRBY was never applied, even though it was successfully queued.

docker exec redis-lesson redis-cli GET balance
999

The balance is 999 — session B’s write, untouched by session A’s +10. If session A’s transaction had actually run, the balance would be 1009. WATCH noticed the key changed between the watch and the EXEC, and refused to run the transaction at all rather than run it against a balance that might already be stale.

The same transaction, uncontested

docker exec redis-lesson redis-cli SET balance 100
printf "WATCH balance\nMULTI\nINCRBY balance 10\nEXEC\n" | docker exec -i redis-lesson redis-cli
docker exec redis-lesson redis-cli GET balance
OK
OK
OK
QUEUED
110
110

No other client touched balance in between this time, so EXEC actually runs and returns 110 — the increment applied cleanly. Same code, same commands; the only difference is whether something else got there first. Production code using this pattern checks whether EXEC came back empty and, if so, re-reads the value and retries the whole sequence.

Practice

1. Queue multiple unrelated commands in one transaction and confirm they all ran.
printf "MULTI\nSET a 1\nSET b 2\nSET c 3\nEXEC\n" | docker exec -i redis-lesson redis-cli
docker exec redis-lesson redis-cli MGET a b c
OK
QUEUED
QUEUED
QUEUED
OK
OK
OK
1
2
3

Three independent SETs, no shared state between them — a plain MULTI/EXEC batch with no need for WATCH, since nothing here depends on a value read beforehand.

2. Use DISCARD to cancel a queued transaction before it runs.
printf "MULTI\nSET temp 1\nDISCARD\n" | docker exec -i redis-lesson redis-cli
docker exec redis-lesson redis-cli EXISTS temp
OK
QUEUED
OK
0

DISCARD throws away everything queued since MULTI without running any of it — temp was never actually set, confirmed by EXISTS returning 0.

3. Watch a key that never changes, and confirm the transaction runs normally.
docker exec redis-lesson redis-cli SET stable "unchanged"
printf "WATCH stable\nMULTI\nGET stable\nEXEC\n" | docker exec -i redis-lesson redis-cli
OK
OK
OK
QUEUED
unchanged

WATCH only aborts the transaction if the watched key actually changes — watching a key that stays untouched costs nothing and the transaction commits normally, exactly like not watching at all.

Next: Pub/Sub — channels, pattern subscriptions, and sharded Pub/Sub.

Frequently Asked Questions

What does MULTI/EXEC actually guarantee?
Every queued command runs back-to-back with no other client's command interleaved between them — atomicity of execution, not isolation of reads before the transaction starts. Redis is single-threaded for command execution, so once EXEC begins, nothing else can run until every queued command finishes.
Why did EXEC return nothing instead of an error when the WATCHed key changed?
A nil/empty EXEC result is Redis's signal that the transaction was aborted because a WATCHed key changed since the WATCH call — none of the queued commands ran at all, not even partially. It's not an error condition to catch; it's an expected outcome to check for and react to, typically by retrying the whole read-watch-queue-exec sequence.
Does WATCH block other clients from writing to the watched key?
No — WATCH is optimistic, not a lock. Another client can read and write the watched key freely; WATCH only makes a note of the key's state and causes the watching client's own EXEC to abort if that state changed. This is why the pattern is called optimistic locking: it assumes conflicts are rare and detects them after the fact, rather than blocking to prevent them.
Can I read a value and decide what to queue based on it, all inside one transaction?
No — commands queued inside MULTI don't execute until EXEC, so a GET queued there doesn't return a real value you can branch on mid-transaction. The actual pattern is: GET the value before MULTI, WATCH the key, decide what to queue based on that read, then MULTI/EXEC — and retry the whole sequence if EXEC comes back empty because the value changed underneath you.
When should I reach for WATCH instead of just using MULTI alone?
Use plain MULTI/EXEC when the queued commands don't depend on a value read beforehand — a batch of independent writes, for instance. Reach for WATCH specifically when a decision inside the transaction depends on a value that could be stale by the time EXEC runs, like the classic 'read current balance, then transfer' pattern this lesson uses.