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 (thescript: <sha>, on @user_script:1.suffix on script errors is real server output). TheSCRIPT KILLand restart outputs come from my own throwaway container and are not replayed by the harness. The Lua interpreter is Lua 5.1.
Why Lua scripting?
Section titled “Why Lua scripting?”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, redis.call and redis.pcall
Section titled “EVAL, redis.call and redis.pcall”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).
127.0.0.1:6379> SCRIPT FLUSHOK127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "hello"OK127.0.0.1:6379> GET mykey"hello"127.0.0.1:6379> EVAL "return {KEYS[1], ARGV[1], ARGV[2]}" 1 k a b1) "k"2) "a"3) "b"127.0.0.1:6379> EVAL "return redis.call('INCR', KEYS[1])" 1 counter(integer) 1127.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 range127.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) 3127.0.0.1:6379> EVAL "return false" 0(nil)127.0.0.1:6379> EVAL "return true" 0(integer) 1127.0.0.1:6379> EVAL "return {1, 2, {3, 'x'}}" 01) (integer) 12) (integer) 23) 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.callraises,redis.pcallreturns. When the command fails,redis.callaborts the script and the client gets the error (with the script SHA and line appended).redis.pcallhands 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.
z1was set toabefore the failingINCR; the script aborted with an error, butGET z1is 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.7became3),falsebecomes nil,truebecomes1, and a table becomes an array. Return strings if you need decimals. EVAL_ROruns a script that must not write: theGETworked and theSETwas refused. Use it (orFCALL_RO) for read-only logic so it can run on replicas.
A conditional script: the rate limiter
Section titled “A conditional script: the rate limiter”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 0local current = redis.call('INCR', KEYS[1])if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1])endif current <= tonumber(ARGV[2]) then return 1else return 0endBecause 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 counterThe 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.
127.0.0.1:6379> SCRIPT FLUSHOK127.0.0.1:6379> SCRIPT LOAD "return redis.call('INCR', KEYS[1])""61636018f4e6b5817b89791bbed242f93fa089e3"127.0.0.1:6379> EVALSHA 61636018f4e6b5817b89791bbed242f93fa089e3 1 counter(integer) 2127.0.0.1:6379> SCRIPT EXISTS 61636018f4e6b5817b89791bbed242f93fa089e3 00000000000000000000000000000000000000001) (integer) 12) (integer) 0127.0.0.1:6379> SCRIPT FLUSHOK127.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 e0e1f9fabfc9d4800c877a703b823ac0578ff8db0 # the script cache is gone$ redis-cli FUNCTION LIST # library "keep" is still there$ redis-cli GET persist:me1The 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.
Using scripts from ioredis
Section titled “Using scripts from ioredis”// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const a = new Redis(url);const b = new Redis(url); // a second, independent connectionconst 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 Bconst 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 NOSCRIPTawait 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 NOSCRIPTa.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 timeconst 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 requestconst 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 dataconsole.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 FCALLawait a.call('FUNCTION', 'LOAD', 'REPLACE', `#!lua name=b3libredis.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: 0A loaded 61636018f4e6b5817b89791bbed242f93fa089e3 | B calls it by SHA -> 1after SCRIPT FLUSH, B -> NOSCRIPT No matching script. Please use EVAL.incrCapped x3 -> 1,2,2 | after a SCRIPT FLUSH the next call still works -> 22000 scripts with the user id in the text : 603 ms | cached: 500 | evicted: 15002000 calls of ONE script, key in KEYS : 642 ms | cached: 1 | evicted: 0injected through the user id -> b3:lua:pwned = 1same input passed in KEYS[1] -> b3:lua:pwned = nullFCALL -> hello ioredis- Shared cache: B ran a script by SHA that only A had loaded.
NOSCRIPTappears the moment the cache is flushed; a hand-writtenevalshamust catch it and fall back toEVAL.defineCommanddoes that for you: ioredis sendsEVALSHAand falls back toEVALonNOSCRIPT, so the call afterSCRIPT FLUSHjust 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
EVALare 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 fromSCRIPT LOADare never evicted). One script called 2000 times with the key inKEYSstays 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.
Functions (Redis 7.0+)
Section titled “Functions (Redis 7.0+)”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 ....
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) 1127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2(integer) 2127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2(integer) 2127.0.0.1:6379> FUNCTION LIST1) 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_libOKFunctions 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 blocks the server: SCRIPT KILL
Section titled “A script blocks the server: SCRIPT KILL”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 PINGBUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.$ redis-cli SCRIPT KILLOK# the looping client then got:ERR Script killed by user with SCRIPT KILL... script: 694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7, on @user_script:1.$ redis-cli PINGPONG
$ redis-cli EVAL "redis.call('SET','b3:half','1') while true do end" 0 &$ redis-cli SCRIPT KILLUNKILLABLE 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.
Reading AI-generated code
Section titled “Reading AI-generated code”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 injectionMental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Lua script (EVAL) instead of MULTI/EXEC | Atomic branching logic | Harder to write and debug; error text is Redis-flavoured |
EVALSHA / defineCommand | Sends a 40-character hash instead of the source | You must handle NOSCRIPT (ioredis defineCommand does) |
Functions instead of EVAL | Named, persisted, replicated, versioned with deploys | Redis 7.0+, must be loaded and replaced on deploy |
EVAL_RO / FCALL_RO | Safe on replicas, refuses writes | Cannot write |
| Atomic execution | No interleaving | A long script blocks every client (BUSY, SCRIPT KILL only before the first write) |
Common Misconceptions
Section titled “Common Misconceptions”- “The script cache is per connection (or per client pool).” It is server memory shared by all clients, emptied by
SCRIPT FLUSHand restarts (proven with two connections and a restart). - “A failing script rolls back.” It stops; earlier writes stay (
z1=a). - “
SCRIPT KILLalways works.” Not after the script has written (UNKILLABLE). - “Returning a Lua number preserves decimals.” It truncates to an integer.
- “
EVALscripts are persisted like data.” Only Functions are.
Common mistakes
Section titled “Common mistakes”- Long or looping scripts — everything stops, and a script that already wrote cannot be killed.
- Ignoring
NOSCRIPT— after a restart orSCRIPT FLUSH, fall back toEVAL(or usedefineCommand). - Pasting values into the script text — cache churn and code injection (proven above).
- Using
redis.callwhen you want to handle the error — useredis.pcall. - Touching keys not passed in
KEYS— breaks Redis Cluster routing and replica safety.
Real-world example
Redis’s own documentation recommends Functions over
EVALfor maintained logic and describes the script cache semantics (“scripts are meant to stay indefinitely in the cache”) in Scripting with Lua and Redis functions.