Transactions, Lua scripting and atomicity

MULTI and EXEC, WATCH for optimistic locking, EVAL with correct KEYS usage, Redis Functions, and the patterns that need real atomicity.

MULTI, EXEC and WATCH

MULTI
INCR balance:42
DECR balance:7
EXEC

# optimistic locking: abort if the watched key changed
WATCH inventory:sku1
val = GET inventory:sku1
MULTI
DECRBY inventory:sku1 3
EXEC          # returns nil if anything watched changed since WATCH

# be honest about what MULTI gives you
MULTI
SET a 1
LPUSH a 2      # wrong type: not reported here
EXEC           # error reported here, but other commands still ran
  • MULTI queues commands and EXEC runs them without interruption, but there is no rollback: if one command fails at runtime the others still take effect.
  • Syntax errors are detected at queue time and abort the whole transaction; runtime errors such as WRONGTYPE do not.
  • WATCH is compare-and-set: it fails the transaction if a watched key changed. Your client must retry the whole read-modify-write loop on a nil reply.
  • A transaction cannot read a value and branch on it - the commands are queued, not executed. That is exactly the gap Lua fills.
💡
Since Redis 7, MULTI is executed in a single blocking step, so transactions are simpler than the old optimistic model. The commands inside must still be independent: the server does not roll back.

Lua scripting

-- rate limit: increment and set a TTL only on the first hit
-- KEYS[1] = counter key, ARGV[1] = limit, ARGV[2] = window seconds
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
  return 0
end
return 1
redis-cli --eval ratelimit.lua counter:user:42 , 100 60

# or inline
EVAL "return redis.call('GET', KEYS[1])" 1 mykey

# load once, run by hash: the preferred production pattern
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
EVALSHA <sha1> 1 mykey
SCRIPT EXISTS <sha1>
RuleWhy
All keys must be in KEYSCluster routing and replication need to know the keys statically
No wall-clock or random inputReplicas re-run the script; TIME and RANDOMKEY are replaced by deterministic values
No blocking loopsThe whole server waits; lua-time-limit only makes the script killable with SCRIPT KILL
Small scriptsEvery call adds latency to every other client
Return simple typesLua numbers become integers; return redis.call(...) preserves the reply
  • A script is atomic: nothing else runs between its commands. That is the real reason to use one, not performance alone.
  • Passing a key in ARGV instead of KEYS works in a standalone instance and breaks immediately in cluster mode.
  • EVALSHA avoids resending the body; on NOSCRIPT your client must fall back to SCRIPT LOAD and retry.
  • Scripts are cached by SHA and are not part of persistence. After a restart the cache is empty, so a robust client always handles NOSCRIPT.

Redis Functions and atomic patterns

#!lua name=mylib

redis.register_function('claim_job', function(keys, args)
  local job_id = redis.call('LMOVE', keys[1], keys[2], 'LEFT', 'RIGHT')
  if not job_id then return false end
  redis.call('HSET', keys[3], job_id, args[1])
  return job_id
end)
FUNCTION LOAD "#!lua name=mylib\n..."
FCALL claim_job 3 queue:pending queue:processing jobs:leases worker-1
FUNCTION LIST
FUNCTION STATS
PatternMechanismNote
Compare and setWATCH + MULTIClient retries on failure
Read then writeEVALSingle round trip, atomic
Immutable deployFUNCTION LOAD REPLACELibrary persists with the dataset
Conditional deleteLua comparing before DELSolves the lock-release race
Idempotent operationA version or token check inside the scriptSafe to retry after a timeout
-- release a lock only if you still own it: the classic compare-and-delete
-- KEYS[1] = lock key, ARGV[1] = my token
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

Any read-then-write sequence issued as two round trips is a race. If the correctness of your feature depends on the pair being atomic, it must be a script or a function - the network is not part of your transaction.

FAQ

EVAL or Functions?
Functions are the modern option: the library is stored with the dataset, survives restarts, and can be updated as a unit. Use EVAL for one-off scripts and when your client must work against older servers.
Can a Lua script block the server?
Yes, for its entire duration. Keep scripts to a handful of commands, never loop over a large collection, and set lua-time-limit so a runaway script can be stopped with SCRIPT KILL.

Distributed locks and rate limiting Data types and the commands that matter

Last refreshed 2026-09-18.