luaguides

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 string
  • numkeys — how many of the following arguments are Redis keys
  • key[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:

RedisLua
Integer replynumber
Bulk stringstring
Array replytable (consecutive integer keys starting at 1)
Nilnil
Errorstring (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