Scripting Redis with EVAL and Lua
Overview
Redis’s EVAL command lets you run Lua scripts directly inside the Redis server. The script executes atomically — no other command can run while your script is executing. This gives you something SQL databases have had for decades: server-side logic that touches your data without the penalty of multiple round-trips.
The practical benefit is this: operations that would normally require GET, compute, SET (with a race condition between the GET and SET) can be written as a single atomic script. For anything more than a simple GET/SET, EVAL is often the right tool.
Eval and evalsha
Basic syntax
EVAL script numkeys key[key...] arg[arg...]
script— the Lua code as a stringnumkeys— how many of the following arguments are Redis keyskey[key...]— the keys the script will touch (used for cluster routing)arg[arg...]— additional arguments passed as strings to the script
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
Evalsha and script caching
Every script is cached after its first run. Redis hashes the script and returns a SHA1 digest. Use EVALSHA to run a cached script without re-sending the full Lua source:
-- First call: EVAL caches the script and returns the SHA
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
-- "c6648c906b4a1691fdd4d39e6e4d924b1e691f19"
-- Later calls: reuse the SHA (works across all Redis clients)
EVALSHA c6648c906b4a1691fdd4d39e6e4d924b1e691f19 1 mykey
In OpenResty/Nginx, use redis.call("SCRIPT", "LOAD", script) to preload scripts at startup.
Keys VS arguments
The distinction between KEYS and ARGV matters for Redis Cluster. Keys determine which cluster node the script runs on. Arguments are pure data — they don’t affect routing.
-- KEYS[1] = user:123:profile, ARGV[1] = "Alice", ARGV[2] = "42"
EVAL "redis.call('SET', KEYS[1], ARGV[1]) return redis.call('GET', KEYS[1])" 1 "user:123:profile" "Alice"
If your script touches multiple keys, list them all in KEYS:
EVAL "
local balance = redis.call('GET', KEYS[1])
local transfer = redis.call('GET', KEYS[2])
redis.call('SET', KEYS[1], balance - ARGV[1])
redis.call('SET', KEYS[2], transfer + ARGV[1])
return 'done'
" 2 "account:a" "account:b" "100"
Working with Redis commands in Lua
Inside a Lua script, use redis.call() for commands that succeed and redis.pcall() for commands that might fail (it returns an error table instead of raising):
-- Set a key, get it back, delete it
redis.call('SET', KEYS[1], 'hello')
local val = redis.call('GET', KEYS[1])
redis.call('DEL', KEYS[1])
-- Using pcall for error handling
local ok, err = redis.pcall('GET', 'nonexistent')
if not ok then
-- handle Redis error
end
redis.call() returns Lua numbers for integers and Lua strings for bulk strings — exactly what you’d expect from the Redis protocol.
Converting between Lua and Redis types
Redis and Lua have compatible type representations:
| Redis | Lua |
|---|---|
| Integer reply | number |
| Bulk string | string |
| Array reply | table (consecutive integer keys starting at 1) |
| Nil | nil |
| Error | string (for errors from pcall) |
Atomic counters and incr in Lua
INCR is atomic, but sometimes you need atomic read-modify-write:
-- Atomically increment a counter only if it's below a threshold
local current = tonumber(redis.call('GET', KEYS[1])) or 0
if current < tonumber(ARGV[1]) then
redis.call('INCR', KEYS[1])
return current + 1
else
return -1 -- rate limited
end
EVAL "
local current = tonumber(redis.call('GET', KEYS[1])) or 0
if current < tonumber(ARGV[1]) then
redis.call('INCR', KEYS[1])
return current + 1
else
return -1
end
" 1 "rate:limit:api" "100"
Hash operations
-- Set multiple hash fields, then retrieve one field
redis.call('HSET', KEYS[1], 'name', ARGV[1], 'email', ARGV[2])
local name = redis.call('HGET', KEYS[1], 'name')
-- Check if a field exists
local exists = redis.call('HEXISTS', KEYS[1], 'name')
-- exists == 1 if field is present, 0 otherwise
-- Increment a hash field that stores a number
redis.call('HINCRBY', KEYS[1], 'visits', 1)
Lists and sets
-- Push to a list and trim to keep only the last N elements
redis.call('RPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], -100, -1)
-- Check set membership
local is_member = redis.call('SISMEMBER', KEYS[1], ARGV[1])
if is_member == 1 then
redis.call('SREM', KEYS[1], ARGV[1])
end
Pipelines with eval
Scripts replace pipelines for atomic multi-command operations. Instead of:
pipe = r.pipeline()
pipe.incr("counter:a")
pipe.incr("counter:b")
pipe.execute() # non-atomic
Use:
-- This runs atomically on the server
redis.call('INCR', 'counter:a')
redis.call('INCR', 'counter:b')
return {redis.call('GET', 'counter:a'), redis.call('GET', 'counter:b')}
The entire script runs atomically. No other command can interleave.
Returning data from scripts
Return values are converted from Lua to Redis protocol:
return "hello" -- bulk string
return 42 -- integer
return {1, 2, 3} -- array
return {ok = "saved", count = 5} -- array with named keys (flat)
-- Return multiple values as an array
local a = redis.call('GET', KEYS[1])
local b = redis.call('GET', KEYS[2])
return {a, b} -- ["value_from_key1", "value_from_key2"]
Script timeout
Redis has a default script timeout (usually 5 seconds). If your script runs longer, Redis kills it and returns an error:
BUSY Redis is busy running a script. You can call KILL at the next EVALSHA
Use SCRIPT KILL to abort a long-running script from another client, or SHUTDOWN NOSAVE if the script is truly stuck.
To set a custom timeout for a specific script (requires Redis 7.2+):
redis.call('EVAL', script, numkeys, ..., 'LIMIT', 10000)
Openresty integration
In OpenResty, send EVAL via the redis API:
local redis = require("resty.redis"):new()
redis:connect("127.0.0.1", 6379)
local script = [[
local current = tonumber(redis.call('GET', KEYS[1])) or 0
if current < tonumber(ARGV[1]) then
redis.call('INCR', KEYS[1])
return 1
end
return 0
]]
local key = "rate:limit:" .. ngx.var.arg_user_id
local limit = 100
local res, err = redis:eval(script, 1, key, limit)
if not res then
ngx.status = 500
ngx.say("Redis error: ", err)
return
end
if res == 1 then
ngx.status = 200
else
ngx.status = 429
ngx.say("Rate limit exceeded")
end
Preload scripts in the Nginx init_by_lua block for lower latency:
init_by_lua_block {
local redis = require("resty.redis")
local loaded_script = redis:new()
-- load scripts here
}
Gotchas
KEYS and ARGV are all strings. Even numeric arguments come in as strings — convert with tonumber():
local limit = tonumber(ARGV[1]) -- required, ARGV[1] is always a string
Script runs atomically but can’t call other scripts. EVAL cannot recursively call EVAL or EVALSHA. It can call SCRIPT LOAD and SCRIPT EXISTS.
Scripts are not sandboxed. Within Redis, Lua scripts have full access to Redis internals and can block the server. Only run scripts from trusted sources.
Return tables must use consecutive integer keys. Redis arrays are indexed starting at 1. If you return {a = 1, b = 2}, Redis may treat it unpredictably. Use {1, 2, 3} or flatten with "field1", "value1", "field2", "value2".
Use pcall when command failure is expected. redis.call() raises a Lua error on Redis errors (like key not found). Wrap in pcall when the error is expected and should be handled gracefully.
See also
- /tutorials/openresty-redis/ — connecting to Redis from OpenResty and basic operations
- /guides/lua-closures/ — closures and upvalues, which are central to Lua script patterns
- /guides/lua-string-patterns/ — Lua pattern matching for data transformation inside Redis scripts