Scripting Redis with EVAL and Lua
Overview
Redis’s EVAL command lets you run Lua scripts directly inside the Redis server, making scripting Redis a first-class feature rather than a client-side workaround. 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...]
The four arguments have distinct roles. The script parameter is your Lua source code, sent as a plain string over the Redis protocol. The numkeys value tells Redis how many of the subsequent arguments are key names, which is essential for Redis Cluster to route the script to the correct node. Keys are listed after numkeys, and any remaining arguments become ARGV[1], ARGV[2], and so on inside the script. The simplest possible EVAL call fetches a single key value:
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
This one-liner demonstrates the core pattern: KEYS[1] contains the string "mykey", and redis.call('GET', KEYS[1]) runs the GET command inside Redis. The return value, which is the data stored at mykey, is sent back to the client. When you need to run the same script repeatedly, re-sending the full Lua source on every call wastes bandwidth. Redis solves this with script caching:
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
The SHA digest is deterministic — the same script always produces the same hash — so you can hardcode it in your application or retrieve it from Redis after the first EVAL. If the script cache is flushed (by SCRIPT FLUSH or a server restart), EVALSHA returns a NOSCRIPT error, and your client should fall back to EVAL with the full source. In OpenResty and Nginx environments, you can avoid the initial EVAL round-trip entirely by preloading scripts during server initialization. Call redis.call("SCRIPT", "LOAD", script) inside an init_by_lua block so every worker process has the script pre-cached at startup.
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 — they tell Redis which shard owns the data and which node should execute the script. Arguments are pure data; they are passed through to the Lua script without affecting routing decisions or cluster topology. Getting this separation right is critical in clustered deployments, where sending a key as an argument instead of a key will cause the script to run on the wrong node and produce a MOVED error. The simplest form passes a single key and a single argument:
-- 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"
This example sets a key to a value and reads it back, all in one atomic operation. The "1" after the script body tells Redis that exactly one key follows ("user:123:profile"), and the remaining argument "Alice" lands in ARGV[1]. When your script needs to operate on data that spans multiple keys, you must declare every key in the KEYS array and bump numkeys accordingly. This ensures the script executes on the node that owns all the keys, or fails early if the keys live on different shards:
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, you interact with Redis through two functions. redis.call() runs a Redis command and raises a Lua error if the command fails, which halts script execution and returns the error to the client. redis.pcall() does the same thing but returns an error table instead of raising, allowing your script to inspect the error and decide whether to retry, fall back, or abort gracefully. The choice between them depends on whether you can recover from a command failure or want the entire script to abort atomically:
-- 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
The first block of the script sets, reads, and deletes a key in sequence. The second block demonstrates pcall for safe error handling: if the GET fails on a nonexistent key, the script can inspect the error and continue rather than aborting. Every return value from redis.call() and redis.pcall() is automatically converted from the Redis protocol to a Lua type. Integers become Lua numbers, bulk strings become Lua strings, and Redis nil becomes Lua nil — exactly the mapping you would expect:
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) |
This type mapping is mostly transparent, but one subtlety trips up newcomers: all ARGV values arrive as Lua strings, even when you pass numbers from the client. If your script does arithmetic on an argument, you must call tonumber() first, or you will get a type error when Lua tries to add a string to a number. With these fundamentals in place, the next section shows how atomic read-modify-write patterns replace the non-atomic GET → compute → SET antipattern:
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
This script reads a counter, checks it against a threshold passed as ARGV[1], increments it only if the limit has not been reached, and returns the new count. The entire read-check-increment sequence is atomic — no other client can sneak an increment between the GET and the INCR. When the limit is hit, the script returns -1 so the caller can respond with a rate-limit error. Here is how you would invoke this from a Redis client:
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"
The EVAL syntax mirrors the Lua script structure: numkeys is 1, the single key is "rate:limit:api", and the limit argument "100" lands in ARGV[1]. You can adapt this pattern to any resource that needs atomic rate limiting, such as API endpoints, login attempts, or message queues, by changing the key prefix and the threshold value. Beyond simple string counters, Redis hashes let you store multiple fields under a single key, and Lua scripts can manipulate them just as atomically:
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)
Hash operations inside Lua scripts work exactly like their standalone counterparts: HSET writes multiple field-value pairs, HGET retrieves a single field, HEXISTS checks membership, and HINCRBY atomically increments a numeric field. The script runs inside Redis, so there is no network latency between these operations, and the entire hash update is atomic. The same atomicity applies to list and set operations, which are covered next:
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
The list example uses RPUSH to append an element and LTRIM to keep only the most recent 100 entries, a common pattern for activity feeds and log buffers. The set example checks membership with SISMEMBER before removing an element, avoiding an unnecessary SREM call. Both snippets benefit from running atomically: the list trim cannot race with another push, and the set membership check cannot go stale between the test and the removal. For operations that were traditionally done with pipelines, EVAL offers a stronger guarantee:
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
A pipeline sends multiple commands in a single network round-trip, but each command still executes independently — another client can run a command between INCR counter:a and INCR counter:b. With EVAL, the entire block of logic runs inside Redis as a single atomic unit, so no interleaving is possible. The equivalent Lua script does the same work in one call:
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 script increments both counters atomically and returns their new values as a Lua table. Redis converts this table to an array reply, so the client receives ["1", "1"] (or whatever the new counts are). This pattern scales to any number of operations: list pushes with trims, hash updates with expiry, or sorted-set insertions with cleanup. Knowing how Lua return types map to Redis protocol replies is essential for designing scripts that your client code can parse reliably:
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)
Simple return values like strings, numbers, and arrays map cleanly to Redis protocol types that every client library understands. Named-key tables like {ok = "saved", count = 5} are flattened into a positional array, which means the receiving client sees ["saved", 5] and must know the expected field order. For scripts that return structured data, it is safer to return an array explicitly or document the field sequence in the calling code. A common pattern returns values from multiple keys as a flat array:
-- 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"]
This example fetches values from two different keys and bundles them into a Lua table that Redis converts to an array reply. The client receives a positional list like ["value_from_key1", "value_from_key2"], which it can unpack by index. For scripts that return dozens of values, this flat-array approach keeps the protocol overhead low and the client parsing simple. One thing all script authors must plan for is the execution time limit, because Redis enforces a hard timeout on every script:
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
This message appears when a script exceeds the default 5-second limit, typically when iterating over large datasets or waiting on external resources. From another Redis client, issue SCRIPT KILL to terminate the script without shutting down the server. If the script has already modified data, SHUTDOWN NOSAVE discards all writes. Starting with Redis 7.2, you can set a per-script timeout to avoid hitting the global limit:
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)
The LIMIT argument tells Redis to allow this specific script up to 10,000 milliseconds instead of the global default. This is useful for scripts that do batch processing or iterate over large hash fields where you know the operation will take longer than 5 seconds. In web server environments, Redis scripting pairs naturally with OpenResty’s non-blocking I/O model to implement rate limiters and cache lookups at the edge:
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
The OpenResty example wires up a rate limiter directly in the Nginx request lifecycle. The script checks a counter key, increments it if below the threshold, and returns 1 (allowed) or 0 (blocked). Based on the result, the Lua block sets the HTTP status to 200 or 429 and sends the response body. This pattern avoids the extra hop to an application server entirely. For even lower latency, you can load the script into Redis once at Nginx startup rather than on every request:
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
}
Preloading scripts in init_by_lua ensures every worker process has the script SHA cached in Redis before it serves its first request. This eliminates the cold-start EVAL latency and the NOSCRIPT fallback path, giving you consistent sub-millisecond script execution from the very first call. With the integration patterns covered, the remaining edge cases are worth keeping in mind whenever you write Redis Lua scripts:
Gotchas
KEYS and ARGV are all strings. Even numeric arguments come in as strings, so you must convert with tonumber():
local limit = tonumber(ARGV[1]) -- required, ARGV[1] is always a string
Scripts run atomically but cannot call other scripts. EVAL does not recursively invoke 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/lua-openresty/openresty-redis/ covers connecting to Redis from OpenResty and basic operations
- /guides/lua-closures/ explains closures and upvalues, which are central to Lua script patterns
- /guides/lua-string-patterns/ walks through Lua pattern matching for data transformation inside Redis scripts
- /guides/lua-performance-tips/ covers table pre-allocation, local caching, and benchmarking techniques for optimizing Lua code