Redis Lua Scripting

Redis lets you write small programs in the Lua language and run them directly inside the Redis server. The entire script executes as one atomic unit β€” no other client can run commands while your script is running. This solves problems that plain transactions cannot handle, because Lua scripts read current values and make decisions based on them before writing anything back.

Why Lua Scripting Exists

Redis commands are fast but simple. Sometimes your app needs to read a value, make a decision based on it, and then write β€” all without another client slipping in between. MULTI/EXEC queues commands but cannot branch on live data. Lua fills that gap.

Problem: Check stock before decrementing (two separate commands are unsafe)

Step 1: App sends GET stock:item:88   β†’  Redis returns "3"
                                          (another client reads "3" too)
Step 2: App sends DECR stock:item:88  β†’  Redis returns "2"
        Other client sends DECR       β†’  Redis returns "1"
        (Both clients thought they got the last item β€” race condition)

Solution: Wrap both steps in one Lua script

  local stock = redis.call('GET', KEYS[1])
  if tonumber(stock) > 0 then
    redis.call('DECR', KEYS[1])
    return 1   -- purchase approved
  end
  return 0     -- out of stock

Redis executes this entire block atomically.
No other client runs between the GET and the DECR.

The Kitchen Chef Analogy

Without Lua, your app is a waiter making one trip to the kitchen per item. Lua scripting gives the chef a recipe card. The chef reads it, cooks everything in order, and returns a finished plate. No back-and-forth. Nobody else enters the kitchen while the chef works.

Without Lua β€” multiple round trips, gaps between each:

  App ──GET──▢ Redis ──"3"──▢ App
  App ──DECR─▢ Redis ──"2"──▢ App
  (gap between these two = window for race condition)

With Lua β€” one round trip, zero gaps:

  App ──EVAL script──▢ Redis
                       β”‚
                  Lua runs:
                  GET β†’ decide β†’ DECR
                       β”‚
                     result ──▢ App

Lua Language Basics You Need for Redis

You do not need to be a Lua expert. Redis scripts use a small, predictable subset of the language. These are the only constructs you will use in almost every script.

Variables

local name = "Alice"       -- string
local count = 10           -- number
local active = true        -- boolean
local nothing = nil        -- empty / not found

Always use 'local' inside Redis scripts.
Global variables persist between script calls in the same Redis session
and cause hard-to-find bugs.

String to Number Conversion

Redis always returns values as strings, even if they look like numbers.
You must convert before doing arithmetic.

local raw = redis.call('GET', 'score')   -- returns "42" (string)
local num = tonumber(raw)                -- converts to 42 (number)
local result = num + 10                  -- 52

tostring(52)   -- converts number back to "52" (string)

If / Else

local stock = tonumber(redis.call('GET', KEYS[1]))

if stock == nil then
  return -1              -- key does not exist
elseif stock > 0 then
  return 1               -- in stock
else
  return 0               -- out of stock
end

Loops

-- Numeric for loop (count from 1 to 5)
for i = 1, 5 do
  redis.call('RPUSH', 'mylist', i)
end

-- While loop
local i = 0
while i < 10 do
  i = i + 1
end

-- Loop over a Lua table (array)
local items = {'a', 'b', 'c'}
for index, value in ipairs(items) do
  -- index = 1, 2, 3  (Lua arrays start at 1, not 0)
  -- value = 'a', 'b', 'c'
end

Tables (Arrays and Maps)

-- Array-style table
local colors = {'red', 'green', 'blue'}
colors[1]   -- "red"   (index starts at 1)
#colors     -- 3        (length operator)

-- Map-style table
local user = {name = 'Bob', age = 25}
user.name   -- "Bob"
user['age'] -- 25

Functions

local function clamp(value, min, max)
  if value < min then return min end
  if value > max then return max end
  return value
end

local score = clamp(tonumber(redis.call('GET', 'score')), 0, 100)

The EVAL Command – Full Syntax

EVAL script numkeys key [key ...] arg [arg ...]

  script    β†’ the Lua code as a string
  numkeys   β†’ count of key names that follow
  key ...   β†’ key names, available inside Lua as KEYS[1], KEYS[2], ...
  arg ...   β†’ extra values, available inside Lua as ARGV[1], ARGV[2], ...

Keys and ARGV indices start at 1 in Lua (not 0).

No Keys, No Args – Simplest Form

127.0.0.1:6379> EVAL "return 7 * 6" 0
(integer) 42

One Key, No Args

127.0.0.1:6379> SET greeting "Hello"
OK
127.0.0.1:6379> EVAL "return redis.call('GET', KEYS[1])" 1 greeting
"Hello"

Two Keys and Two Args

Goal: Copy value from key1 to key2 with a TTL from ARGV[1]

EVAL "
  local val = redis.call('GET', KEYS[1])
  if val then
    redis.call('SET', KEYS[2], val)
    redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1]))
    return 1
  end
  return 0
" 2 source:key dest:key 300

  numkeys = 2
  KEYS[1] = source:key
  KEYS[2] = dest:key
  ARGV[1] = "300"   (TTL in seconds)

Passing a List of Values via ARGV

Goal: Push multiple items into a list in one script call

EVAL "
  for i = 1, #ARGV do
    redis.call('RPUSH', KEYS[1], ARGV[i])
  end
  return redis.call('LLEN', KEYS[1])
" 1 myqueue "task-A" "task-B" "task-C"

  KEYS[1] = myqueue
  ARGV    = {"task-A", "task-B", "task-C"}
  #ARGV   = 3  (length of the args table)

Returns: (integer) 3

Data Type Conversions Between Lua and Redis

Redis and Lua have different type systems. Redis automatically converts values when moving between the two. Understanding these conversions prevents confusing nil returns and unexpected errors.

Lua β†’ Redis return type conversion:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Lua Type            β”‚  Redis Reply Type              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  number (integer)    β”‚  integer reply                 β”‚
β”‚  string              β”‚  bulk string reply             β”‚
β”‚  table (array)       β”‚  multi-bulk reply (array)      β”‚
β”‚  false / nil         β”‚  nil bulk reply                β”‚
β”‚  true                β”‚  integer reply: 1              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Redis β†’ Lua conversion (what redis.call returns):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Redis Reply               β”‚  Lua Value                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  integer reply             β”‚  number                    β”‚
β”‚  bulk string               β”‚  string                    β”‚
β”‚  nil bulk reply            β”‚  false (not nil!)          β”‚
β”‚  status reply (OK)         β”‚  table with ok field       β”‚
β”‚  error reply               β”‚  table with err field      β”‚
β”‚  multi-bulk (array)        β”‚  table                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key trap: Redis nil comes back as Lua false, not Lua nil.
Check for false, not nil, when testing a GET result.

Wrong:
  local v = redis.call('GET', 'missing_key')
  if v == nil then ...end     -- never triggers!

Correct:
  local v = redis.call('GET', 'missing_key')
  if not v then ...end       -- triggers correctly for nil/false
  if v == false then ...end  -- also correct

redis.call vs redis.pcall

Both functions run a Redis command from inside Lua. The difference is what happens when that command fails.

redis.call β€” raises an error, stops the script immediately

  local result = redis.call('INCR', 'mystring')
  -- If 'mystring' holds "hello", Redis returns an error.
  -- Script stops here. Error propagates to the caller.

redis.pcall β€” catches the error, returns it as a table

  local result = redis.pcall('INCR', 'mystring')
  if type(result) == 'table' and result.err then
    -- Command failed. result.err holds the error message.
    return -1   -- handle gracefully
  end
  return result

Flow diagram:

  redis.call fails:
    Script ──▢ redis.call ──▢ Redis error
                                   β”‚
                              Script stops
                              Error returned to app

  redis.pcall fails:
    Script ──▢ redis.pcall ──▢ Redis error
                                   β”‚
                           error table returned
                           to your Lua code
                                   β”‚
                           if result.err then
                             handle it
                           end
                           Script continues

Returning Values from Lua Scripts

Return a Single String

EVAL "return 'purchase approved'" 0
β†’ "purchase approved"

Return a Number

EVAL "return 42" 0
β†’ (integer) 42

Note: Lua floats are truncated to integers when returned to Redis.
  return 3.9  β†’  (integer) 3   (not 3.9!)

To return a float, wrap it as a string:
  return tostring(3.9)  β†’  "3.9"

Return a Table (Array)

EVAL "return {'apple', 'banana', 'cherry'}" 0
β†’ 1) "apple"
   2) "banana"
   3) "cherry"

Return OK or Error Status

-- Return a Redis status reply (like "OK")
return redis.status_reply('OK')

-- Return a Redis error reply
return redis.error_reply('Not enough balance')
β†’ (error) Not enough balance

Real-World Script 1 – Atomic Inventory Decrement

This script checks stock, decrements it only if available, and returns a status code the app can act on.

Script:
  local key   = KEYS[1]
  local qty   = tonumber(ARGV[1])
  local stock = tonumber(redis.call('GET', key))

  if not stock then
    return -1    -- item does not exist
  end

  if stock < qty then
    return 0     -- not enough stock
  end

  redis.call('DECRBY', key, qty)
  return 1       -- success

Call:
  EVAL "<script>" 1 stock:item:88 3
  β†’ 1   (3 units reserved)
  β†’ 0   (fewer than 3 in stock)
  β†’ -1  (item key not found)

Flow:
  App calls EVAL
       β”‚
       β–Ό
  Redis runs Lua atomically
       β”‚
  GET stock  ──▢  "10"
       β”‚
  10 >= 3?  YES
       β”‚
  DECRBY stock 3  ──▢  "7"
       β”‚
  return 1  ──▢  App reads 1 = success

Real-World Script 2 – Distributed Lock (Mutex)

Two servers must never run the same background job at the same time. A Redis lock prevents that. The lock is a key with an expiry. Only one server can create it (using SETNX semantics). The release script makes sure only the server that owns the lock can delete it.

Acquire the lock:
  SET lock:job:invoice "server-A" NX EX 30
  β†’ OK   (server-A owns the lock for 30 seconds)
  β†’ nil  (another server holds the lock)

  NX = set only if key does Not eXist
  EX = expire after 30 seconds (auto-release if server crashes)

Release the lock β€” MUST be atomic (use Lua):

  Script:
    if redis.call('GET', KEYS[1]) == ARGV[1] then
      redis.call('DEL', KEYS[1])
      return 1    -- lock released
    end
    return 0      -- not our lock, do nothing

  Call:
    EVAL "<script>" 1 lock:job:invoice "server-A"
    β†’ 1  (lock deleted β€” server-A owned it)
    β†’ 0  (lock belongs to another server or already expired)

Why Lua is required for the release:
  Without Lua:
    Server A: GET lock β†’ "server-A" βœ“ (I own it)
    (lock expires here, server B acquires it)
    Server A: DEL lock                  ← deletes server B's lock! Bug!

  With Lua:
    GET and DEL happen atomically.
    No window for another server to acquire in between.

Full lifecycle:
  Server A                   Redis                   Server B
     β”‚                         β”‚                        β”‚
     β”œβ”€β”€ SET lock NX EX 30 ───▢│                        β”‚
     │◀── OK ─────────────────│                        β”‚
     β”‚   (owns lock)           β”‚                        β”‚
     β”‚                         β”‚    SET lock NX EX 30 ──
     β”‚                         │◀── nil ────────────────
     β”‚   (runs the job)        β”‚   (waits or skips)    β”‚
     β”‚                         β”‚                        β”‚
     β”œβ”€β”€ EVAL release "server-A"β–Άβ”‚                      β”‚
     │◀── 1 ──────────────────│                        β”‚
     β”‚   (lock released)       β”‚                        β”‚

Real-World Script 3 – Leaderboard Score Update with Rank Return

This script updates a player's score in a Sorted Set and returns the player's new rank β€” all in one round trip.

Script:
  local board   = KEYS[1]
  local player = ARGV[1]
  local points = tonumber(ARGV[2])

  redis.call('ZINCRBY', board, points, player)

  local rank = redis.call('ZREVRANK', board, player)
  -- ZREVRANK returns 0-indexed rank (0 = top player)

  return rank + 1    -- convert to 1-indexed

Call:
  EVAL "<script>" 1 leaderboard:game1 "Alice" 500
  β†’ (integer) 2    (Alice is now ranked 2nd)

Without Lua β€” two round trips with a gap:
  App: ZINCRBY leaderboard:game1 500 Alice
  (another player's score updates here β€” rank shifts!)
  App: ZREVRANK leaderboard:game1 Alice
  β†’ stale rank

With Lua β€” rank reflects the score update instantly.

Real-World Script 4 – Atomic Rate Limiter

This script increments a counter for an IP address and sets an expiry only on the first request of the window. Because both steps are in one script, there is no gap where a concurrent request could skip the EXPIRE call.

Script:
  local key    = KEYS[1]
  local limit  = tonumber(ARGV[1])
  local window = tonumber(ARGV[2])

  local current = redis.call('INCR', key)

  if current == 1 then
    redis.call('EXPIRE', key, window)
  end

  if current > limit then
    return 0   -- blocked
  end

  return 1     -- allowed

Call:
  EVAL "<script>" 1 rate:192.168.1.5 100 60
  β†’ 1  (allowed)
  β†’ 0  (blocked β€” over 100 requests in 60 seconds)

Timeline for one IP address:
  Request 1:  INCR β†’ 1,  EXPIRE 60 set,  return 1 (allowed)
  Request 2:  INCR β†’ 2,  no EXPIRE,      return 1 (allowed)
  ...
  Request 100: INCR β†’ 100,               return 1 (allowed)
  Request 101: INCR β†’ 101,               return 0 (blocked)
  (60 seconds pass β†’ key expires β†’ counter resets)
  Request 1:  INCR β†’ 1,  EXPIRE 60 set,  return 1 (allowed again)

Caching Scripts with SCRIPT LOAD and EVALSHA

Sending the full Lua script text on every EVAL call wastes bandwidth, especially for long scripts called thousands of times per second. SCRIPT LOAD uploads the script once and gives you a SHA1 fingerprint. You use that fingerprint with EVALSHA on every subsequent call.

Step 1 – Upload the script once at app startup

  SCRIPT LOAD "
    local v = redis.call('GET', KEYS[1])
    if not v then return 0 end
    return tonumber(v)
  "
  β†’ "7f2d435f5b4c9e8a1c3d6e9f0b2a4c8d1e3f5a7b"   (SHA1 hash)

Step 2 – Call by hash on every request

  EVALSHA 7f2d435f5b4c9e8a1c3d6e9f0b2a4c8d1e3f5a7b 1 visits
  β†’ (integer) 2048

Step 3 – Check whether a script is cached

  SCRIPT EXISTS 7f2d435f5b4c9e8a1c3d6e9f0b2a4c8d1e3f5a7b
  β†’ 1  (cached)
  β†’ 0  (not cached β€” server may have restarted)

Step 4 – Flush all cached scripts (use in emergencies only)

  SCRIPT FLUSH

EVALSHA execution flow:
  App startup:
    SCRIPT LOAD ──▢ Redis stores script ──▢ returns SHA
                                               β”‚
                                          App stores SHA
  Every request:
    EVALSHA SHA ──▢ Redis finds script by SHA
                    Runs atomically
                    Returns result ──▢ App

  If EVALSHA returns NOSCRIPT error:
    Script was flushed (e.g. server restart).
    Fall back to EVAL with full script text,
    then store the new SHA.

Handling NOSCRIPT Gracefully

result = EVALSHA sha 1 mykey
if result == NOSCRIPT_ERROR:
    sha = SCRIPT LOAD "<full script text>"
    result = EVALSHA sha 1 mykey

Debugging Lua Scripts

Using redis.log to Print Debug Messages

Lua scripts cannot print to the terminal.
Use redis.log() to write messages to the Redis server log.

redis.log(redis.LOG_WARNING, "Script started")
redis.log(redis.LOG_NOTICE,  "stock value: " .. tostring(stock))
redis.log(redis.LOG_DEBUG,   "about to DECR")

Log levels (from least to most verbose):
  redis.LOG_WARNING
  redis.LOG_VERBOSE
  redis.LOG_NOTICE
  redis.LOG_DEBUG

View output in the Redis log file (set loglevel debug in redis.conf).

Using the Redis Lua Debugger (LDB)

Redis ships a built-in step debugger for Lua scripts.
Start a debugging session (does NOT affect the live server):

  redis-cli --ldb --eval myscript.lua key1 key2 , arg1 arg2

  The comma ( , ) separates keys from args on the command line.

Inside the debugger:
  s        β†’ step to next line
  n        β†’ step over (runs current line, stops at next)
  c        β†’ continue to next breakpoint
  p var    β†’ print a variable's value
  b 10     β†’ set breakpoint at line 10
  list     β†’ show current script with line numbers

Example session:
  Lua debugging session started. Type 'help' for commands.
  * Stopped at 1, stop reason = step
  -> 1   local stock = redis.call('GET', KEYS[1])
  lua debugger> p stock
  <value> "10"
  lua debugger> s
  -> 2   if tonumber(stock) > 0 then
  lua debugger> c
  (integer) 1

Lua Script Rules and Hard Limits

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Rule                           β”‚ Reason                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Scripts block all clients      β”‚ Redis is single-threaded.     β”‚
β”‚ while running                  β”‚ Keep scripts under 1ms.       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Time limit: 5 seconds default  β”‚ lua-time-limit in redis.conf  β”‚
β”‚ (script-time-limit in Redis 7) β”‚ After limit: SCRIPT KILL      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ No os.time(), math.random()    β”‚ Scripts must be               β”‚
β”‚ for production use             β”‚ deterministic for AOF/replica β”‚
β”‚ Use redis.call('TIME')         β”‚ replication to work correctly β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ No I/O inside scripts          β”‚ No HTTP, sockets, or file     β”‚
β”‚ (no require of external libs)  β”‚ reads allowed from Lua        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ All keys must be declared      β”‚ Pass keys as KEYS[n],         β”‚
β”‚ in the KEYS array              β”‚ not as hardcoded strings      β”‚
β”‚                                β”‚ (required for Redis Cluster)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Killing a Stuck Script

If a script runs too long:

  SCRIPT KILL    ← stops a script that has not yet written anything
                   (safe to kill)

  If the script has already done writes, SCRIPT KILL refuses.
  Only a server SHUTDOWN NOSAVE can stop it.
  This is why you must keep scripts short and test thoroughly.

Getting the Server Time Correctly Inside Lua

Wrong (not deterministic, breaks AOF replay):
  local t = os.time()

Correct:
  local time = redis.call('TIME')
  local seconds     = tonumber(time[1])   -- Unix seconds
  local microseconds = tonumber(time[2])  -- fractional part

Redis 7 Functions – The Modern Replacement for EVAL

Redis 7.0 introduced Functions as a more structured way to deploy Lua code. Instead of sending raw scripts each time, you load named libraries into Redis. The functions in those libraries persist across restarts (unlike EVALSHA, which is lost on restart without AOF).

Comparison:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Feature             β”‚ EVAL/EVALSHA     β”‚ Functions (Redis 7)    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Persist on restart  β”‚ No (EVALSHA      β”‚ Yes (stored in RDB     β”‚
β”‚                     β”‚ needs reload)    β”‚ and AOF)               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Named functions     β”‚ No (SHA only)    β”‚ Yes (call by name)     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Library grouping    β”‚ No               β”‚ Yes (one lib,          β”‚
β”‚                     β”‚                  β”‚ many functions)        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Deployment          β”‚ Per-script       β”‚ Deploy entire library  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Load a library (do this once at deployment):
  FUNCTION LOAD "#!lua name=mylib
  local function buy(keys, args)
    local stock = redis.call('GET', keys[1])
    if tonumber(stock) > 0 then
      redis.call('DECR', keys[1])
      return 1
    end
    return 0
  end
  redis.register_function('buy', buy)"

Call the function by name:
  FCALL buy 1 stock:item:88
  β†’ 1  (success)
  β†’ 0  (out of stock)

List all loaded libraries:
  FUNCTION LIST

Delete a library:
  FUNCTION DELETE mylib

Choosing Between EVAL, EVALSHA, and FCALL

Use EVAL when:
  β†’ Testing or debugging a script interactively
  β†’ Running a one-off script that you will not repeat

Use EVALSHA when:
  β†’ Your app calls the same script many times per second
  β†’ You want to save bandwidth (send SHA, not full script text)
  β†’ You handle NOSCRIPT gracefully by falling back to EVAL

Use FCALL (Functions) when:
  β†’ You run Redis 7.0 or later
  β†’ You want scripts to survive server restarts without reloading
  β†’ You want to deploy and version scripts like application code

Lua vs MULTI/EXEC – When to Use Which

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Need                         β”‚ MULTI/EXEC    β”‚ Lua Script       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Queue fixed commands         β”‚ βœ“             β”‚ βœ“                β”‚
β”‚ Branch on current values     β”‚ βœ—             β”‚ βœ“                β”‚
β”‚ Loop over variable data      β”‚ βœ—             β”‚ βœ“                β”‚
β”‚ Conditional writes           β”‚ βœ— (need WATCH β”‚ βœ“                β”‚
β”‚ Error handling per command   β”‚ βœ—             β”‚ βœ“ (pcall)        β”‚
β”‚ Return computed values       β”‚ Partial       β”‚ βœ“                β”‚
β”‚ Works in Redis Cluster       β”‚ Same-slot     β”‚ Same-slot        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key Points

  • Lua scripts run inside Redis as a single atomic unit β€” no other client executes commands between lines of your script.
  • Use EVAL to run a script. Pass key names as KEYS[n] and other values as ARGV[n]. Indices start at 1 in Lua.
  • Redis returns nil as Lua false, not Lua nil. Check if not v then instead of if v == nil then.
  • Always convert Redis string returns to numbers with tonumber() before doing arithmetic.
  • Use redis.call() when an error should stop the script. Use redis.pcall() to catch errors and handle them inside the script.
  • SCRIPT LOAD uploads a script once and returns a SHA1 hash. EVALSHA runs it by hash on every subsequent call, saving bandwidth.
  • Keep scripts short β€” the Redis server cannot serve any other client while a Lua script runs. Target under 1 millisecond per script.
  • Use redis.call('TIME') for timestamps inside scripts. os.time() and math.random() break AOF replay and replica replication.
  • Redis 7 Functions (FCALL) are the modern replacement for EVALSHA β€” they persist across restarts and support named, versioned libraries.
  • In Redis Cluster, all keys a script touches must live in the same hash slot. Use hash tags to enforce this.

Leave a Comment