Skip to main content
Redis · lesson 10 of 14
Redis advanced Lesson 10 of 14

Lua Scripting, EVALSHA's NOSCRIPT, and Functions That Forget to Declare no-writes

A guessed script hash fails with a real NOSCRIPT error, and a Function callable via FCALL refuses to run through FCALL_RO until it explicitly declares itself read-only — Redis won't infer that for you.

By SkillByExample · Published

redis lua scripting functions eval

A Lua script gives you one atomic round trip instead of several — and the two ways to run one (EVAL/EVALSHA versus FUNCTION/FCALL) fail differently when used carelessly.

Atomic read-then-write in one script

docker exec redis-lesson redis-cli SET stock 5
docker exec redis-lesson redis-cli EVAL "local s = tonumber(redis.call('GET', KEYS[1])); if s > 0 then return redis.call('DECR', KEYS[1]) else return -1 end" 1 stock
OK
4

The whole “check if any stock remains, then decrement” happens on the server, atomically — no other client’s command can run between the GET and the DECR inside this script, which is exactly the race the WATCH-based transaction lesson needed a retry loop to handle.

docker exec redis-lesson redis-cli SET stock 0
docker exec redis-lesson redis-cli EVAL "local s = tonumber(redis.call('GET', KEYS[1])); if s > 0 then return redis.call('DECR', KEYS[1]) else return -1 end" 1 stock
OK
-1

At zero stock, the script returns -1 and never calls DECR at all — the guard and the action are inseparable inside one script, with no window for another client to sneak in between the check and the decrement.

EVALSHA and the bug: a hash Redis has never seen

docker exec redis-lesson redis-cli SCRIPT LOAD "return redis.call('GET', KEYS[1])"
d3c21d0c2b9ca22f82737626a27bcaf5d288f99f

SCRIPT LOAD caches the script and returns its real SHA-1 hash. Now try EVALSHA with a plausible-looking but wrong hash:

docker exec redis-lesson redis-cli EVALSHA e0e1f9fabfc9d4800c877a703b823ac0578ff831 1 mykey
NOSCRIPT No matching script. Please use EVAL.

That hash was never actually loaded — it looks like a real SHA-1 but doesn’t correspond to any cached script, so Redis refuses to guess. The real hash, returned by SCRIPT LOAD a moment ago, works correctly:

docker exec redis-lesson redis-cli EVALSHA d3c21d0c2b9ca22f82737626a27bcaf5d288f99f 1 mykey
hello from lua

Functions: the bug — forgetting no-writes

# mylib.lua
#!lua name=mylib
redis.register_function('my_get', function(keys, args)
    return redis.call('GET', keys[1])
end)
docker exec -i redis-lesson redis-cli -x FUNCTION LOAD < mylib.lua
docker exec redis-lesson redis-cli FCALL my_get 1 mykey
mylib
hello from lua

Works fine through FCALL. Now try the read-only variant:

docker exec redis-lesson redis-cli FCALL_RO my_get 1 mykey
ERR Can not execute a script with write flag using *_ro command.

my_get never actually writes anything — but Redis doesn’t inspect the function body to figure that out. Without an explicit no-writes flag on registration, a function is assumed capable of writing, and FCALL_RO refuses it outright.

The fix: declare no-writes explicitly

# mylib2.lua
#!lua name=mylib2
redis.register_function{
    function_name='my_get_ro',
    callback=function(keys, args) return redis.call('GET', keys[1]) end,
    flags={'no-writes'}
}
docker exec -i redis-lesson redis-cli -x FUNCTION LOAD < mylib2.lua
docker exec redis-lesson redis-cli FCALL_RO my_get_ro 1 mykey
mylib2
hello from lua

Same logic, registered with flags={'no-writes'} this time — FCALL_RO now runs it, because the function explicitly declared what it does rather than leaving Redis to assume the worst.

Practice

1. Check whether specific script hashes are cached without running them.
docker exec redis-lesson redis-cli SCRIPT EXISTS d3c21d0c2b9ca22f82737626a27bcaf5d288f99f 0000000000000000000000000000000000000000
1
0

SCRIPT EXISTS accepts multiple hashes and reports 1/0 per hash — the real one from earlier is cached, the all-zeros placeholder obviously isn’t. Useful for a client deciding whether it’s safe to EVALSHA or whether it needs to fall back to EVAL first.

2. List every loaded function library and its declared flags.
docker exec redis-lesson redis-cli FUNCTION LIST
library_name
mylib2
engine
LUA
functions
name
my_get_ro
description

flags
no-writes

FUNCTION LIST shows every registered library, its functions, and each function’s declared flags — the same no-writes flag that made FCALL_RO work is visible here directly, no need to re-read the library source to check.

3. Remove a loaded function library entirely.
docker exec redis-lesson redis-cli FUNCTION DELETE mylib2
docker exec redis-lesson redis-cli FUNCTION LIST
OK

FUNCTION DELETE removes the whole library and everything it registered — FUNCTION LIST afterward returns nothing, confirming my_get_ro is gone along with it.

Next: clustering — hash slots, resharding, and what a cluster-aware client actually needs.

Frequently Asked Questions

Why script this in Lua instead of using MULTI/EXEC or WATCH?
A script runs entirely on the server as one atomic step with no round trips at all — MULTI/EXEC still requires sending each command over the network, and WATCH-based optimistic locking requires a client-side retry loop when it aborts. A script that reads a value and conditionally acts on it (like this lesson's stock check) needs none of that: the whole read-then-decide-then-write happens server-side, atomically, in one call.
What's the point of EVALSHA over just calling EVAL every time?
EVALSHA sends only a 40-character hash instead of the full script body, which matters once a script is large or called frequently — Redis caches scripts by their SHA-1 hash after the first EVAL or an explicit SCRIPT LOAD. The tradeoff is that EVALSHA fails with NOSCRIPT if that exact hash was never cached on this server, so a real client needs to fall back to EVAL when that happens.
Why did EVALSHA fail on a hash that looked plausible?
The hash has to be the exact SHA-1 of a script this server has actually loaded (via EVAL or SCRIPT LOAD) — a made-up or mistyped hash, even one that looks like a real SHA-1, will never match anything in the script cache, and Redis returns NOSCRIPT rather than guessing what you meant.
Why did FCALL_RO refuse to run a function that FCALL ran fine?
FCALL_RO only executes functions explicitly registered with the no-writes flag — Redis doesn't analyze a function's body to infer whether it writes anything, it trusts the declared flag. A function with no flags declared is assumed capable of writing, so *_ro refuses it outright, even if that particular function never actually calls a write command.
When should I use Functions instead of plain EVAL scripts?
Functions are the more maintainable option for anything reused: they're loaded once as a named library (FUNCTION LOAD), callable by name afterward (no hash to track or NOSCRIPT to handle), and support declaring flags like no-writes that unlock FCALL_RO. Plain EVAL/EVALSHA still makes sense for a one-off script that isn't worth packaging as a library.