Skip to content

EVAL + Lua

nate
Jul 9, 2026 · 3 min read · 1 read

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 args
  • redis.call(cmd, ...) — invokes another Redis command from inside the script; raises on error
  • redis.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 to table.unpack)
  • #tbl returns array length but has nil-hole edge cases
  • no goto, no integer type (everything's a double until 5.3)
  • pcall/xpcall exist; 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 typeRESP reply
nilnil
trueinteger 1
falsenil (!)
numberinteger (truncated)
stringbulk 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.

Across the AtmosphereDiscussions