Lua Scripting
Redis Lua scripts run atomically on the server — safe for multi-step operations that should be all-or-nothing. EVAL runs inline; EVALSHA caches by hash. Common for rate limits, atomic move/dedup, counters with caps.
EVAL, EVALSHA, atomic patterns
EXAMPLE
# 1) Basic EVAL
EVAL "return 'hello'" 0
# Result: 'hello'
# Numbers
EVAL "return 42" 0
# Result: 42
# Tables (arrays)
EVAL "return {1, 2, 3}" 0
# Result: 1) 1 2) 2 3) 3
# 2) Pass KEYS + ARGV
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey 'hello'
# Returns 'OK'
EVAL "return redis.call('INCRBY', KEYS[1], ARGV[1])" 1 counter 10
# Returns new value
# 3) Atomicity guarantee — script runs as ONE Redis command
# No other client can interleave between calls within the script.
# IMPORTANT: this is single-threaded; long scripts block the entire server.
# === Real recipes ===
# 4) Atomic compare-and-set
EVAL "
local val = redis.call('GET', KEYS[1])
if val == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
" 1 mykey oldvalue newvalue
# Returns 1 if swap happened, 0 if mykey wasn't 'oldvalue'
# 5) Rate limit — fixed window with auto-expire
EVAL "
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
end
return 1
" 1 rate:user:42 60 100
# KEYS[1] = key, ARGV[1] = window seconds, ARGV[2] = max count
# Returns 1 (allowed) or 0 (denied)
# 6) Sliding-window rate limit (more accurate)
EVAL "
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- Remove entries older than window
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window * 1000)
-- Count current requests in window
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
return 0
end
-- Add current request
redis.call('ZADD', KEYS[1], now, now)
redis.call('EXPIRE', KEYS[1], window)
return 1
" 1 rate:user:42 1700000000000 60 100
# 7) Atomic INCR with cap
EVAL "
local v = redis.call('INCR', KEYS[1])
if v > tonumber(ARGV[1]) then
redis.call('DECR', KEYS[1])
return -1
end
return v
" 1 inventory:item:A100 100
# If current value > 100, decrement and return -1 (rejected); else return new value
# 8) Atomic LPUSH + LTRIM (bounded list)
EVAL "
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], 0, tonumber(ARGV[2]) - 1)
return redis.call('LLEN', KEYS[1])
" 1 feed:user:42 'new event data' 100
# Keeps only the most recent 100 entries
# 9) Distributed lock (Redlock-style basics)
# Acquire
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
end
" 1 lock:resource unique-token-abc123 30000
# Release (only if we still hold it)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
" 1 lock:resource unique-token-abc123
# 10) Cache stampede protection — early refresh
EVAL "
local v = redis.call('GET', KEYS[1])
local ttl = redis.call('TTL', KEYS[1])
if not v or ttl < 30 then
redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))
return ARGV[1]
end
return v
" 1 cache:slow_query 'new fresh value' 300
# If cached value is close to expiring, refresh it; otherwise return existing
# 11) Move with dedup — atomic LPOP + check for duplicate
EVAL "
local item = redis.call('RPOPLPUSH', KEYS[1], KEYS[2])
if item and redis.call('SISMEMBER', KEYS[3], item) == 1 then
redis.call('LREM', KEYS[2], 1, item)
return nil
end
redis.call('SADD', KEYS[3], item)
return item
" 3 queue:in queue:processing seen:items
# 12) EVALSHA — cache the script
# Load script once, get SHA1 back
SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
# Returns SHA1 like 'e0e1f9fabfc9d4800c877a703b823ac0578ff831'
EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff831 1 counter
# Saves network bandwidth (don't send script text repeatedly)
# If 'NOSCRIPT' error → re-load script
# 13) Lua API inside Redis scripts
redis.call('GET', KEYS[1]) -- Throws Lua error on Redis error
redis.pcall('GET', KEYS[1]) -- Returns error object
redis.status_reply('OK') -- Wrap a status reply
redis.error_reply('My error message') -- Wrap an error
redis.log(redis.LOG_WARNING, 'msg') -- Log to Redis log
# Type conversion:
redis.call returns:
string → Lua string
int → Lua number
table → Lua table (1-indexed)
nil → false (NOT nil in Lua!)
Lua → Redis:
nil/false → null reply
number → integer
string → bulk string
table → array
# 14) Lua built-ins available
tostring(), tonumber(), type()
string.format(), string.sub(), string.upper()
table.insert(), table.remove(), table.concat()
math.floor(), math.ceil(), math.random()
ipairs(), pairs()
string.find(), string.match(), string.gmatch()
# NOT available: os, io, network — sandboxed by design
# 15) Functions (Redis 7+) — replacement for EVAL with cleaner deployment
FUNCTION LOAD "#!lua name=mylib
redis.register_function('rate_limit', function(KEYS, ARGV)
local current = redis.call('INCR', KEYS[1])
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return current
end)"
FCALL rate_limit 1 mykey 60
# Functions are persisted (replicated, survive restart) — better for production than EVAL.
# 16) Node + ioredis
import Redis from 'ioredis';
const redis = new Redis();
// Define the script
const script = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
`;
// Define a custom command
redis.defineCommand('rateLimit', {
numberOfKeys: 1,
lua: script,
});
// Use it
const count = await redis.rateLimit('rate:user:42', 60);
// 17) Python + redis-py
import redis
r = redis.Redis()
rate_limit = r.register_script('''
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
''')
count = rate_limit(keys=['rate:user:42'], args=[60])
# 18) Performance
# ✅ Server-side atomicity — no network round-trips
# ❌ Blocks Redis while running (single-threaded)
# ❌ Long scripts slow ALL clients
# ✅ EVALSHA saves bandwidth
# ✅ Functions (Redis 7+) persist + replicate
# 19) Common bugs
# • Forgetting that Redis-nil becomes Lua false (NOT nil)
# • Lua tables 1-indexed, not 0
# • Long-running script blocks server (max ~5s with lua-time-limit setting)
# • Modifying KEYS in script that aren't in the KEYS argument → CLUSTER errors
# • Forgetting to convert ARGV strings to numbers (tonumber)
# • Script SHA changes if you tweak whitespace → use Functions for stability
# 20) Best practices
# ✅ Pass ALL keys via KEYS, all values via ARGV (cluster safety)
# ✅ Keep scripts SHORT — they block the server
# ✅ Use EVALSHA for repeated scripts (less bandwidth)
# ✅ Migrate to Functions (Redis 7+) for production logic
# ✅ Test scripts with edge cases — Lua nil semantics surprise people
# ✅ Use redis.pcall for error handling within scripts
# ✅ For complex multi-key transactions, consider MULTI/EXEC instead of Lua
Why it matters
Lua scripts make multi-step Redis ops atomic. Pass ALL keys via KEYS for cluster safety; keep them short (they block the server); use Redis Functions (7+) for stable, persistent server-side logic.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
EVAL "redis.call('SET', KEYS[1], ARGV[1]); return redis.call('GET', KEYS[1])" 1 myKey "42"
Try it Yourself »
Discussion
Loading…