EVAL + Lua
EVAL + Lua
EVAL script numkeys key1 ... keyN arg1 ... argM runs script server-side as one atomic operation. the script sees:
KEYS[1..N]— the named keys (1-indexed)ARGV[1..M]— the named argsredis.call(cmd, ...)— invokes another Redis command from inside the script; raises on errorredis.pcall(cmd, ...)— same but returns{err = "..."}instead of raising
a script either runs to completion or errors; either way, no other client sees any intermediate state.
why it's lua 5.1 forever
redis embeds Lua 5.1 and uses the included sandbox restrictions. 5.1 has been frozen for ~17 years. that's a feature, not a bug — every redis 5.1 script you've ever written keeps working unchanged forever. it also means:
unpack(table)is global (in 5.2+ it moved totable.unpack)#tblreturns array length but has nil-hole edge cases- no
goto, no integer type (everything's a double until 5.3) pcall/xpcallexist;error()works the obvious way
if you're writing a redis-compatible engine, embed Lua 5.1 specifically. mlua (rust) and ziglua (zig) both let you pick the version explicitly — lang = .lua51 in build.zig.zon.
script return values
redis maps Lua return values back to RESP this way:
| lua type | RESP reply |
|---|---|
nil | nil |
true | integer 1 |
false | nil (!) |
| number | integer (truncated) |
| string | bulk string |
{ok = "OK"} | simple string "+OK" |
{err = "msg"} | error "-msg" |
| table (sequential) | array (recursive) |
{ok = ...} and {err = ...} are the only way for a script to return a status or error reply. otherwise you can only return strings, integers, arrays, or nil.
composability via redis.call
redis.call lets the script call any other command. typical pattern: a one-shot "do these N things atomically":
-- schedule.lua
if is_immediate == '1' then
redis.call('XADD', stream_key, '*', unpack(fields))
else
redis.call('HSET', parked_key, unpack(fields))
redis.call('ZADD', queue_key, when, task_key)
end
redis.call('HSET', runs_key, 'state', 'parked', 'task_key', task_key)
return 'SCHEDULED'inside EVAL, redis is still single-threaded. no other client can observe state between any two of those redis.calls. that's the entire reason EVAL exists.
error semantics
redis.call('NOPE')— raises a Lua error → script terminates → redis returns error reply.redis.pcall('NOPE')— returns{err = "..."}→ script continues, can decide what to do.
real redis does not roll back side effects on error. if your script did XADD and then errored, the XADD stays. matters because anyone parked on that stream (XREADGROUP BLOCK) needs to be woken up regardless of whether the script succeeded — pydocket/burner-redis track this as the had_xadd / had_list_mutation flags on EvalOutcome.
SCRIPT LOAD + EVALSHA
SCRIPT LOAD <body> returns a SHA1; EVALSHA <sha> ... re-runs it. the redis instance caches scripts in memory and returns NOSCRIPT if asked for one it doesn't know — clients are expected to handle NOSCRIPT by re-LOADing. docket (a distributed-task system built on these scripts) doesn't use this; it embeds its three scripts at build time and EVALs them every call. the bandwidth saving is negligible for our payload sizes.
embedded engines
burner-redis (rust, the original) and burner-redis-zig (the port) both embed a real Lua 5.1 interpreter and expose redis.call as a C function backed by the in-process store. EVAL scripts run unchanged against either backend — that's what makes "swap redis for an in-process engine in tests" work cleanly.
caveats:
- the script runs single-threaded against the in-process store, so atomicity is free (mutex around the whole store + a fresh Lua VM per EVAL call).
- script-level blocking (XREADGROUP BLOCK inside a script) isn't a thing in real redis either — BLOCK arguments are silently ignored when inside an EVAL.
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.