skip to content

Why can't application code read a key inside a Redis MULTI block and branch on its value to decide the next queued command, and what should you do when the logic genuinely needs that?

level: middleimportance: should knowfreq 40%

answer

  1. +QUEUED, not a value — results only in the EXEC array
  2. no branches, no conditionals inside a transaction
  3. client-side decision → WATCH + retry on nil EXEC
  4. server-side decision → Lua / Function, one round trip
  5. often a native NX/XX/INCRBY command already does it

basics

~20 s

Because nothing executes until EXEC: a queued GET replies +QUEUED, and the real value only appears in the EXEC reply array after the whole batch has run. There is no value to branch on at build time. Use WATCH around the read with a retry loop, or a server-side Lua script/Function that reads and decides where the data lives.

solid answer

~60 s

Between MULTI and EXEC the server only queues. A `GET` sent there answers `+QUEUED`; its real value arrives as an element of the array EXEC returns — after every command in the batch has already executed. So a decision that depends on that value cannot influence the batch that produced it. Redis transactions have no conditional or control flow at all. Two correct alternatives: 1. **Optimistic concurrency with WATCH.** Read the key normally, decide in the client, `WATCH` the key before MULTI, queue the writes, EXEC. If anything modified the watched key in the meantime, EXEC returns a null reply and you retry the whole read-decide-write cycle. This keeps logic in the application and costs extra round trips plus a retry loop. 2. **A server-side script** — Lua via EVAL/EVALSHA, or a Redis Function on 7.x. The script runs atomically on the server, so it can read, branch, loop and write within one execution with no round trips and no retries. Rule of thumb: unconditional batches use MULTI/EXEC; read-then-decide logic goes into a script, or uses WATCH when the logic must stay client-side.

code

text · 5 lines
text
WATCH balance
GET balance            # "250" - a real value, read outside the transaction
MULTI
DECRBY balance 100
EXEC                   # array => committed; (nil) => balance changed, rebuild and retry

go deeper

for a junior

Say that queued commands return QUEUED rather than values, so there is nothing to test until EXEC has already run everything.

for a middle

Explain the two-phase model as the cause, and name both remedies: WATCH with a retry loop, or a server-side script.

for a senior

Choose between them on contention and latency, and point out that many conditionals are already native atomic commands, avoiding both.

for a principal

Weigh where business logic should live — testability and observability in application code versus round trips and retry amplification — and note the operational cost of scripts blocking the single execution thread.

## The queueing model forbids it structurally The reason is not a missing feature; it falls out of how a Redis transaction is built. After `MULTI`, the server does not execute anything. Each command is validated and appended to a per-connection queue, and the reply is the placeholder `+QUEUED`. Execution happens only when `EXEC` arrives, and its reply is an array holding one real reply per queued command. So the timeline is: you finish building the batch, *then* results exist. A branch that needs a result would have to happen before the batch is built, from a value that does not yet exist. Client libraries reflect this honestly — the object a queued command returns is a future or placeholder that only resolves after EXEC. ``` MULTI GET balance -> QUEUED # no value here # there is nothing to compare against yet DECRBY balance 100 -> QUEUED EXEC -> 1) "250" 2) (integer) 150 # already applied, too late to reconsider ``` The DECRBY happened regardless of what the balance turned out to be. ## There is no control flow either Redis transactions are a flat list of commands. There is no conditional command, no branch, no abort-if, and no way to make one queued command's execution depend on another's outcome. The only decision point Redis offers around a transaction is WATCH, and it is binary: run the whole thing, or run none of it and tell the client to retry. This is also why "just retry inside the transaction" is not a thing, and why partial application on a runtime error cannot be prevented by clever queueing. ## Option 1: WATCH, the optimistic route WATCH implements check-and-set. The shape is: ``` WATCH balance GET balance # a real read, outside the transaction # client decides: 250 >= 100, so proceed MULTI DECRBY balance 100 EXEC # array => committed; nil => balance changed, retry ``` The server remembers the watched keys for that connection; if any of them is modified by anyone before EXEC, EXEC returns a null reply and executes nothing. The client loops: unwatch/re-read/re-decide/retry. What it costs: at least three round trips per attempt, plus more under contention, since a hot key can starve a retry loop. What it buys: the decision logic stays in your application language, with your types, your business rules and your test suite. That is a real benefit when the rule is complicated. The mechanics of WATCH — which keys are tracked, when the watch is cleared — are a topic in their own right; here it is enough to know it is the client-side answer. ## Option 2: move the logic to the server A Lua script run with `EVAL`/`EVALSHA`, or a Redis Function registered with `FUNCTION LOAD` on Redis 7.x, executes on the server as one unit. Inside, `redis.call()` returns real values immediately, so you can read, compare, branch, loop and write in one pass: ```lua local balance = tonumber(redis.call('GET', KEYS[1]) or '0') if balance < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 ``` One round trip, no retry loop, no contention amplification, and — crucially — the read and the write cannot be separated by another client's command. This is why conditional logic on Redis data overwhelmingly ends up in scripts. The tradeoffs are real too: the logic is in a second language, harder to test and observe; the script blocks the server for its whole duration, so it must be short; and all keys it touches must be declared (and in Cluster must live in one hash slot). ## Option 3: use a command that already does the check Often the conditional is one Redis already implements atomically, and neither WATCH nor Lua is needed: - `SET key val NX` — write only if absent (the basis of lock acquisition). - `SET key val XX` — write only if present. - `SETNX`, `HSETNX`, `ZADD ... NX|GT|LT`, `EXPIRE ... NX|XX|GT|LT`. - `INCRBY` returning the new value, which turns "read, add, write" into one atomic step. - `GETDEL`, `GETEX`, `SET ... GET`. Reaching for a transaction when a single conditional command exists is a common over-engineering smell, and interviewers notice. ## Choosing - Unconditional batch of writes that must not interleave → MULTI/EXEC. - A single conditional that a native command expresses → that command. - Read-then-decide with rich or client-owned rules, low contention → WATCH with a bounded retry loop. - Read-then-decide that is hot, latency-sensitive, or must not retry → a Lua script or Function.

  • When would you prefer WATCH over a Lua script for read-then-decide logic?
    When the decision rule is complex, changes often, or belongs in your application's domain code where it can be unit-tested and observed in your normal language and tooling. WATCH keeps it there at the cost of extra round trips and a retry loop. It fits low-contention keys; on a hot key the retries multiply and a script becomes the better answer.
  • Is there a way to express a conditional write without either a transaction or a script?
    Frequently yes — Redis has conditional variants that are atomic by themselves: SET with NX or XX, HSETNX, ZADD with NX/GT/LT, EXPIRE with NX/XX/GT/LT, and counters like INCRBY that return the new value. If the whole conditional collapses into one such command, using it is simpler and faster than either alternative and removes the retry loop entirely.

Filling in a paper order form: you can write every line, but you cannot read the kitchen's answer to line one before writing line two — the form is only handed over once, complete.

saying these in an interview costs you the question

  • Believing a GET between MULTI and EXEC returns a usable value to branch on
  • Expecting an if/else or abort-if construct inside a transaction
  • Reaching for MULTI/EXEC when a single conditional command such as SET NX already expresses the logic
  • Reading a value before MULTI and assuming it is still valid at EXEC without WATCH
  • Assuming EXEC returning an array means the precondition you checked earlier still held

context