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 the KEYS table); everything after that is a plain argument (populates ARGV).
  • 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 with SCRIPT LOAD or a prior EVAL call.
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 EVAL with 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 LOAD would) and populates the KEYS and ARGV tables 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 return value 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 KEYS scan) stalls your whole Redis instance, not just the caller.
  • Validate ARGV and 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 + EVALSHA over repeated EVAL calls in production to avoid resending script text on every invocation, and fall back to EVAL only when you get a NOSCRIPT error.
  • Use redis.pcall instead of redis.call when 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/EXEC transaction is simpler when that’s all you need.

Practice Exercises

  • Write a Lua script invoked with EVAL that 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: call GET then DEL on KEYS[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 ARGV entry and returns 1 if the caller is now over the limit, or 0 if they’re still within it.
  • Load a script with SCRIPT LOAD, confirm it’s cached with SCRIPT EXISTS, then call it several times with EVALSHA instead of EVAL. 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 KEYS Lua table and other arguments into ARGV; call Redis commands from inside the script with redis.call or redis.pcall.
  • Scripting can do things plain MULTI/EXEC transactions 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 LOAD caches a script and returns its SHA1 hash; EVALSHA then 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.