Module A-2·22 min read

Why Lua scripts execute atomically, KEYS and ARGV conventions, redis.call() vs redis.pcall(), SCRIPT LOAD and EVALSHA for script caching, atomic rate limiters and conditional operations impossible without Lua.

A-2 — Lua Scripting: EVAL, EVALSHA, and Atomic Compound Operations

Who this module is for: You have reached the limits of MULTI/EXEC and WATCH — you need to read a value, make a decision based on it, and write conditionally, all as a single atomic operation. Lua scripts run atomically on the Redis server, executing arbitrary logic without any other client interleaving. This module covers the EVAL model, the KEYS/ARGV convention, script caching, error handling, and the patterns that are impossible to implement correctly without Lua.


Why Lua Scripts Are Atomic

Redis's single-threaded event loop executes commands one at a time. A Lua script is executed as if it were a single command — it runs to completion before any other client's command executes. No other client can see intermediate state or interleave their commands during script execution.

This is stronger than MULTI/EXEC:

  • MULTI/EXEC queues commands and sends them together, but does not provide read-then-decide-then-write atomicity (you cannot use the result of a read to conditionally control what you write)
  • Lua executes arbitrary code server-side — you can read, branch, loop, and write all within the atomic boundary

The price: While a Lua script runs, Redis processes no other commands. Long-running scripts block all clients. Scripts must be fast (< 1ms ideally, < 5ms acceptable).


EVAL: Running a Script

EVAL script numkeys key [key ...] arg [arg ...]
  • script — the Lua script as a string
  • numkeys — the number of key arguments (required for Cluster routing)
  • key [key ...] — key names accessible in the script as KEYS[1], KEYS[2], etc.
  • arg [arg ...] — additional arguments accessible as ARGV[1], ARGV[2], etc.
127.0.0.1:6379> EVAL "return redis.call('GET', KEYS[1])" 1 mykey
"myvalue"

127.0.0.1:6379> EVAL "return ARGV[1]" 0 "hello"
"hello"

The KEYS and ARGV Convention

KEYS — all Redis key names the script accesses. Required for Redis Cluster: the cluster client routes the command based on KEYS[1]. If your script accesses keys on different slots, it will fail in Cluster.

ARGV — all non-key parameters: values, thresholds, configuration.

The convention is enforced by policy, not the interpreter. You can technically access any key by hardcoding the name in the script, but this breaks Cluster routing. Always pass key names via KEYS.

lua
-- Correct: keys via KEYS, values via ARGV local current = tonumber(redis.call('GET', KEYS[1])) local limit = tonumber(ARGV[1]) if current >= limit then return 0 end redis.call('INCR', KEYS[1]) return 1

redis.call vs redis.pcall

lua
redis.call('SET', KEYS[1], ARGV[1]) -- raises error on failure; script aborts redis.pcall('SET', KEYS[1], ARGV[1]) -- returns error as a table; script continues

redis.call propagates errors — if the Redis command fails (type mismatch, wrong arg count), the script aborts and Redis returns an error to the client.

redis.pcall catches errors and returns them as a Lua table {err = "error message"}. Use when you want to handle errors within the script:

lua
local result = redis.pcall('INCR', KEYS[1]) if result.err then -- Key is not a string (type error), handle gracefully return -1 end return result

Return Types

Lua → Redis type conversion:

LuaRedis reply
integerInteger reply
stringBulk string reply
table (array)Multi-bulk reply
{ok = "OK"}Simple string reply (+OK)
{err = "ERR msg"}Error reply
false or nilNil bulk reply
lua
return 42(integer) 42 return "hello""hello" return {1, 2, 3}1) (integer) 1 \n 2) (integer) 2 \n 3) (integer) 3 return {ok = "OK"}+OK return false(nil)

Important: Lua numbers are always floats. When returning integers to Redis, use math.floor() or tonumber() for explicit integer conversion. return 3.14(integer) 3 (Redis truncates floats).


SCRIPT LOAD and EVALSHA

Sending the full script text on every call is wasteful for large scripts. SCRIPT LOAD uploads the script to Redis once and returns its SHA1 digest. EVALSHA then calls the script by SHA:

SCRIPT LOAD "return redis.call('GET', KEYS[1])"
→ "2067d915024a3e1657c4169c84f809f8ec75b9a7"

EVALSHA 2067d915024a3e1657c4169c84f809f8ec75b9a7 1 mykey
→ "myvalue"
typescript
// Load script once at application startup const sha = await redis.script('LOAD', scriptText); // Call by SHA on every invocation (no retransmission of script body) const result = await redis.evalsha(sha, 1, key, ...args);

Script cache persistence: The script cache lives in Redis memory and is cleared on restart. Your application must re-load scripts after a Redis restart. Pattern:

typescript
async function callScript(sha: string, script: string, ...args: Parameters<typeof redis.evalsha>) { try { return await redis.evalsha(sha, ...args); } catch (err: any) { if (err.message.includes('NOSCRIPT')) { // Script was flushed — reload and retry await redis.script('LOAD', script); return await redis.evalsha(sha, ...args); } throw err; } }

SCRIPT EXISTS sha1 [sha1 ...] — check if scripts are loaded:

SCRIPT EXISTS 2067d915024a3e1657c4169c84f809f8ec75b9a7
→ 1) (integer) 1   ← loaded
   2) (integer) 0   ← not loaded

SCRIPT FLUSH — clear all cached scripts. Run this after code changes that modify Lua scripts.


Production Lua Patterns

Pattern 1: Atomic Rate Limiter

The sliding window rate limiter with Sorted Sets (from P-5) requires multiple commands. In a pipeline, they are not atomic. In Lua, they are:

lua
-- KEYS[1] = rate limit key -- ARGV[1] = window duration in ms -- ARGV[2] = max requests in window -- ARGV[3] = current timestamp in ms local key = KEYS[1] local window = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local window_start = now - window -- Remove expired entries redis.call('ZREMRANGEBYSCORE', key, 0, window_start) -- Count current entries local count = redis.call('ZCARD', key) if count >= limit then -- Reject: return 0 + time until next slot opens local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') local retry_after = oldest[2] and (tonumber(oldest[2]) + window - now) or 0 return {0, math.ceil(retry_after)} end -- Allow: add this request redis.call('ZADD', key, now, now .. '-' .. math.random(1000000)) redis.call('PEXPIRE', key, window) return {1, limit - count - 1} -- {allowed, remaining}
typescript
const rateLimiterSha = await redis.script('LOAD', rateLimiterScript); async function checkRateLimit(userId: string): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> { const result = await redis.evalsha( rateLimiterSha, 1, `rate:${userId}`, '60000', // 60-second window '100', // 100 requests per window String(Date.now()) ) as [number, number]; return { allowed: result[0] === 1, remaining: result[1], retryAfter: result[0] === 0 ? result[1] : undefined, }; }

Pattern 2: Conditional Set (Set If Less Than)

Update a leaderboard score only if it is higher than the current score — atomically:

lua
-- KEYS[1] = sorted set key -- ARGV[1] = member -- ARGV[2] = new score local current = redis.call('ZSCORE', KEYS[1], ARGV[1]) if current == false or tonumber(ARGV[2]) > tonumber(current) then redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]) return 1 -- updated end return 0 -- not updated (current score is higher)

Without Lua, this would require WATCH + MULTI/EXEC with a retry loop. With Lua: one atomic call.

Pattern 3: Atomic Inventory Deduction

lua
-- KEYS[1] = inventory key -- ARGV[1] = quantity to deduct local current = tonumber(redis.call('GET', KEYS[1])) if current == nil then return {-1, 0} -- item doesn't exist end if current < tonumber(ARGV[1]) then return {0, current} -- insufficient inventory end local remaining = current - tonumber(ARGV[1]) redis.call('SET', KEYS[1], remaining) return {1, remaining} -- success, remaining quantity

Pattern 4: Get-or-Set (Single-Flight Cache)

lua
-- KEYS[1] = cache key -- ARGV[1] = lock key -- ARGV[2] = lock value (UUID) -- ARGV[3] = lock TTL in ms local cached = redis.call('GET', KEYS[1]) if cached then return {1, cached} -- cache hit end -- Try to acquire lock for recompute local locked = redis.call('SET', ARGV[1], ARGV[2], 'NX', 'PX', ARGV[3]) if locked then return {2, nil} -- acquired lock — caller should recompute and populate else return {0, nil} -- lock held by another — caller should wait and retry end

Debugging Lua Scripts

redis.log

lua
redis.log(redis.LOG_WARNING, "Script executing with key: " .. KEYS[1]) redis.log(redis.LOG_VERBOSE, "Current value: " .. tostring(current))

Log levels: LOG_DEBUG, LOG_VERBOSE, LOG_NOTICE, LOG_WARNING. Output appears in the Redis log file.

SCRIPT DEBUG

Redis 3.2+ includes a Lua debugger. Start a debugging session:

bash
redis-cli --ldb -e 'return redis.call("GET", KEYS[1])' 1 mykey

Commands in debug mode: s (step), n (next), c (continue), b line (breakpoint), p var (print), l (list source).

Test Scripts in Isolation

Before deploying a Lua script, test it with EVAL directly in redis-cli with representative inputs. Verify the return values match your expected Redis response types.


Execution Limits

lua-time-limit 5000   → script cannot run for more than 5 seconds (default)

After lua-time-limit milliseconds, Redis stops accepting most new commands and returns a BUSY error. The script cannot be killed immediately — you can send SCRIPT KILL to terminate a script that has not yet performed any writes. If the script has written data, SCRIPT KILL is refused (to prevent partial writes), and only SHUTDOWN NOSAVE will stop Redis.

This is why Lua scripts must be fast. A script that loops over a large dataset or has an infinite loop can make Redis completely unresponsive.

Design rule: Lua scripts should complete in microseconds to single-digit milliseconds. If you need to process large datasets, do it in application code with SCAN-based iteration — not in a single Lua script.


Summary

  • Lua scripts execute atomically on the Redis server — no other command runs during script execution
  • EVAL script numkeys KEYS... ARGV... — pass key names via KEYS (required for Cluster routing), other params via ARGV
  • redis.call() aborts on error; redis.pcall() catches and returns errors as Lua tables
  • SCRIPT LOAD uploads a script and returns its SHA1; EVALSHA calls by SHA (avoids retransmitting the script body on every call)
  • Handle NOSCRIPT errors after Redis restart by reloading scripts automatically
  • Lua enables atomic operations impossible with MULTI/EXEC: read-then-decide-then-write, conditional updates, get-or-set
  • Scripts must be fastlua-time-limit (default 5s) blocks all clients when exceeded; design for < 1ms execution
  • Debug with redis.log() and redis-cli --ldb

Next: A-3 — Redis Functions: Persistent Stored Procedures — how Redis Functions differ from Lua scripts (functions survive restarts), function libraries, and when to migrate from EVALSHA to Functions.

© 2026 Jatin Jain Saraf (JJS). All rights reserved.