What Is Redis Lua Scripting?

Redis Lua scripting lets you send a block of Lua to the server and have it executed there as a single atomic unit. While the script runs, no other client's commands can interleave with it — the server finishes the whole script before it does anything else. That one property is the entire reason the feature exists, and it is why Lua is the standard answer to logic that has to read a value, decide something, and write a result without anyone else getting in between. That is also the line between a script and pipelining: a pipeline batches independent commands to save round trips, but it cannot branch on a reply it has not read yet.

Why Lua Scripts Are Atomic

Valkey and Redis execute commands on a single thread. A script is treated as one command, so from every other client's point of view it either has not started or has already finished. There is no window in the middle for a race condition to occur.

A native transaction gives you the same isolation, but not the same power. MULTI queues commands before any of them run, so the queue cannot branch on a value it has not read yet. WATCH works around that by aborting and making the client retry. A script simply reads, decides and writes in one pass.

MULTI / EXECLua script
IsolationYesYes
Branch on a value read mid-wayNo — commands are queued firstYes
Conflict handlingWATCH, then retry on abortNot needed
Round tripsSeveral — watch, read, then queue and execOne
Rollback on errorNoNo

Neither gives you rollback, and it is worth being concrete about what that costs. Atomic here means uninterrupted, not undoable:

redis.call('SET', KEYS[1], ARGV[1])     -- succeeds
redis.call('INCR', KEYS[2])             -- fails: KEYS[2] holds a string
                                        -- the script aborts here

The SET stands. There is no DISCARD, no rollback, and no way to learn from the error reply which of the preceding writes had already landed. If a script can fail partway, either make every write idempotent or validate everything before you write anything.

EVAL, EVALSHA and the Script Cache

EVAL takes the script body, the number of keys, then the keys and arguments. Sending the full source on every call wastes bandwidth when the script is hot, so the server keeps a cache of every script it has seen, addressed by the SHA1 of its body.

SCRIPT LOAD puts a script in that cache and returns the digest; EVALSHA then runs it by digest alone. If the server has been restarted or the cache flushed, EVALSHA answers NOSCRIPT and the caller is expected to fall back to EVAL and try again. Handle that path explicitly: a bare evalSha against a digest the server no longer holds simply fails.

Since Redis 7.4 and Valkey 8.0 that cache is no longer unbounded, and the detail is finer than most write-ups suggest. Only the entries the server learned from an EVAL call are evictable — they live in an LRU list capped at 500, and the running total appears as evicted_scripts in INFO stats. Scripts installed deliberately with SCRIPT LOAD are exempt and stay until SCRIPT FLUSH.

So the familiar advice to load at startup and use EVALSHA thereafter still holds — provided you actually call SCRIPT LOAD. A client that warms the cache by firing a plain EVAL once has created an evictable entry, and on a busy server with many distinct scripts NOSCRIPT can then surface on a healthy, long-running node that has never restarted or failed over. Keep the full-EVAL fallback either way; it is three lines and it removes a whole class of incident.

There are read-only variants too. EVAL_RO and EVALSHA_RO, both added in Redis 7.0, refuse to run any write command, which makes them safe to serve from a replica. Two surprises are worth knowing before you rely on them: PUBLISH, SPUBLISH and PFCOUNT all count as writes inside a script despite reading like read-only operations, and a replica configured with replica-serve-stale-data no will still reject the script while it is stale unless the script declares the allow-stale flag.

Writing a Script: KEYS and ARGV

Every key a script touches should arrive through the KEYS table, with everything else in ARGV. This is not a style preference. The client uses the declared keys to work out which shard the script belongs to, so a script that reaches for an undeclared key may work on a single node and fail once you move to Redis Cluster. In a cluster, all of a script's declared keys must also hash to the same slot.

Here is the case a transaction cannot express — check stock, and decrement only if there is enough:

-- KEYS[1] = inventory key, ARGV[1] = quantity requested
local stock = tonumber(redis.call('GET', KEYS[1]))

if stock == nil or stock < tonumber(ARGV[1]) then
  return -1                                    -- not enough; change nothing
end

return redis.call('DECRBY', KEYS[1], ARGV[1])  -- returns the new level

Two clients running this concurrently cannot both succeed against the last unit, because neither can observe the other mid-script.

Script Flags and the Shebang Trap

Since Redis 7.0 a script can declare its own flags on a shebang line, which is how you promise the server that a script performs no writes, tolerates a stale replica, or may run while the server is out of memory:

#!lua flags=no-writes
return redis.call('GET', KEYS[1])

The available flags are no-writes, allow-oom, allow-stale, no-cluster and allow-cross-slot-keys.

Adding a bare shebang makes a script stricter, not neutral — and this is the opposite of what most guides imply. A script with no #! line runs in a backwards-compatibility mode that implicitly permits cross-slot key access and skips the out-of-memory and stale-replica gating. As soon as the server sees a #! comment it applies the newer, tighter defaults, even if you declared no flags at all. So adding #!lua to a script that has been working happily in a cluster for months can break it on the next deploy. If you need the old behaviour, say so explicitly with allow-cross-slot-keys.

What Your Script Returns: Lua and Redis Type Conversion

A script's return value is converted from a Lua type to a Redis reply, and the conversion is lossy in ways that produce quiet, hard-to-trace bugs rather than errors.

Lua valueRedis replyWhat to watch for
numberintegerThe decimal part is discarded. return 3.7 gives the client 3
stringbulk stringThe safe way to return a float — return tostring(3.7)
tablearrayTruncated at the first nil; string keys are silently dropped
booleantrue → 1, false → null (RESP2)Changes under RESP3 — see below
redis.error_reply(msg)errorRaised to the caller
redis.status_reply(msg)simple stringFor OK-style replies

The boolean row is the one that catches people out in 2026. Every article written before RESP3 documents only the RESP2 mapping. If your client negotiates RESP3 with HELLO 3, Lua booleans become real RESP3 booleans instead of 1 and null — so upgrading a client to RESP3 can silently change what an unchanged script returns.

There is a second protocol subtlety hiding underneath that. Inside a script the protocol is always RESP2 by default, regardless of what the client negotiated. Replies that redis.call() hands back to your Lua code stay RESP2-shaped until you call redis.setresp(3). The client's protocol and the script's internal view of the same server can differ within a single request.

redis.call vs redis.pcall

redis.call() raises any error straight back to the client and abandons the script. redis.pcall() never throws: it returns a Lua table with an err field, so the script can inspect the failure and decide what to do.

local reply = redis.pcall('INCR', KEYS[1])

if type(reply) == 'table' and reply.err then
  redis.call('SET', KEYS[1], ARGV[1])          -- repair and continue
  return 0
end

return reply

The type(reply) == 'table' guard is not defensive padding — without it the script breaks on the success path. A successful INCR returns a Lua number, and Lua 5.1 numbers have no metatable, so reply['err'] raises "attempt to index a local (a number value)" and the script fails exactly when everything went right. The bare reply['err'] ~= nil test that circulates widely is copied from an example built on ECHO, which returns a string — and strings do have a metatable in Lua 5.1, so indexing one quietly yields nil instead of throwing. Check the type first whenever the command can return a number or a nil.

Use call when a failure should abort everything, and pcall only when you have a genuine recovery path. A pcall whose result you never check is worse than a call, because it converts a loud failure into a wrong answer.

Lua Scripting in Java With Redisson

Redisson, the Valkey and Redis Java client, exposes scripting through RScript:

RScript script = redisson.getScript(StringCodec.INSTANCE);

Long remaining = script.eval(
        RScript.Mode.READ_WRITE,
        RESERVE_STOCK,                       // the Lua source above
        RScript.ReturnType.LONG,
        Collections.singletonList("inventory:42"),   // KEYS
        "3");                                        // ARGV

The mode declares intent: a READ_ONLY script contains no writes and can therefore be served by a replica, while READ_WRITE goes to the master. ReturnType saves you casting a raw reply, converting the response into the Java type you asked for. To use the script cache directly, scriptLoad returns the SHA1 digest and evalSha runs it; scriptExists, scriptFlush and scriptKill complete the interface.

Upgrade note — Redisson 4.0.0 renamed three RScript.ReturnType constants. This is a source-incompatible change, so 3.x code will not compile against 4.x until it is updated:

Redisson 3.xRedisson 4.x
ReturnType.INTEGERReturnType.LONG
ReturnType.MULTIReturnType.LIST
ReturnType.STATUSReturnType.STRING

BOOLEAN, VALUE, MAPVALUE and MAPVALUELIST are unchanged.

How Much of This You Get for Free

Before you write any Lua of your own, it is worth knowing that Redisson is itself built on server-side scripting. A large part of the API ships as Lua because the Java contract it implements cannot be expressed in a single Redis command:

Redisson APIWhy it has to be a script
RMap.putIfAbsentWraps HSETNX in EVAL purely to honour java.util.Map's contract of returning the previous value — one command cannot both set and report
RBucket.compareAndSetGET, compare, then SET in one uninterrupted pass
RLock.tryLockexists / hexists / hincrby / pexpire / pttl as a single unit, so reentrancy and expiry cannot diverge
RRateLimiter.tryAcquireToken-bucket refill and consume must not be split

The pattern is worth internalising: the Java collection contract is what forces server-side scripting. Reach for RScript when you need multi-key logic Redisson does not already model, or when you want to collapse a chatty sequence of commands into one round trip — not to re-implement SETNX semantics or a distributed lock that already exists.

Redis Functions, FCALL and When They Beat EVAL

Redis Functions, added in 7.0, invert the ownership of the code. Instead of the client shipping a script on every call, you register a named library on the server once and call functions inside it by name. A library declares itself with a shebang and registers its entry points:

#!lua name=inventory

local function reserve(keys, args)
  local stock = tonumber(redis.call('GET', keys[1]))
  if stock == nil or stock < tonumber(args[1]) then
    return -1
  end
  return redis.call('DECRBY', keys[1], args[1])
end

redis.register_function('reserve', reserve)

FUNCTION LOAD installs the library and FCALL reserve 1 inventory:42 3 runs it; FCALL_RO is the read-only counterpart. In Java, Redisson exposes the same surface through RFunction:

RFunction function = redisson.getFunction(StringCodec.INSTANCE);
function.load("inventory", LIBRARY_SOURCE);

Long remaining = function.call(
        FunctionMode.WRITE,
        "reserve",
        FunctionResult.LONG,
        Collections.singletonList("inventory:42"),
        "3");

Note that FunctionMode and FunctionResult are top-level types in org.redisson.api, not nested inside RFunction the way Mode and ReturnType are nested inside RScript. RFunction also carries delete, flush, kill, list, stats, and dump/restore for moving libraries between servers.

EVAL scriptFunction
Where the code livesWith the application that sends itOn the server, as a named library
Survives a restartNo — the cache is volatileYes — persisted to RDB and AOF
The code reaches replicasNo — SCRIPT LOAD is not a write command, so it is never propagated (the script's effects replicate normally)Yes — replicated from master
Reaches every cluster nodeNoNo — see below
DeploymentShips with your buildA separate artifact to version and roll out

The caveat that undercuts the usual pitch: functions are not propagated across a cluster. Data replicates from master to replica, and so do libraries — but FUNCTION LOAD must be issued against every master in the cluster separately. If you adopted Functions to solve script distribution, a multi-shard deployment hands most of that problem straight back to you. Functions earn their place when the logic genuinely belongs to the database rather than the application; EVAL remains the simpler choice when it belongs to the service that sends it.

Limits and Gotchas

A slow script blocks the entire server. This is the direct cost of atomicity. Once a script passes busy-reply-threshold — 5,000 ms by default — the server starts answering other clients with BUSY, but it does not stop the script, because killing it mid-way would break the atomicity guarantee it just made. The older name lua-time-limit was not removed or deprecated; both names are live aliases for the same setting in redis.conf today. Setting it to zero or a negative value disables the interrupt entirely.

SCRIPT KILL only works before the first write. Once a script has modified data there is nothing safe to terminate, and the only remaining option is SHUTDOWN NOSAVE — which discards everything since the last save. FUNCTION KILL is the equivalent for functions. Avoid unbounded loops and never call KEYS inside a script.

A long script can trigger a failover. Sentinel polls for health, and a node replying BUSY looks unavailable. If that outlasts down-after-milliseconds, Sentinel promotes a replica while the original master is still busy finishing your script.

The cluster rules are enforced at run time, not just at routing time. Routing is done client-side from the keys you declare, so numkeys 0 sends a script to an arbitrary node. But the server also checks every key the script actually touches, and will answer "Script attempted to access a non local key in a cluster node" or "Script attempted to access keys that do not hash to the same slot". What the server never checks is whether a key you touched was declared in KEYS — that part is convention, which is exactly why the failure surfaces later, in production, on the one node that does not hold the key.

The determinism advice you will read elsewhere is out of date. Redis once replicated scripts verbatim, which meant TIME or SRANDMEMBER could desynchronise a replica. Effects-based replication became the default in Redis 5.0 and verbatim replication was removed entirely in 7.0, so only the resulting writes are propagated and non-deterministic commands are fine. redis.replicate_commands() has been deprecated since 7.0 and now always succeeds.

Valkey prefers a different namespace. Valkey exposes the same API under a server object — server.call(), server.register_function() — and keeps the redis object for compatibility, with no stated intention of removing it. Existing scripts work unchanged on both; new scripts written specifically for Valkey are better off using server.

Redis Lua Scripting: Frequently Asked Questions

Is a Redis Lua Script Atomic?

Yes. The server runs commands on a single thread and treats a script as one command, so no other client can observe the dataset partway through. Be careful what you read into the word, though: it means uninterrupted rather than transactional. Redis has no rollback, so anything written before an error occurred remains written.

What Is the Difference Between EVAL and EVALSHA?

EVAL sends the whole script body with every call. EVALSHA sends only the SHA1 digest of a script the server already has cached, which saves bandwidth on frequently used scripts. If the digest is not in the cache the server replies NOSCRIPT, and the client resends the full source with EVAL. Since Redis 7.4 and Valkey 8.0, entries the server learned from an EVAL call are held in an LRU list capped at 500 entries and can be evicted, so NOSCRIPT is no longer only a restart-or-failover symptom. Scripts installed deliberately with SCRIPT LOAD are exempt. Keep the EVAL fallback in either case.

Why Do Redis Scripts Use KEYS and ARGV?

Declaring keys in KEYS tells the client which keys a script will touch, which is how it routes the script to the correct shard. A script that accesses an undeclared key can work on a single node and then fail in a cluster. Everything that is not a key belongs in ARGV.

Why Does My Lua Script Return the Wrong Number?

Because Lua numbers are converted to Redis integers and the decimal part is discarded — returning 3.7 gives the caller 3. Return floats as strings with tostring() and parse them client-side. The same class of bug affects tables, which are converted to arrays and truncated at the first nil element, silently dropping everything after it.

Can a Lua Script Run on a Redis Replica?

Yes, if it performs no writes. Use EVAL_RO or EVALSHA_RO, or declare the no-writes flag on a shebang line, and the server will reject any write command inside the script. Two catches: PUBLISH, SPUBLISH and PFCOUNT all count as writes despite appearing read-only, and a replica serving stale data will still refuse the script unless it declares allow-stale.

Should I Use Lua Scripts or Redis Functions?

Functions, added in Redis 7.0, register a named library that persists to RDB and AOF and replicates to replicas, so the logic belongs to the database rather than to the client. EVAL scripts remain simpler for logic that belongs with the application that sends it. One caveat before you switch for deployment reasons: functions are not propagated across a cluster, so FUNCTION LOAD must be issued against every master node. Redisson exposes both, through RScript and RFunction.

Is Redis Lua Scripting Safe to Enable?

Treat it as a privilege, not a default. CVE-2025-49844 was a use-after-free in the Lua interpreter that allowed an authenticated user running EVAL to achieve remote code execution; it is fixed in Redis 8.2.2, 8.0.4, 7.4.6, 7.2.11 and 6.2.20, and in Valkey 8.1.4, 8.0.6 and 7.2.11. Patch first. Restricting the @scripting ACL category is the documented stopgap, but note that it will break a Redisson application, because Redisson implements locks, rate limiters and atomic map operations as server-side Lua and therefore needs EVAL itself.

Similar terms