Redis Lua Scripting
Redis lets you write small programs in the Lua language and run them directly inside the Redis server. The entire script executes as one atomic unit β no other client can run commands while your script is running. This solves problems that plain transactions cannot handle, because Lua scripts read current values and make decisions based on them before writing anything back.
Why Lua Scripting Exists
Redis commands are fast but simple. Sometimes your app needs to read a value, make a decision based on it, and then write β all without another client slipping in between. MULTI/EXEC queues commands but cannot branch on live data. Lua fills that gap.
Problem: Check stock before decrementing (two separate commands are unsafe)
Step 1: App sends GET stock:item:88 β Redis returns "3"
(another client reads "3" too)
Step 2: App sends DECR stock:item:88 β Redis returns "2"
Other client sends DECR β Redis returns "1"
(Both clients thought they got the last item β race condition)
Solution: Wrap both steps in one Lua script
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1 -- purchase approved
end
return 0 -- out of stock
Redis executes this entire block atomically.
No other client runs between the GET and the DECR.
The Kitchen Chef Analogy
Without Lua, your app is a waiter making one trip to the kitchen per item. Lua scripting gives the chef a recipe card. The chef reads it, cooks everything in order, and returns a finished plate. No back-and-forth. Nobody else enters the kitchen while the chef works.
Without Lua β multiple round trips, gaps between each:
App ββGETβββΆ Redis ββ"3"βββΆ App
App ββDECRββΆ Redis ββ"2"βββΆ App
(gap between these two = window for race condition)
With Lua β one round trip, zero gaps:
App ββEVAL scriptβββΆ Redis
β
Lua runs:
GET β decide β DECR
β
result βββΆ App
Lua Language Basics You Need for Redis
You do not need to be a Lua expert. Redis scripts use a small, predictable subset of the language. These are the only constructs you will use in almost every script.
Variables
local name = "Alice" -- string local count = 10 -- number local active = true -- boolean local nothing = nil -- empty / not found Always use 'local' inside Redis scripts. Global variables persist between script calls in the same Redis session and cause hard-to-find bugs.
String to Number Conversion
Redis always returns values as strings, even if they look like numbers.
You must convert before doing arithmetic.
local raw = redis.call('GET', 'score') -- returns "42" (string)
local num = tonumber(raw) -- converts to 42 (number)
local result = num + 10 -- 52
tostring(52) -- converts number back to "52" (string)
If / Else
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
return -1 -- key does not exist
elseif stock > 0 then
return 1 -- in stock
else
return 0 -- out of stock
end
Loops
-- Numeric for loop (count from 1 to 5)
for i = 1, 5 do
redis.call('RPUSH', 'mylist', i)
end
-- While loop
local i = 0
while i < 10 do
i = i + 1
end
-- Loop over a Lua table (array)
local items = {'a', 'b', 'c'}
for index, value in ipairs(items) do
-- index = 1, 2, 3 (Lua arrays start at 1, not 0)
-- value = 'a', 'b', 'c'
end
Tables (Arrays and Maps)
-- Array-style table
local colors = {'red', 'green', 'blue'}
colors[1] -- "red" (index starts at 1)
#colors -- 3 (length operator)
-- Map-style table
local user = {name = 'Bob', age = 25}
user.name -- "Bob"
user['age'] -- 25
Functions
local function clamp(value, min, max)
if value < min then return min end
if value > max then return max end
return value
end
local score = clamp(tonumber(redis.call('GET', 'score')), 0, 100)
The EVAL Command β Full Syntax
EVAL script numkeys key [key ...] arg [arg ...] script β the Lua code as a string numkeys β count of key names that follow key ... β key names, available inside Lua as KEYS[1], KEYS[2], ... arg ... β extra values, available inside Lua as ARGV[1], ARGV[2], ... Keys and ARGV indices start at 1 in Lua (not 0).
No Keys, No Args β Simplest Form
127.0.0.1:6379> EVAL "return 7 * 6" 0 (integer) 42
One Key, No Args
127.0.0.1:6379> SET greeting "Hello"
OK
127.0.0.1:6379> EVAL "return redis.call('GET', KEYS[1])" 1 greeting
"Hello"
Two Keys and Two Args
Goal: Copy value from key1 to key2 with a TTL from ARGV[1]
EVAL "
local val = redis.call('GET', KEYS[1])
if val then
redis.call('SET', KEYS[2], val)
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1]))
return 1
end
return 0
" 2 source:key dest:key 300
numkeys = 2
KEYS[1] = source:key
KEYS[2] = dest:key
ARGV[1] = "300" (TTL in seconds)
Passing a List of Values via ARGV
Goal: Push multiple items into a list in one script call
EVAL "
for i = 1, #ARGV do
redis.call('RPUSH', KEYS[1], ARGV[i])
end
return redis.call('LLEN', KEYS[1])
" 1 myqueue "task-A" "task-B" "task-C"
KEYS[1] = myqueue
ARGV = {"task-A", "task-B", "task-C"}
#ARGV = 3 (length of the args table)
Returns: (integer) 3
Data Type Conversions Between Lua and Redis
Redis and Lua have different type systems. Redis automatically converts values when moving between the two. Understanding these conversions prevents confusing nil returns and unexpected errors.
Lua β Redis return type conversion:
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β Lua Type β Redis Reply Type β
ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββ€
β number (integer) β integer reply β
β string β bulk string reply β
β table (array) β multi-bulk reply (array) β
β false / nil β nil bulk reply β
β true β integer reply: 1 β
ββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββ
Redis β Lua conversion (what redis.call returns):
ββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ
β Redis Reply β Lua Value β
ββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββ€
β integer reply β number β
β bulk string β string β
β nil bulk reply β false (not nil!) β
β status reply (OK) β table with ok field β
β error reply β table with err field β
β multi-bulk (array) β table β
ββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββ
Key trap: Redis nil comes back as Lua false, not Lua nil.
Check for false, not nil, when testing a GET result.
Wrong:
local v = redis.call('GET', 'missing_key')
if v == nil then ...end -- never triggers!
Correct:
local v = redis.call('GET', 'missing_key')
if not v then ...end -- triggers correctly for nil/false
if v == false then ...end -- also correct
redis.call vs redis.pcall
Both functions run a Redis command from inside Lua. The difference is what happens when that command fails.
redis.call β raises an error, stops the script immediately
local result = redis.call('INCR', 'mystring')
-- If 'mystring' holds "hello", Redis returns an error.
-- Script stops here. Error propagates to the caller.
redis.pcall β catches the error, returns it as a table
local result = redis.pcall('INCR', 'mystring')
if type(result) == 'table' and result.err then
-- Command failed. result.err holds the error message.
return -1 -- handle gracefully
end
return result
Flow diagram:
redis.call fails:
Script βββΆ redis.call βββΆ Redis error
β
Script stops
Error returned to app
redis.pcall fails:
Script βββΆ redis.pcall βββΆ Redis error
β
error table returned
to your Lua code
β
if result.err then
handle it
end
Script continues
Returning Values from Lua Scripts
Return a Single String
EVAL "return 'purchase approved'" 0 β "purchase approved"
Return a Number
EVAL "return 42" 0 β (integer) 42 Note: Lua floats are truncated to integers when returned to Redis. return 3.9 β (integer) 3 (not 3.9!) To return a float, wrap it as a string: return tostring(3.9) β "3.9"
Return a Table (Array)
EVAL "return {'apple', 'banana', 'cherry'}" 0
β 1) "apple"
2) "banana"
3) "cherry"
Return OK or Error Status
-- Return a Redis status reply (like "OK")
return redis.status_reply('OK')
-- Return a Redis error reply
return redis.error_reply('Not enough balance')
β (error) Not enough balance
Real-World Script 1 β Atomic Inventory Decrement
This script checks stock, decrements it only if available, and returns a status code the app can act on.
Script:
local key = KEYS[1]
local qty = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', key))
if not stock then
return -1 -- item does not exist
end
if stock < qty then
return 0 -- not enough stock
end
redis.call('DECRBY', key, qty)
return 1 -- success
Call:
EVAL "<script>" 1 stock:item:88 3
β 1 (3 units reserved)
β 0 (fewer than 3 in stock)
β -1 (item key not found)
Flow:
App calls EVAL
β
βΌ
Redis runs Lua atomically
β
GET stock βββΆ "10"
β
10 >= 3? YES
β
DECRBY stock 3 βββΆ "7"
β
return 1 βββΆ App reads 1 = success
Real-World Script 2 β Distributed Lock (Mutex)
Two servers must never run the same background job at the same time. A Redis lock prevents that. The lock is a key with an expiry. Only one server can create it (using SETNX semantics). The release script makes sure only the server that owns the lock can delete it.
Acquire the lock:
SET lock:job:invoice "server-A" NX EX 30
β OK (server-A owns the lock for 30 seconds)
β nil (another server holds the lock)
NX = set only if key does Not eXist
EX = expire after 30 seconds (auto-release if server crashes)
Release the lock β MUST be atomic (use Lua):
Script:
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1 -- lock released
end
return 0 -- not our lock, do nothing
Call:
EVAL "<script>" 1 lock:job:invoice "server-A"
β 1 (lock deleted β server-A owned it)
β 0 (lock belongs to another server or already expired)
Why Lua is required for the release:
Without Lua:
Server A: GET lock β "server-A" β (I own it)
(lock expires here, server B acquires it)
Server A: DEL lock β deletes server B's lock! Bug!
With Lua:
GET and DEL happen atomically.
No window for another server to acquire in between.
Full lifecycle:
Server A Redis Server B
β β β
βββ SET lock NX EX 30 ββββΆβ β
ββββ OK ββββββββββββββββββ β
β (owns lock) β β
β β SET lock NX EX 30 ββ€
β ββββ nil ββββββββββββββββ€
β (runs the job) β (waits or skips) β
β β β
βββ EVAL release "server-A"βΆβ β
ββββ 1 βββββββββββββββββββ β
β (lock released) β β
Real-World Script 3 β Leaderboard Score Update with Rank Return
This script updates a player's score in a Sorted Set and returns the player's new rank β all in one round trip.
Script:
local board = KEYS[1]
local player = ARGV[1]
local points = tonumber(ARGV[2])
redis.call('ZINCRBY', board, points, player)
local rank = redis.call('ZREVRANK', board, player)
-- ZREVRANK returns 0-indexed rank (0 = top player)
return rank + 1 -- convert to 1-indexed
Call:
EVAL "<script>" 1 leaderboard:game1 "Alice" 500
β (integer) 2 (Alice is now ranked 2nd)
Without Lua β two round trips with a gap:
App: ZINCRBY leaderboard:game1 500 Alice
(another player's score updates here β rank shifts!)
App: ZREVRANK leaderboard:game1 Alice
β stale rank
With Lua β rank reflects the score update instantly.
Real-World Script 4 β Atomic Rate Limiter
This script increments a counter for an IP address and sets an expiry only on the first request of the window. Because both steps are in one script, there is no gap where a concurrent request could skip the EXPIRE call.
Script:
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0 -- blocked
end
return 1 -- allowed
Call:
EVAL "<script>" 1 rate:192.168.1.5 100 60
β 1 (allowed)
β 0 (blocked β over 100 requests in 60 seconds)
Timeline for one IP address:
Request 1: INCR β 1, EXPIRE 60 set, return 1 (allowed)
Request 2: INCR β 2, no EXPIRE, return 1 (allowed)
...
Request 100: INCR β 100, return 1 (allowed)
Request 101: INCR β 101, return 0 (blocked)
(60 seconds pass β key expires β counter resets)
Request 1: INCR β 1, EXPIRE 60 set, return 1 (allowed again)
Caching Scripts with SCRIPT LOAD and EVALSHA
Sending the full Lua script text on every EVAL call wastes bandwidth, especially for long scripts called thousands of times per second. SCRIPT LOAD uploads the script once and gives you a SHA1 fingerprint. You use that fingerprint with EVALSHA on every subsequent call.
Step 1 β Upload the script once at app startup
SCRIPT LOAD "
local v = redis.call('GET', KEYS[1])
if not v then return 0 end
return tonumber(v)
"
β "7f2d435f5b4c9e8a1c3d6e9f0b2a4c8d1e3f5a7b" (SHA1 hash)
Step 2 β Call by hash on every request
EVALSHA 7f2d435f5b4c9e8a1c3d6e9f0b2a4c8d1e3f5a7b 1 visits
β (integer) 2048
Step 3 β Check whether a script is cached
SCRIPT EXISTS 7f2d435f5b4c9e8a1c3d6e9f0b2a4c8d1e3f5a7b
β 1 (cached)
β 0 (not cached β server may have restarted)
Step 4 β Flush all cached scripts (use in emergencies only)
SCRIPT FLUSH
EVALSHA execution flow:
App startup:
SCRIPT LOAD βββΆ Redis stores script βββΆ returns SHA
β
App stores SHA
Every request:
EVALSHA SHA βββΆ Redis finds script by SHA
Runs atomically
Returns result βββΆ App
If EVALSHA returns NOSCRIPT error:
Script was flushed (e.g. server restart).
Fall back to EVAL with full script text,
then store the new SHA.
Handling NOSCRIPT Gracefully
result = EVALSHA sha 1 mykey
if result == NOSCRIPT_ERROR:
sha = SCRIPT LOAD "<full script text>"
result = EVALSHA sha 1 mykey
Debugging Lua Scripts
Using redis.log to Print Debug Messages
Lua scripts cannot print to the terminal. Use redis.log() to write messages to the Redis server log. redis.log(redis.LOG_WARNING, "Script started") redis.log(redis.LOG_NOTICE, "stock value: " .. tostring(stock)) redis.log(redis.LOG_DEBUG, "about to DECR") Log levels (from least to most verbose): redis.LOG_WARNING redis.LOG_VERBOSE redis.LOG_NOTICE redis.LOG_DEBUG View output in the Redis log file (set loglevel debug in redis.conf).
Using the Redis Lua Debugger (LDB)
Redis ships a built-in step debugger for Lua scripts.
Start a debugging session (does NOT affect the live server):
redis-cli --ldb --eval myscript.lua key1 key2 , arg1 arg2
The comma ( , ) separates keys from args on the command line.
Inside the debugger:
s β step to next line
n β step over (runs current line, stops at next)
c β continue to next breakpoint
p var β print a variable's value
b 10 β set breakpoint at line 10
list β show current script with line numbers
Example session:
Lua debugging session started. Type 'help' for commands.
* Stopped at 1, stop reason = step
-> 1 local stock = redis.call('GET', KEYS[1])
lua debugger> p stock
<value> "10"
lua debugger> s
-> 2 if tonumber(stock) > 0 then
lua debugger> c
(integer) 1
Lua Script Rules and Hard Limits
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Rule β Reason β
ββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ€
β Scripts block all clients β Redis is single-threaded. β
β while running β Keep scripts under 1ms. β
ββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ€
β Time limit: 5 seconds default β lua-time-limit in redis.conf β
β (script-time-limit in Redis 7) β After limit: SCRIPT KILL β
ββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ€
β No os.time(), math.random() β Scripts must be β
β for production use β deterministic for AOF/replica β
β Use redis.call('TIME') β replication to work correctly β
ββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ€
β No I/O inside scripts β No HTTP, sockets, or file β
β (no require of external libs) β reads allowed from Lua β
ββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ€
β All keys must be declared β Pass keys as KEYS[n], β
β in the KEYS array β not as hardcoded strings β
β β (required for Redis Cluster) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Killing a Stuck Script
If a script runs too long:
SCRIPT KILL β stops a script that has not yet written anything
(safe to kill)
If the script has already done writes, SCRIPT KILL refuses.
Only a server SHUTDOWN NOSAVE can stop it.
This is why you must keep scripts short and test thoroughly.
Getting the Server Time Correctly Inside Lua
Wrong (not deterministic, breaks AOF replay):
local t = os.time()
Correct:
local time = redis.call('TIME')
local seconds = tonumber(time[1]) -- Unix seconds
local microseconds = tonumber(time[2]) -- fractional part
Redis 7 Functions β The Modern Replacement for EVAL
Redis 7.0 introduced Functions as a more structured way to deploy Lua code. Instead of sending raw scripts each time, you load named libraries into Redis. The functions in those libraries persist across restarts (unlike EVALSHA, which is lost on restart without AOF).
Comparison:
βββββββββββββββββββββββ¬βββββββββββββββββββ¬βββββββββββββββββββββββββ
β Feature β EVAL/EVALSHA β Functions (Redis 7) β
βββββββββββββββββββββββΌβββββββββββββββββββΌβββββββββββββββββββββββββ€
β Persist on restart β No (EVALSHA β Yes (stored in RDB β
β β needs reload) β and AOF) β
βββββββββββββββββββββββΌβββββββββββββββββββΌβββββββββββββββββββββββββ€
β Named functions β No (SHA only) β Yes (call by name) β
βββββββββββββββββββββββΌβββββββββββββββββββΌβββββββββββββββββββββββββ€
β Library grouping β No β Yes (one lib, β
β β β many functions) β
βββββββββββββββββββββββΌβββββββββββββββββββΌβββββββββββββββββββββββββ€
β Deployment β Per-script β Deploy entire library β
βββββββββββββββββββββββ΄βββββββββββββββββββ΄βββββββββββββββββββββββββ
Load a library (do this once at deployment):
FUNCTION LOAD "#!lua name=mylib
local function buy(keys, args)
local stock = redis.call('GET', keys[1])
if tonumber(stock) > 0 then
redis.call('DECR', keys[1])
return 1
end
return 0
end
redis.register_function('buy', buy)"
Call the function by name:
FCALL buy 1 stock:item:88
β 1 (success)
β 0 (out of stock)
List all loaded libraries:
FUNCTION LIST
Delete a library:
FUNCTION DELETE mylib
Choosing Between EVAL, EVALSHA, and FCALL
Use EVAL when: β Testing or debugging a script interactively β Running a one-off script that you will not repeat Use EVALSHA when: β Your app calls the same script many times per second β You want to save bandwidth (send SHA, not full script text) β You handle NOSCRIPT gracefully by falling back to EVAL Use FCALL (Functions) when: β You run Redis 7.0 or later β You want scripts to survive server restarts without reloading β You want to deploy and version scripts like application code
Lua vs MULTI/EXEC β When to Use Which
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββ¬βββββββββββββββββββ β Need β MULTI/EXEC β Lua Script β ββββββββββββββββββββββββββββββββΌββββββββββββββββΌβββββββββββββββββββ€ β Queue fixed commands β β β β β β Branch on current values β β β β β β Loop over variable data β β β β β β Conditional writes β β (need WATCH β β β β Error handling per command β β β β (pcall) β β Return computed values β Partial β β β β Works in Redis Cluster β Same-slot β Same-slot β ββββββββββββββββββββββββββββββββ΄ββββββββββββββββ΄βββββββββββββββββββ
Key Points
- Lua scripts run inside Redis as a single atomic unit β no other client executes commands between lines of your script.
- Use EVAL to run a script. Pass key names as KEYS[n] and other values as ARGV[n]. Indices start at 1 in Lua.
- Redis returns nil as Lua false, not Lua nil. Check
if not v theninstead ofif v == nil then. - Always convert Redis string returns to numbers with tonumber() before doing arithmetic.
- Use redis.call() when an error should stop the script. Use redis.pcall() to catch errors and handle them inside the script.
- SCRIPT LOAD uploads a script once and returns a SHA1 hash. EVALSHA runs it by hash on every subsequent call, saving bandwidth.
- Keep scripts short β the Redis server cannot serve any other client while a Lua script runs. Target under 1 millisecond per script.
- Use redis.call('TIME') for timestamps inside scripts. os.time() and math.random() break AOF replay and replica replication.
- Redis 7 Functions (FCALL) are the modern replacement for EVALSHA β they persist across restarts and support named, versioned libraries.
- In Redis Cluster, all keys a script touches must live in the same hash slot. Use hash tags to enforce this.
