from EVAL to production-ready business logic
Redis Lua scripting runs a complete script as a single atomic command, without any other client being able to interleave. Anyone who implements increment-with-limit, conditional delete or other read-then-write patterns without real server-side logic risks race conditions that Lua scripts structurally rule out.
Table of Contents
- 1. What Redis Lua scripting really delivers
- 2. EVAL in detail: running scripts directly
- 3. KEYS and ARGV: passing parameters cleanly
- 4. EVALSHA and SCRIPT LOAD for performance
- 5. Why Lua scripts are genuinely atomic
- 6. Practical example: atomic increment with limit
- 7. Practical example: conditional delete (compare-and-delete)
- 8. Limits and pitfalls of Lua scripts
- 9. Lua scripts compared to MULTI/EXEC and Functions
- 10. Summary
- 11. FAQ
1. What Redis Lua scripting really delivers
With Redis Lua scripting, a complete script written in Lua 5.1 runs directly on the Redis server, instead of sending several individual commands from the client. The central advantage: the entire script runs as a single, indivisible operation, during which no other client can execute any command against the dataset. This fundamentally distinguishes Lua scripting from a sequence of individual client commands, where a race condition can arise between every command.
The use case for Redis Lua scripting arises everywhere an operation consists of a read followed by a conditional write. A classic example: a counter should be incremented, but only if it is below a certain limit. Without Lua scripting, the client would first have to call GET, check the result and then conditionally send INCR, with the risk that another client changes the same value between GET and INCR. A Lua script executes the read and the write as a single, uninterrupted step on the server and structurally rules out this race condition.
It is important that Redis Lua scripting is not a general purpose programming environment. Scripts have access to a restricted Lua standard library, cannot perform filesystem or network access, and interact with Redis exclusively through the redis.call or redis.pcall function. This restriction is a deliberate choice to guarantee determinism and safety, because a script that could access external resources would immediately undermine the atomicity guarantee.
2. EVAL in detail: running scripts directly
The EVAL command takes the Lua source code as a string, followed by the number of keys and the actual keys and arguments. Inside the script, these are available as the global tables KEYS and ARGV. The return value of the script is automatically converted into a Redis response type: Lua strings become Redis bulk strings, Lua numbers become Redis integers, Lua tables become Redis arrays.
A simple EVAL example shows the basic mechanism: a script reads a value, processes it with Lua logic and writes the result back, all within a single Redis command that appears atomic from the client's perspective. This property makes Redis Lua scripting the tool of choice for any business logic that must rule out classic race conditions under concurrent access.
-- Simple script: read a value, double it, write it back, return old and new
-- Called via: redis-cli EVAL "$(cat script.lua)" 1 mykey
local old_value = tonumber(redis.call("GET", KEYS[1])) or 0
local new_value = old_value * 2
redis.call("SET", KEYS[1], new_value)
return {old_value, new_value}
redis-cli> SET counter 21
OK
redis-cli> EVAL "local old = tonumber(redis.call('GET', KEYS[1])) or 0; \
local new = old * 2; redis.call('SET', KEYS[1], new); return {old, new}" \
1 counter
1) (integer) 21
2) (integer) 42
3. KEYS and ARGV: passing parameters cleanly
A frequent beginner mistake in Redis Lua scripting is concatenating key names directly into the script text instead of passing them via KEYS. This works technically, but breaks Redis Cluster compatibility, because Redis Cluster decides which shard a script is allowed to run on based on the declared keys. A script with hardcoded key names cannot be correctly routed to a shard by Redis and either fails in cluster mode or returns incorrect results.
The clear rule is therefore: all keys the script accesses belong in the KEYS table, all other values such as limits, timestamps or strings belong in the ARGV table. This separation lets Redis statically detect which keys are affected, which matters both for cluster routing and for internal concurrency control. In practice this means: even in a single node setup, KEYS and ARGV should be used consistently from the start to avoid later migration problems.
-- Correct: key comes from KEYS, values come from ARGV
-- redis-cli EVAL "..." 1 rate:limit:user:42 100 60
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0
end
return 1
4. EVALSHA and SCRIPT LOAD for performance
On every EVAL call the client sends the full Lua source code over the network and Redis compiles it again, which creates unnecessary overhead for frequently executed scripts. SCRIPT LOAD compiles a script once, caches it server side and returns a SHA1 hash. With EVALSHA, this script can then be invoked using only the hash, without transferring the source code again, saving network load and parse time.
The Redis script cache is server local and is cleared on a restart or FLUSHALL, which is why production clients must be prepared for the NOSCRIPT error: if this error occurs on EVALSHA, the client falls back to a regular EVAL with the full source code, implicitly caching the script again in the process. Most Redis client libraries such as Jedis, redis-py or Predis already implement this fallback behavior automatically.
# Load the script once, get back its SHA1 hash
redis-cli> SCRIPT LOAD "local c = redis.call('INCR', KEYS[1]); \
if tonumber(c) > tonumber(ARGV[1]) then redis.call('DECR', KEYS[1]); \
return 0 else return 1 end"
"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
# Reuse the cached script by hash, no source code transferred
redis-cli> EVALSHA a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 1 counter:daily 1000
(integer) 1
# Check whether a script hash is still cached on the server
redis-cli> SCRIPT EXISTS a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
1) (integer) 1
5. Why Lua scripts are genuinely atomic
The atomicity guarantee of Redis Lua scripting is based on the same single threaded execution model that also makes regular Redis commands atomic. While a script runs, the main Redis process fully blocks all other client requests, there is no way for parallel execution within the same Redis process. This distinguishes Lua scripting from MULTI/EXEC transactions in that a script immediately stops its own execution on a runtime error, whereas MULTI/EXEC still executes the remaining commands.
This atomicity comes at a price: a long running script blocks the entire Redis server for the duration of its execution, including all other clients. That is why the clear recommendation is to keep Lua scripts short and to avoid expensive operations such as iterating over millions of keys within a single script. The lua-time-limit parameter in the Redis configuration defines a warning threshold above which Redis flags a running script as potentially problematic, without automatically terminating it, since aborting in the middle of write operations would jeopardize data integrity.
6. Practical example: atomic increment with limit
A typical use case for Redis Lua scripting is a rate limiter that increments a counter per time window but rejects further increments once a defined limit is reached. Without a Lua script, the application would have to execute GET, a comparison and INCR as separate commands, with the risk that two concurrent requests both pass the comparison successfully before either of them increments the counter, causing the limit to be exceeded.
The following script solves this problem by first incrementing the counter and then checking whether the limit was exceeded. If the limit is exceeded, the counter is decremented again and 0 is returned, otherwise the current number of allowed calls is returned. The EXPIRE call on the very first increment ensures the counter disappears automatically at the end of the time window, without a separate cleanup job being necessary.
-- Atomic rate limiter: increment counter with limit and TTL window
-- redis-cli EVAL "$(cat rate_limit.lua)" 1 rate:api:user:42 100 60
local current = redis.call("INCR", KEYS[1])
-- Set expiry only on the very first increment of the window
if current == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
-- Reject and roll back the increment if the limit is exceeded
if current > tonumber(ARGV[1]) then
redis.call("DECR", KEYS[1])
return 0
end
return current
7. Practical example: conditional delete (compare-and-delete)
A second classic pattern is conditional delete, also known as compare-and-delete: a key should only be removed if its current value matches an expected value. This pattern is central to distributed locks, where only the process that originally set the lock is allowed to release it, identified by a unique token used as the lock value. A plain DEL without a value check would risk a process accidentally releasing another process's lock.
The following script checks the current value of the key against the expected value passed in and only deletes on a match. Since GET and DEL run within the same atomic script, no other client can change the value between the check and the deletion, which would theoretically be possible with separate GET and DEL commands.
-- Compare-and-delete: only remove the key if its value matches the expected token
-- redis-cli EVAL "$(cat cas_delete.lua)" 1 lock:order:9931 "worker-a1b2c3"
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
redis-cli> SET lock:order:9931 "worker-a1b2c3" NX EX 30
OK
# Correct owner releases the lock, deletion succeeds
redis-cli> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then \
return redis.call('DEL', KEYS[1]) else return 0 end" \
1 lock:order:9931 worker-a1b2c3
(integer) 1
# Wrong owner tries to release, deletion is safely rejected
redis-cli> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then \
return redis.call('DEL', KEYS[1]) else return 0 end" \
1 lock:order:9931 worker-zzzzzz
(integer) 0
8. Limits and pitfalls of Lua scripts
Redis Lua scripting has a few hard limits worth knowing before production use. Scripts should not use non-deterministic Lua functions such as os.time or math.random without special care, because replication and AOF persistence require a script to produce the same result on every execution. Since Redis 5, effect replication is used, where the write commands actually executed are replicated instead of the script text itself, which has significantly eased this restriction, but determinism inside the script remains best practice regardless.
Another pitfall concerns error handling: redis.call throws a Lua exception on a Redis error, which immediately aborts the entire script, while redis.pcall returns the error as a Lua table that can be handled within the script itself. Anyone who wants to write robust scripts that react to specific error types instead of aborting on every error must deliberately use redis.pcall instead of redis.call and explicitly check the return value for an err field.
9. Lua scripts compared to MULTI/EXEC and Functions
Redis offers several mechanisms for server side logic that differ in atomicity, flexibility and performance. The following table compares the most important options.
| Criterion | MULTI/EXEC | Lua script (EVAL) | Redis Functions (7.0+) |
|---|---|---|---|
| Conditional logic between commands | Not possible | Fully supported | Fully supported |
| Abort on runtime error | No abort of remaining commands | Script stops immediately | Function stops immediately |
| Persistence of code | Not applicable | Cache can be cleared (NOSCRIPT) | Permanently stored with RDB/AOF |
| Management and versioning | Not applicable | Only via SHA1 hash in cache | Named libraries via FUNCTION LOAD |
| Network round trips | Multiple | A single one | A single one |
For simple read-then-write operations with clear, unconditional logic, a Lua script with EVAL is often the most pragmatic solution. Anyone who wants to version code permanently and keep it consistently available across multiple deployments should rely on Redis Functions from version 7.0 onward, since these scripts persist instead of only living in the volatile script cache.
10. Summary
Redis Lua scripting runs complete scripts as a single, indivisible server operation and thereby structurally rules out race conditions in read-then-write patterns. EVAL executes scripts directly, EVALSHA uses the server side script cache for better performance, and the clear separation between KEYS and ARGV is a prerequisite for cluster compatibility. Practical patterns such as atomic increment-with-limit and compare-and-delete show how classic race conditions can be reliably avoided with a few lines of Lua code.
Important for practice: scripts should stay short and deterministic, since they block the entire Redis server for their runtime. Anyone who wants to version scripts permanently should consider switching to Redis Functions. For simple, atomic business logic, however, EVAL remains the most direct and most widely used tool in production Redis environments.
Redis Lua scripting, the essentials at a glance
Genuine atomicity
A script runs as an indivisible operation, no other client can interleave, errors stop execution immediately.
EVAL vs. EVALSHA
EVAL sends the full source code, EVALSHA uses the script cache via SHA1 hash and saves network load on frequent calls.
KEYS and ARGV
Always pass keys through KEYS, other values through ARGV, otherwise the script does not work correctly in cluster mode.
Keep scripts short
Long running scripts block the entire server. Use Redis Functions from 7.0 for versioned, persistent code.