A Redis Lua script can read data, make a decision, and update it without another client’s command interleaving between those steps. It does not mean every statement succeeds, that earlier writes are undone after an error, or that the result is automatically durable.
Keep the decision beside the data
Consider deleting a token only if it still belongs to a particular owner. A client-side GET followed later by DEL has a gap: another owner can replace the value before deletion. A script can compare the current value and conditionally delete it during one execution. A stale owner then leaves the replacement intact.
Use redis.call to access Redis data. The sandbox does not provide general filesystem or network access. Pass every accessed key through KEYS and ordinary values through ARGV, rather than constructing key names from stored data. Normal multi-key use in Redis Cluster needs keys in the same hash slot.
Prefer a native atomic command when it already expresses the condition. Redis 8.4 introduced DELEX with IFEQ for conditional string deletion. The compare-and-delete script below teaches branching and response handling; it is not a reason to replace that command with custom code. Deleting an ownership token neither completes a distributed-lock protocol nor protects external resources from stale workers.
When it breaks
An unhandled redis.call error stops the script. A SET executed before a failing LPUSH remains applied, while statements after that failure are not reached. That differs from MULTI/EXEC, where an individual runtime error normally leaves later queued commands running. Neither mechanism supplies automatic rollback of earlier successful writes.
redis.pcall returns a Redis command error to the script as a table with an err field. The script can inspect that field and continue deliberately. Catching the error does not reverse previous writes or make a failed business operation successful. Validate arguments and expected types before the first write where possible, and design the remaining failure path explicitly.
A caller timeout also is not cancellation or evidence that nothing happened. Retrying a script that increments a counter may increment it twice. Keep operations bounded and use an application-level outcome protocol where repeated effects are unacceptable. SCRIPT KILL can interrupt a running script only if it has not yet modified the dataset; it is not a safe undo button for a stuck writer.
Keep source available
EVALSHA refers to a script by digest, not a permanently installed routine. The script cache is volatile and may disappear on restart, failover, or SCRIPT FLUSH. EVAL-loaded scripts can also be evicted under the policy introduced in Redis 7.4. Keep parameterized source in the application and handle a confirmed NOSCRIPT response with the client’s supported loading mechanism.
Do not generate a different script body for every value; use arguments. A confirmed NOSCRIPT means that invocation did not execute the missing script. Reloading or sending EVAL addresses missing code, not an uncertain earlier business outcome.
Bound both loop counts and the amount of data a command processes. A short script containing an unbounded collection read can still monopolize the server. Moving logic into Redis removes client round trips, not the need for latency and memory budgets.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. The setup compiles Redis core with libc and installs the client in a temporary virtual environment; it does not install a system service.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python" in the same shell. It starts Redis on a private Unix socket under /tmp, with TCP and persistence disabled. Cleanup stops only that process and removes its runtime directory. Downloads need network access; no existing Redis instance is contacted.
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
compare_delete = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
with instance() as client, client.client() as rival:
client.set("lease", "owner-a")
assert client.get("lease") == b"owner-a"
rival.set("lease", "owner-b")
assert client.eval(compare_delete, 1, "lease", "owner-a") == 0
assert client.get("lease") == b"owner-b"
assert client.eval(compare_delete, 1, "lease", "owner-b") == 1
assert client.eval(compare_delete, 1, "lease", "owner-b") == 0
print("compare-and-delete preserves a new owner's token and removes only a match")
client.set("wrong-type", "string")
fails_after_write = """
redis.call('SET', KEYS[1], 'before')
redis.call('LPUSH', KEYS[2], 'item')
redis.call('SET', KEYS[3], 'after')
return 1
"""
try:
client.eval(fails_after_write, 3, "written", "wrong-type", "not-reached")
except redis.ResponseError as error:
assert "WRONGTYPE" in str(error)
else:
raise AssertionError("script should fail on the string key")
assert client.mget("written", "wrong-type", "not-reached") == [
b"before", b"string", None,
]
print("redis.call error stops the script but keeps the earlier write")
catches_error = """
local reply = redis.pcall('LPUSH', KEYS[1], 'item')
if type(reply) == 'table' and reply.err then
redis.call('SET', KEYS[2], 'handled')
return reply.err
end
return reply
"""
response = client.eval(catches_error, 2, "wrong-type", "handled-error")
assert response.startswith(b"WRONGTYPE")
assert client.get("handled-error") == b"handled"
assert client.eval(catches_error, 2, "new-list", "unused-marker") == 1
assert client.lrange("new-list", 0, -1) == [b"item"]
assert client.get("unused-marker") is None
print("redis.pcall exposes errors for handling and preserves successful command replies")
digest = client.script_load(compare_delete)
assert client.script_exists(digest) == [True]
client.set("lease", "owner-c")
assert client.evalsha(digest, 1, "lease", "owner-c") == 1
client.set("lease", "owner-d")
assert client.script_flush(sync_type="SYNC") is True
assert client.script_exists(digest) == [False]
try:
client.evalsha(digest, 1, "lease", "owner-d")
except redis.exceptions.NoScriptError:
pass
else:
raise AssertionError("flushed script remained available")
assert client.get("lease") == b"owner-d"
assert client.eval(compare_delete, 1, "lease", "owner-d") == 1
print("SCRIPT FLUSH causes NOSCRIPT; EVAL with retained source can run it again")
The first case replaces owner-a with owner-b before the stale owner’s script runs. The stale token returns zero and preserves owner-b; the matching token returns one and deletes it. Another call returns zero because the key is absent. The script reads the current value rather than trusting the earlier client-side read.
The next script writes one marker, attempts LPUSH against a string, and never reaches its final marker. Assertions confirm the first write survives the error. The pcall variant handles the same error and writes its handled marker; successful LPUSH instead returns its integer result. Type-checking distinguishes that number from an error table.
Finally, SCRIPT LOAD supplies a digest that EVALSHA can execute. SCRIPT FLUSH removes that code from this private test server. The resulting NOSCRIPT leaves the token intact, and a subsequent EVAL using retained source removes it. Never run this cache-clearing exercise against a shared instance.
Four output summaries follow assertions on results and stored values. The run does not measure throughput, test a long-running script, reproduce a network failure, or establish distributed-lock, cross-shard, or durability guarantees.
Sources
- Introduction to Redis Eval scripts, execution isolation and volatile script caching, checked 2026-09-08.
- Redis Lua API, sandbox, command calls, and error conversion, checked 2026-09-08.
- Redis EVAL, key arguments and eviction of EVAL-loaded scripts, checked 2026-09-08.
- Redis SCRIPT KILL, restriction after a script performs writes, checked 2026-09-08.
- Redis SCRIPT FLUSH, explicit cache removal, checked 2026-09-08.
- Redis DELEX, native conditional deletion since Redis 8.4, checked 2026-09-08.
- Redis Cluster specification, hash-slot boundaries for multi-key operations, checked 2026-09-08.
- Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.