Skip to content

Lua Scripting

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcript is one session in document order, replayed by the course harness (the script: <sha>, on @user_script:1. suffix on script errors is real server output). The SCRIPT KILL and restart outputs come from my own throwaway container and are not replayed by the harness. The Lua interpreter is Lua 5.1.

Transactions group commands but cannot branch on an intermediate result. If you need to read a value, decide, and write, all without another client slipping in, you need a script. A script runs entirely on the server: no round trips between its steps, and no other command runs while it executes (Redis Lua API).

EVAL script numkeys [key ...] [arg ...]: keys arrive in KEYS[1..], other arguments in ARGV[1..]. Always pass keys through KEYS (the server, and Redis Cluster, need to know which keys a script touches).

cli/tsl-lua-1.redis
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "hello"
OK
127.0.0.1:6379> GET mykey
"hello"
127.0.0.1:6379> EVAL "return {KEYS[1], ARGV[1], ARGV[2]}" 1 k a b
1) "k"
2) "a"
3) "b"
127.0.0.1:6379> EVAL "return redis.call('INCR', KEYS[1])" 1 counter
(integer) 1
127.0.0.1:6379> EVAL "return redis.call('INCR', KEYS[1])" 1 mykey
(error) ERR value is not an integer or out of range script: 61636018f4e6b5817b89791bbed242f93fa089e3, on @user_script:1.
127.0.0.1:6379> EVAL "return redis.pcall('INCR', KEYS[1])" 1 mykey
(error) ERR value is not an integer or out of range
127.0.0.1:6379> EVAL "local r = redis.pcall('INCR', KEYS[1]) if type(r) == 'table' and r.err then return 'caught: ' .. r.err end return r" 1 mykey
"caught: ERR value is not an integer or out of range"
127.0.0.1:6379> EVAL "redis.call('SET', KEYS[1], 'a') return redis.call('INCR', KEYS[1])" 1 z1
(error) ERR value is not an integer or out of range script: fcf4f339936374b211090ac0dcb064e100c050a9, on @user_script:1.
127.0.0.1:6379> GET z1
"a"
127.0.0.1:6379> EVAL "return 3.7" 0
(integer) 3
127.0.0.1:6379> EVAL "return false" 0
(nil)
127.0.0.1:6379> EVAL "return true" 0
(integer) 1
127.0.0.1:6379> EVAL "return {1, 2, {3, 'x'}}" 0
1) (integer) 1
2) (integer) 2
3) 1) (integer) 3
2) "x"
127.0.0.1:6379> EVAL_RO "return redis.call('GET', KEYS[1])" 1 mykey
"hello"
127.0.0.1:6379> EVAL_RO "return redis.call('SET', KEYS[1], 'x')" 1 mykey
(error) ERR Write commands are not allowed from read-only scripts. script: bd6f3018b28bbdb36260f19d3c44afed740db987, on @user_script:1.

What the session shows:

  • redis.call raises, redis.pcall returns. When the command fails, redis.call aborts the script and the client gets the error (with the script SHA and line appended). redis.pcall hands the error back as a table {err = ...} you can inspect and handle, as the “caught:” line does.
  • There is no rollback inside a script either. z1 was set to a before the failing INCR; the script aborted with an error, but GET z1 is still "a". Atomic means “nothing interleaves”, not “all or nothing”.
  • Return values are converted, and some information is lost. A Lua number is truncated to an integer (3.7 became 3), false becomes nil, true becomes 1, and a table becomes an array. Return strings if you need decimals.
  • EVAL_RO runs a script that must not write: the GET worked and the SET was refused. Use it (or FCALL_RO) for read-only logic so it can run on replicas.

Branching on the result of INCR is exactly what a transaction cannot do:

-- lua/tsl-lua-ratelimit.lua
-- rate limiter: increment; set the window on the first hit; return 1 if under the limit, else 0
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 1
else
return 0
end

Because the script is atomic, the INCR and the EXPIRE cannot be separated by a crash or another client (compare the two-command version in Rate Limiting).

EVAL "return redis.call('INCR', KEYS[1])" 1 counter
EVAL "return redis.call('INCR', KEYS[1])" 1 counter
GET counter

The script cache: SCRIPT LOAD, EVALSHA and NOSCRIPT

Section titled “The script cache: SCRIPT LOAD, EVALSHA and NOSCRIPT”

Re-sending the source on every call wastes bandwidth. SCRIPT LOAD compiles a script and returns its SHA1; EVALSHA sha numkeys ... runs it by hash. The SHA1 of the exact script text is the same everywhere, so the hash in this lesson (61636018...) is what SCRIPT LOAD "return redis.call('INCR', KEYS[1])" returns on any server.

cli/tsl-lua-2.redis
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
"61636018f4e6b5817b89791bbed242f93fa089e3"
127.0.0.1:6379> EVALSHA 61636018f4e6b5817b89791bbed242f93fa089e3 1 counter
(integer) 2
127.0.0.1:6379> SCRIPT EXISTS 61636018f4e6b5817b89791bbed242f93fa089e3 0000000000000000000000000000000000000000
1) (integer) 1
2) (integer) 0
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> EVALSHA 61636018f4e6b5817b89791bbed242f93fa089e3 1 counter
(error) NOSCRIPT No matching script. Please use EVAL.

(counter was already 1 from the first session, so EVALSHA returns 2.)

Where the cache lives. It is server-side memory, shared by every client connection, not something a client or its connection pool remembers. The script below proves it (connection A loads, connection B calls by SHA). SCRIPT FLUSH (or a server restart) empties it for everyone. I also restarted my container to check what survives:

$ redis-cli SCRIPT LOAD "return 1" # then FUNCTION LOAD ..., SET persist:me 1
$ docker restart redis-b3-db
$ redis-cli SCRIPT EXISTS e0e1f9fabfc9d4800c877a703b823ac0578ff8db
0 # the script cache is gone
$ redis-cli FUNCTION LIST # library "keep" is still there
$ redis-cli GET persist:me
1

The script cache did not survive the restart; the loaded function library and the data did. That is the practical difference between EVAL/SCRIPT LOAD scripts and Functions.

src/tsl-lua-scripting.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const a = new Redis(url);
const b = new Redis(url); // a second, independent connection
const cached = async () => Number(/number_of_cached_scripts:(\d+)/.exec(await a.info('memory'))![1]);
declare module 'ioredis' {
interface RedisCommander<Context> {
incrCapped(key: string, cap: number): Promise<number>;
}
}
await a.script('FLUSH');
console.log('scripts cached at start:', await cached());
// 1) the script cache lives on the SERVER: loaded on connection A, called by SHA on connection B
const src = `return redis.call('INCR', KEYS[1])`;
const sha = (await a.script('LOAD', src)) as string;
console.log('A loaded', sha, '| B calls it by SHA ->', await b.evalsha(sha, 1, 'b3:lua:n'));
// 2) SCRIPT FLUSH empties it for everyone: EVALSHA now fails with NOSCRIPT
await a.script('FLUSH');
try {
await b.evalsha(sha, 1, 'b3:lua:n');
} catch (e) {
console.log('after SCRIPT FLUSH, B ->', (e as Error).message);
}
// 3) defineCommand hides that: ioredis tries EVALSHA and falls back to EVAL on NOSCRIPT
a.defineCommand('incrCapped', {
numberOfKeys: 1,
lua: `local c = redis.call('INCR', KEYS[1])
if c > tonumber(ARGV[1]) then redis.call('SET', KEYS[1], ARGV[1]) return tonumber(ARGV[1]) end
return c`,
});
await a.del('b3:lua:cap');
const r1 = [await a.incrCapped('b3:lua:cap', 2), await a.incrCapped('b3:lua:cap', 2), await a.incrCapped('b3:lua:cap', 2)];
await a.script('FLUSH'); // cache wiped behind the client's back...
console.log('incrCapped x3 ->', r1.join(','), '| after a SCRIPT FLUSH the next call still works ->', await a.incrCapped('b3:lua:cap', 2));
// 4) the bug: a NEW script text per user (the id is pasted into the source) is a new cache entry each time
const evicted = async () => Number(/evicted_scripts:(\d+)/.exec(await a.info('stats'))![1]);
await a.script('FLUSH');
const e0 = await evicted();
let t = performance.now();
for (let i = 0; i < 2000; i++) await a.eval(`return redis.call('INCR', 'rate:user:${i}')`, 0);
console.log(`2000 scripts with the user id in the text : ${(performance.now() - t).toFixed(0)} ms | cached: ${await cached()} | evicted: ${(await evicted()) - e0}`);
await a.script('FLUSH');
const e1 = await evicted();
t = performance.now();
for (let i = 0; i < 2000; i++) await a.eval(`return redis.call('INCR', KEYS[1])`, 1, `rate:user:${i}`);
console.log(`2000 calls of ONE script, key in KEYS : ${(performance.now() - t).toFixed(0)} ms | cached: ${await cached()} | evicted: ${(await evicted()) - e1}`);
// ...and it is a code-injection hole when the id comes from a request
const evil = "x') and redis.call('SET','b3:lua:pwned','1') or redis.call('INCR','y";
await a.eval(`return redis.call('INCR', 'rate:user:${evil}')`, 0);
console.log('injected through the user id -> b3:lua:pwned =', await a.get('b3:lua:pwned'));
await a.del('b3:lua:pwned');
await a.eval(`return redis.call('INCR', KEYS[1])`, 1, `rate:user:${evil}`); // same input, passed as data
console.log('same input passed in KEYS[1] -> b3:lua:pwned =', await a.get('b3:lua:pwned'));
// 5) Functions (Redis 7+): named, persisted, replicated; called with FCALL
await a.call('FUNCTION', 'LOAD', 'REPLACE', `#!lua name=b3lib
redis.register_function('b3_hello', function(keys, args) return 'hello ' .. args[1] end)`);
console.log('FCALL ->', await a.fcall('b3_hello', 0, 'ioredis'));
await a.call('FUNCTION', 'DELETE', 'b3lib');
const del: string[] = ['b3:lua:n', 'b3:lua:cap', 'b3:lua:pwned', 'rate:user:x', 'y', `rate:user:${evil}`];
for (let i = 0; i < 2000; i++) del.push(`rate:user:${i}`);
await a.unlink(del);
await a.script('FLUSH');
a.disconnect(); b.disconnect();

Real output (this Mac):

scripts cached at start: 0
A loaded 61636018f4e6b5817b89791bbed242f93fa089e3 | B calls it by SHA -> 1
after SCRIPT FLUSH, B -> NOSCRIPT No matching script. Please use EVAL.
incrCapped x3 -> 1,2,2 | after a SCRIPT FLUSH the next call still works -> 2
2000 scripts with the user id in the text : 603 ms | cached: 500 | evicted: 1500
2000 calls of ONE script, key in KEYS : 642 ms | cached: 1 | evicted: 0
injected through the user id -> b3:lua:pwned = 1
same input passed in KEYS[1] -> b3:lua:pwned = null
FCALL -> hello ioredis
  • Shared cache: B ran a script by SHA that only A had loaded.
  • NOSCRIPT appears the moment the cache is flushed; a hand-written evalsha must catch it and fall back to EVAL.
  • defineCommand does that for you: ioredis sends EVALSHA and falls back to EVAL on NOSCRIPT, so the call after SCRIPT FLUSH just worked.
  • Script text is the cache key. 2000 scripts that differ only by a user id pasted into the source filled the cache to its limit of 500 and evicted 1500 of them (scripts run with EVAL are evicted in LRU order at 500; the server source has the comment “Users who abuse EVAL will generate a new lua script on each call, which can consume large amounts of memory over time”, eval.c; scripts from SCRIPT LOAD are never evicted). One script called 2000 times with the key in KEYS stays at 1 cached script. (The timings are similar here because the script is tiny; the cost is churn and the injection risk below.)
  • Functions (FUNCTION LOAD, FCALL) are the named, persisted alternative, next.

A function library is a named group of Lua functions registered with redis.register_function. Unlike EVAL scripts it is stored in the dataset: it survives restarts (as shown above), is included in RDB/AOF and is replicated, and you call it by name with FCALL name numkeys ....

cli/tsl-lua-3.redis
127.0.0.1:6379> FUNCTION LOAD REPLACE "#!lua name=tsl_lib\nredis.register_function('tsl_hello', function(keys, args) return 'hello ' .. args[1] end)\nredis.register_function{function_name='tsl_incr_cap', callback=function(keys, args) local c = redis.call('INCR', keys[1]) if c > tonumber(args[1]) then redis.call('SET', keys[1], args[1]) return tonumber(args[1]) end return c end}"
"tsl_lib"
127.0.0.1:6379> FCALL tsl_hello 0 world
"hello world"
127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2
(integer) 1
127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2
(integer) 2
127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2
(integer) 2
127.0.0.1:6379> FUNCTION LIST
1) 1) "library_name"
2) "tsl_lib"
3) "engine"
4) "LUA"
5) "functions"
6) 1) 1) "name"
2) "tsl_hello"
3) "description"
4) (nil)
5) "flags"
6) (empty array)
2) 1) "name"
2) "tsl_incr_cap"
3) "description"
4) (nil)
5) "flags"
6) (empty array)
127.0.0.1:6379> FUNCTION DELETE tsl_lib
OK

Functions get keys and arguments as the two parameters of the callback (keys, args, lower-case), the library lives in the server and is versioned with your deployment, and FUNCTION LOAD REPLACE updates it. For anything beyond a one-off script prefer Functions; keep EVAL for quick ad hoc logic (Redis Functions).

A script that loops forever stops every client, because commands run one at a time. After busy-reply-threshold (5000 ms by default; I set 200 ms for the demo) Redis starts answering other clients with BUSY and only accepts SCRIPT KILL or SHUTDOWN NOSAVE. Real output from my container:

$ redis-cli EVAL "while true do end" 0 & # never returns
$ redis-cli PING
BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
$ redis-cli SCRIPT KILL
OK
# the looping client then got:
ERR Script killed by user with SCRIPT KILL... script: 694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7, on @user_script:1.
$ redis-cli PING
PONG
$ redis-cli EVAL "redis.call('SET','b3:half','1') while true do end" 0 &
$ redis-cli SCRIPT KILL
UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.

SCRIPT KILL works only while the script has done no writes. A script that already wrote is UNKILLABLE (killing it would leave a half-applied change) and the only exit is SHUTDOWN NOSAVE. So keep scripts short and bounded: no unbounded loops, no KEYS, no huge SMEMBERS.

An assistant writes a Node helper around a Lua rate limiter and “keeps the script self-contained” by interpolating the user id into the source:

🐛 Spot the bug

async function hit(userId: string) {
return redis.eval(
`local c = redis.call('INCR', 'rate:user:${userId}')
if c == 1 then redis.call('EXPIRE', 'rate:user:${userId}', 60) end
return c`,
0,
);
}
Show the answer

Two proven problems. Every user is a new script: 2000 different ids evicted 1500 scripts from the 500-entry cache and none could be reused by EVALSHA, whereas one script with the key in KEYS stayed at 1 cached entry. Worse, the id is code: a request-controlled id such as x') and redis.call('SET','b3:lua:pwned','1') or redis.call('INCR','y ran the attacker’s SET on the server (pwned = 1); the same string passed as data in KEYS[1] did nothing. Keep the source constant and pass the values as arguments:

redis.defineCommand('hit', {
numberOfKeys: 1,
lua: `local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return c`,
});
await redis.hit(`rate:user:${userId}`, 60); // EVALSHA, one cached script, no injection

A script is handing the clerk a written procedure and leaving the counter closed while they follow it. They cannot be interrupted, which is what makes it atomic, and every other customer waits, which is why it must be short. The procedure book (the script cache) is the shop’s, shared by every window, and it is thrown away when the shop is restarted; only a procedure you filed properly as a Function survives. If the clerk writes something and then stumbles, what they wrote stays.

ChoiceBenefitCost
Lua script (EVAL) instead of MULTI/EXECAtomic branching logicHarder to write and debug; error text is Redis-flavoured
EVALSHA / defineCommandSends a 40-character hash instead of the sourceYou must handle NOSCRIPT (ioredis defineCommand does)
Functions instead of EVALNamed, persisted, replicated, versioned with deploysRedis 7.0+, must be loaded and replaced on deploy
EVAL_RO / FCALL_ROSafe on replicas, refuses writesCannot write
Atomic executionNo interleavingA long script blocks every client (BUSY, SCRIPT KILL only before the first write)
  • “The script cache is per connection (or per client pool).” It is server memory shared by all clients, emptied by SCRIPT FLUSH and restarts (proven with two connections and a restart).
  • “A failing script rolls back.” It stops; earlier writes stay (z1 = a).
  • “SCRIPT KILL always works.” Not after the script has written (UNKILLABLE).
  • “Returning a Lua number preserves decimals.” It truncates to an integer.
  • “EVAL scripts are persisted like data.” Only Functions are.
  • Long or looping scripts — everything stops, and a script that already wrote cannot be killed.
  • Ignoring NOSCRIPT — after a restart or SCRIPT FLUSH, fall back to EVAL (or use defineCommand).
  • Pasting values into the script text — cache churn and code injection (proven above).
  • Using redis.call when you want to handle the error — use redis.pcall.
  • Touching keys not passed in KEYS — breaks Redis Cluster routing and replica safety.

Real-world example

Redis’s own documentation recommends Functions over EVAL for maintained logic and describes the script cache semantics (“scripts are meant to stay indefinitely in the cache”) in Scripting with Lua and Redis functions.

Inside a Lua script, how do you read the first key passed to EVAL?
A command inside a script fails. What is the difference between redis.call and redis.pcall?
A script does SET z1 a and then a failing INCR. What does GET z1 return afterwards?
Where does the script cache live, and what happens on a server restart?
What does EVALSHA return when the script is not cached?
A script loops forever after writing a key. What can you do?
Why is building a script's source with the user id pasted in a bug?