Introduction to Lua Scripting (EVAL)
Redis lets you send a small program written in Lua straight to the server with the EVAL command, and the server runs it from start to finish as a single atomic step. This is more powerful than a plain MULTI/EXEC transaction because a Lua script can read a value, make a decision based on it, and write a different value — all without any other client’s commands slipping in between. Scripting is how you build things like atomic rate limiters, conditional updates, and multi-step counters that would otherwise require careful client-side retry logic.
Overview / How it works
Every Redis command already runs atomically because Redis executes commands one at a time on a single main thread — nothing else can run in the middle of a command. EVAL extends that same guarantee to an entire script: once Redis starts executing your Lua code, it will not process any other client’s command, or move on to a replica sync, or run an expiration sweep, until your script finishes. From the outside, the whole script looks like one giant atomic command.
Inside the script, Redis exposes two special Lua tables: KEYS, populated from the key names you pass, and ARGV, populated from the extra arguments. You call ordinary Redis commands from Lua with redis.call(...) (or redis.pcall(...), its error-tolerant twin), passing the command name and arguments as strings, exactly the way you’d type them at the prompt. Whatever the script returns is converted from a Lua value into a normal RESP reply and sent back to the client — a Lua string becomes a bulk string reply, a Lua number becomes an integer reply (truncated toward zero), a Lua table becomes a multi-bulk (array) reply, false becomes a nil reply, and a table shaped like {ok = "..."} or {err = "..."} becomes a status or error reply respectively.
Because a script must produce the same effect every time it’s replayed for replication and the append-only file, Redis restricts nondeterministic behavior inside scripts (things like unseeded randomness or wall-clock reads are wrapped so replicas end up in the same state). Modern Redis replicates the effects of a script — the actual write commands it issued — to replicas and the AOF, rather than shipping the raw Lua source to be re-executed independently.
Redis also caches every script body it has seen, indexed by the SHA1 hash of its exact text, in an internal script cache. This is what makes EVALSHA possible: instead of resending a large script on every call, you send only its hash once you know the server already has it cached.
Syntax
EVAL script numkeys key [key ...] arg [arg ...]
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
SCRIPT LOAD script
SCRIPT EXISTS sha1 [sha1 ...]
script— the Lua source code as a single string argument.numkeys— how many of the following arguments are Redis key names (populates theKEYStable); everything after that is a plain argument (populatesARGV).key [key ...]— the key names the script will touch, always passed explicitly rather than hardcoded, so Redis Cluster can route the command to the right node and so tooling can see which keys a script accesses.arg [arg ...]— any other values the script needs (values to set, limits, thresholds).sha1— the SHA1 hex digest of a script previously registered withSCRIPT LOADor a priorEVALcall.
| Command | Time complexity | Notes |
|---|---|---|
| EVAL / EVALSHA | Depends on the script executed | Each redis.call inside costs whatever that command normally costs |
| SCRIPT LOAD | O(N) where N is script length | Compiles and caches the script; does not execute it |
| SCRIPT EXISTS | O(N) where N is number of hashes checked | Checks the server’s script cache without running anything |
Examples
Example 1: A trivial script
EVAL "return 'Hello from Lua!'" 0
"Hello from Lua!"
With numkeys set to 0, no keys are passed. The script just returns a Lua string literal, which comes back as a bulk string reply.
Example 2: Setting a value through a script
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 user:1001:name "Ada"
GET user:1001:name
OK
"Ada"
KEYS[1] is bound to user:1001:name and ARGV[1] to "Ada". The script calls SET exactly as you would from the prompt; its status reply ({ok="OK"}) is returned to the client as a plain OK. The follow-up GET confirms the write landed.
Example 3: An atomic rate limiter
EVAL "local current = redis.call('INCR', KEYS[1]); if tonumber(current) == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end; return current" 1 ratelimit:user:42 60
TTL ratelimit:user:42
EVAL "local current = redis.call('INCR', KEYS[1]); if tonumber(current) == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end; return current" 1 ratelimit:user:42 60
TTL ratelimit:user:42
(integer) 1
(integer) 60
(integer) 2
(integer) 60
This is a request counter with a self-installing TTL, a pattern you cannot safely build from separate INCR and EXPIRE calls (a crash or race between the two would leave the counter without a TTL forever). Because tonumber(current) == 1 only sets the expiry on the very first increment of a fresh window, the second call increments to 2 without touching the TTL — you can see it’s still counting down from the original 60.
Example 4: Atomic compare-and-swap
SET config:feature:beta "old"
EVAL "local val = redis.call('GET', KEYS[1]); if val == ARGV[1] then redis.call('SET', KEYS[1], ARGV[2]); return 1 else return 0 end" 1 config:feature:beta "old" "new"
GET config:feature:beta
EVAL "local val = redis.call('GET', KEYS[1]); if val == ARGV[1] then redis.call('SET', KEYS[1], ARGV[2]); return 1 else return 0 end" 1 config:feature:beta "old" "new"
OK
(integer) 1
"new"
(integer) 0
The script only overwrites the key if its current value still matches the expected old value — a classic compare-and-swap. Doing this with separate GET and SET commands from your application would leave a window where another client could change the value between your read and your write; wrapping both in one Lua script closes that window completely, since nothing else can run mid-script. The second invocation returns 0 because the value is already "new", so the swap correctly refuses to happen twice.
How it works step by step
- The client sends
EVALwith the script text,numkeys, the key names, and the extra arguments. - Redis compiles the Lua chunk (and caches it by its SHA1 hash internally, exactly as
SCRIPT LOADwould) and populates theKEYSandARGVtables from what you sent. - The script begins executing on Redis’s single main thread. Every
redis.call(...)inside it runs immediately and synchronously, just like a command typed at the prompt. - No other client’s command, keyspace notification, or expiration sweep can run until the script returns — the entire script is one uninterrupted unit of work.
- The script’s final
returnvalue is converted from its Lua type into the matching RESP reply type and sent back to the client. - Any writes the script performed are propagated to replicas and the AOF as the underlying commands it actually issued (effects replication), not as the Lua source itself.
Common Mistakes
Mistake 1: Hardcoding key names instead of using KEYS.
EVAL "return redis.call('SET', 'user:1001:name', ARGV[1])" 0 "Bob"
GET user:1001:name
OK
"Bob"
This works on a single standalone instance, but it’s a trap: Redis Cluster uses the declared KEYS to figure out which node should run the script, and tooling that inspects scripts for the keys they touch can’t see a key baked into the string. Always route every key the script touches through KEYS[n], never as a literal inside the script body.
Mistake 2: Assuming a mid-script error rolls back earlier writes in the same script.
RPUSH mylist:queue "task1"
EVAL "redis.call('SET', KEYS[1], 'done-so-far'); return redis.call('INCR', KEYS[2])" 2 status:job:99 mylist:queue
GET status:job:99
(integer) 1
(error) WRONGTYPE Operation against a key holding the wrong kind of value
"done-so-far"
The atomicity of a Lua script means no interleaving with other clients — it does not mean all-or-nothing rollback like a database transaction. Here the script sets status:job:99 successfully and only then hits a WRONGTYPE error trying to INCR a key that’s really a list. The script aborts and the client gets an error reply, but the earlier SET already happened and stays in place, as the final GET shows. Validate your arguments and key types as early as possible in the script, before performing any writes.
Mistake 3: Re-sending the full script text on every call in production.
Calling plain EVAL with the full script body every time works, but wastes bandwidth and re-parsing effort once a script is known to be stable. Load it once and call it by hash instead:
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
EVALSHA <sha1-returned-by-script-load> 1 user:1001:name
"<40-character sha1 hex digest>"
"Ada"
SCRIPT LOAD compiles and caches the script without running it, returning its SHA1 digest; EVALSHA then invokes it by that hash. If the server doesn’t recognize the hash (for example, after a restart with no persistence, or on a replica that hasn’t seen it), it replies with a NOSCRIPT error, and the well-behaved fallback is to resend the full script with EVAL once.
Best Practices
- Always pass key names through
KEYS, never hardcode them — required for Cluster routing and for tooling that inspects a script’s key footprint. - Keep scripts short and fast. Because a script blocks every other client on a single-threaded server for its entire duration, a slow script (a big loop, a large
KEYSscan) stalls your whole Redis instance, not just the caller. - Validate
ARGVand key types near the top of the script, before issuing any writes, since a later error will not undo writes the script already made. - Prefer
SCRIPT LOAD+EVALSHAover repeatedEVALcalls in production to avoid resending script text on every invocation, and fall back toEVALonly when you get aNOSCRIPTerror. - Use
redis.pcallinstead ofredis.callwhen you want to inspect or recover from a failing sub-command inside the script rather than aborting immediately. - Avoid nondeterministic logic (unseeded randomness, wall-clock branching) in scripts that write data, since it can cause primary/replica or AOF-replay divergence.
- Reach for a script only when you genuinely need atomic multi-step logic with conditionals; a single Redis command or a plain
MULTI/EXECtransaction is simpler when that’s all you need.
Practice Exercises
- Write a Lua script invoked with
EVALthat atomically reads a key’s value and deletes the key in the same step (a “pop”), returning the value that was there (or nil if the key didn’t exist). Hint: callGETthenDELonKEYS[1]inside one script and return the value you read. - Extend the rate-limiter script from Example 3 so it takes a maximum allowed count as a second
ARGVentry and returns1if the caller is now over the limit, or0if they’re still within it. - Load a script with
SCRIPT LOAD, confirm it’s cached withSCRIPT EXISTS, then call it several times withEVALSHAinstead ofEVAL. Compare how much you have to send over the wire each way.
Summary
EVAL script numkeys key [key ...] arg [arg ...]runs a Lua script atomically on the server; no other client’s command can interleave with it.- Keys go into the
KEYSLua table and other arguments intoARGV; call Redis commands from inside the script withredis.callorredis.pcall. - Scripting can do things plain
MULTI/EXECtransactions can’t, like reading a value and branching on it before deciding what to write — useful for rate limiters and compare-and-swap updates. - Atomic means uninterrupted, not all-or-nothing: writes a script makes before hitting an error are not rolled back.
SCRIPT LOADcaches a script and returns its SHA1 hash;EVALSHAthen runs it by hash, saving bandwidth over resending the source every time.- Because scripts run on Redis’s single thread, keep them short — a slow script blocks the entire server, not just its own caller.
