An application that runs Lua scripts with EVALSHA works until the node it talks to forgets the script. Every call then returns NOSCRIPT No matching script., and a failover or restart becomes a burst of errors and retries. Restarts, failovers, and SCRIPT FLUSH have always emptied the cache. Valkey 8.0 added a fourth way, and the documentation still does not mention it: the EVAL script cache keeps at most 500 scripts that arrived through EVAL and evicts the oldest. Valkey Functions (FUNCTION LOAD, FCALL, and FCALL_RO) store code as named libraries that are persisted and replicated, so restarts, failovers, and eviction do not remove them. This guide covers where NOSCRIPT comes from, what changed in 8.0, how to load function libraries on every cluster node, and a migration path that keeps the old scripts working until the new ones are proven.
- The
EVALscript cache is per process, not persisted, and not replicated. After a restart, a failover, orSCRIPT FLUSH,EVALSHAreturns NOSCRIPT. - Since Valkey 8.0, scripts first loaded by
EVALare evicted beyond 500 entries and counted inevicted_scripts. Scripts loaded withSCRIPT LOADare kept. The eval-intro page still says onlySCRIPT FLUSHempties the cache. - Function libraries are saved in RDB and AOF files and replicated to replicas.
FLUSHALLdoes not remove them.FCALL_ROon a replica needs theno-writesflag. - In a cluster, load each library on every primary with
valkey-cli --cluster call, then check every node. Atomic slot migration in 9.0 does not copy functions. - A server built with
BUILD_LUA=noruns neitherEVALnor Lua functions, and cannot load an RDB file that contains Lua libraries.
Scope and review date: November 2026. This post covers Valkey 8.0 to 9.1 (latest patches 8.0.12, 8.1.11, 9.0.7, and 9.1.3), with notes on 7.2 and the 9.2 release candidates. Commands, error strings, and INFO fields were checked against the Valkey documentation, release notes, and server source on 10 November 2026. They were not run in a lab, so rehearse them on staging.
Why does EVALSHA return NOSCRIPT after a failover or restart?
EVAL sends the script source. The server compiles it, stores it in a cache keyed by its SHA1 digest, and runs it. EVALSHA sends only the digest. The Valkey eval-intro page calls this cache "always volatile": it may be cleared when the server restarts, during a failover when a replica becomes the primary, or by SCRIPT FLUSH.
The failover case catches teams out most often. Since Redis OSS 7.0, scripts replicate as effects: the primary sends the write commands a script produced, wrapped in MULTI/EXEC, and never the script itself. SCRIPT LOAD carries no write flag in the command table, so it is not propagated either. A replica's cache therefore holds only the scripts that ran on that replica, such as EVAL_RO reads. When it is promoted, every EVALSHA from every application instance misses at the same moment.
127.0.0.1:6379> EVALSHA ffffffffffffffffffffffffffffffffffffffff 0
(error) NOSCRIPT No matching script.Interpretation: the wording changed between major versions. Valkey 7.2 returns NOSCRIPT No matching script. Please use EVAL., and 8.0 and later return NOSCRIPT No matching script. (compare shared.noscripterr in src/server.c on the 7.2 and 8.0 branches). Client libraries match the NOSCRIPT prefix, so they still work. Your own code that compares the whole message does not.
A NOSCRIPT storm after failover
The script source never reaches the replica, so its cache is empty when it is promoted in step 3
With Functions: FUNCTION LOAD is a write command, so the replica already holds the library when it is promoted and step 3 produces no errors.
Clients recover in different ways. valkey-py's Script object catches NOSCRIPT, runs SCRIPT LOAD, and retries. ioredis sends EVAL the first time a script runs on each connection and again after a NOSCRIPT. Either way, every connection resends the full source while the cluster is busiest. Inside a pipeline or MULTI block, a NOSCRIPT reply cannot be handled at all.
# Did this node restart, and how many scripts does it hold now?
valkey-cli -h 10.0.1.12 INFO server | grep '^run_id'
valkey-cli -h 10.0.1.12 INFO memory | grep -E '^(number_of_cached_scripts|used_memory_scripts_eval)'
valkey-cli -h 10.0.1.12 INFO errorstats | grep NOSCRIPTInterpretation: a new run_id means a new process with an empty cache. errorstat_NOSCRIPT:count=N counts NOSCRIPT replies since the last restart or CONFIG RESETSTAT. A count that jumps right after a failover and then flattens is the storm in the diagram.
Does Valkey evict scripts from the EVAL cache?
Yes, since 8.0. Valkey ported an upstream change (Redis #13108, "Lua eval scripts first in first out LRU eviction") into the code that became 8.0.0, released on 15 September 2024. In src/eval.c at the 8.0.0 tag, LRU_LIST_LENGTH is 500. When a 501st script arrives through EVAL, the server deletes the least recently used EVAL script and increments stat_evictedscripts. The same constant is still in 9.1.3. The 7.2 branch has no such list, and the 8.0 release notes do not mention the change.
Three details decide whether you are affected:
- Only EVAL-loaded scripts are evicted. A script added with
SCRIPT LOADnever enters the list. If a script first arrived throughEVALand is later sent withSCRIPT LOAD, Valkey PR #637 promotes it out of the list, also in 8.0.0. - Running a script refreshes it. Each
EVALorEVALSHAof an EVAL-loaded script moves it to the newest end, so scripts in steady use survive. - The limit is per node and shared. Every application, job runner, and admin tool that sends
EVALto the node fills the same 500 slots.
The usual cause is script text built at runtime, with values pasted into the source instead of passed as KEYS and ARGV. Each distinct body has a new digest, so a stream of one-off scripts pushes out any stable script that an ioredis-style client loaded with EVAL and has not run recently. The next EVALSHA misses, the client resends EVAL, and that evicts another script.
# Is the 8.0+ eviction happening on this node?
valkey-cli INFO stats | grep '^evicted_scripts'
valkey-cli INFO memory | grep '^number_of_cached_scripts'
valkey-cli INFO commandstats | grep -E 'cmdstat_(eval|evalsha|script)'Interpretation: evicted_scripts above zero means the cache has hit 500 EVAL-loaded scripts at least once. A cached-script count pinned near 500 with a rising eviction counter means dynamic script text. Compare cmdstat_eval calls with cmdstat_evalsha calls: a high share of EVAL in steady state means clients keep resending source.
The eval-intro page still says "The only way to flush the script cache is by explicitly calling the SCRIPT FLUSH command" and that scripts stay in the cache until a restart or flush. That has not been true for EVAL-loaded scripts since 8.0. The 9.2.0-rc1 release notes add a maxmemory-scripts setting for cached EVAL scripts and make SCRIPT LOAD respect maxmemory. That is release-candidate behavior, not GA.
What do Valkey Functions change compared with EVAL scripts?
Functions arrived in Redis OSS 7.0, so every Valkey release has them. The Valkey Functions introduction describes them as "first-class artifacts of the database": a library is loaded once, given a name, persisted, and replicated, and the application calls functions by name. Each library is replaced as a whole, which makes it a natural unit to version and deploy.
EVAL scripts and Functions side by side
Switch tabs to see where the code lives and what survives a restart, a failover, and eviction
EVAL and EVALSHA
| Property | EVAL and EVALSHA |
|---|---|
| Where the code lives | In the application; each node keeps a cache |
| How you call it | EVAL with source or EVALSHA with a SHA1 digest |
| After a restart | Gone; the cache is not persisted |
| On replicas | Not copied; replicas receive write effects only |
| After a failover | The new primary lacks scripts that ran on the old one |
| Eviction (8.0 and later) | EVAL-loaded scripts beyond 500 are evicted; SCRIPT LOAD scripts stay |
| Cluster deployment | Each client loads scripts node by node on demand |
| Read-only calls on replicas | EVAL_RO or EVALSHA_RO |
| Error when code is missing | NOSCRIPT No matching script. |
Functions
| Property | Functions |
|---|---|
| Where the code lives | On the server, as a named library |
| How you call it | FCALL or FCALL_RO with the function name |
| After a restart | Reloaded from the RDB or AOF file |
| On replicas | Copied; FUNCTION LOAD is a replicated write |
| After a failover | The promoted replica already has the library |
| Eviction (8.0 and later) | Never evicted; removed only by FUNCTION DELETE or FUNCTION FLUSH |
| Cluster deployment | Load on every primary; atomic slot migration does not copy it |
| Read-only calls on replicas | FCALL_RO, only for functions flagged no-writes |
| Error when code is missing | ERR Function not found |
The trade: Functions move deployment from the application to the database. You stop handling NOSCRIPT, and in exchange you own a load step on every primary.
The execution model does not change. A function runs atomically and blocks the server, busy-reply-threshold (5000 ms by default) still decides when other clients get BUSY, and FUNCTION KILL stops a function that has not written yet. FLUSHALL deletes keys but leaves libraries in place ("flushall should not flush the functions", says the 9.1 source). Use FUNCTION FLUSH when you mean it.
How do you turn an EVAL script into a function library?
Take a fixed-window rate limiter that an application sends with EVAL. The key arrives in KEYS, the limit and window in ARGV:
valkey-cli EVAL "local n = server.call('INCR', KEYS[1])
if n == 1 then server.call('EXPIRE', KEYS[1], ARGV[2]) end
if n > tonumber(ARGV[1]) then return 0 end
return 1" 1 rl:{user:42} 10 60Interpretation: it returns 1 while the caller is under 10 calls in the 60-second window, then 0. The script is fine; the problem is that every client owns a copy and every node caches it separately.
As a library, the same logic becomes a file with a shebang line that names the engine and the library. Each function receives the key names as its first argument and the other arguments as its second. A read-only helper is registered with the table form so that it can carry flags:
#!lua name=ratelimit
local function take(keys, args)
local limit = tonumber(args[1])
local window = tonumber(args[2])
local used = server.call('INCR', keys[1])
if used == 1 then
server.call('EXPIRE', keys[1], window)
end
if used > limit then
return 0
end
return 1
end
local function remaining(keys, args)
local limit = tonumber(args[1])
local used = tonumber(server.call('GET', keys[1]) or '0')
return math.max(limit - used, 0)
end
server.register_function('rl_take', take)
server.register_function{
function_name = 'rl_remaining',
callback = remaining,
flags = { 'no-writes' }
}Interpretation: FUNCTION LOAD fails with ERR No functions registered if the file registers nothing, and refuses a name that another library already uses, even with REPLACE. Libraries written for Redis that call redis.call and redis.register_function still load: the Valkey Lua API page keeps the redis object as an alias of server.
Check the flags before you switch callers
The defaults differ between the two models, and the script flags reference lists them. A function, like an EVAL script that starts with a #! line, cannot run on a stale replica, is refused when the server is over maxmemory, and cannot touch keys in more than one hash slot. A plain EVAL script without a shebang keeps the older behavior: the eval-intro page says it can access keys in different hash slots, and it aborts only at its first write that needs more memory. Code that relied on those older rules breaks when it moves into a library.
no-writes: needed forFCALL_ROand for read-only replicas; also allows running when out of memory.allow-oom: lets a writing function run overmaxmemory. Use it only for functions that free memory.allow-stale: runs on a replica set toreplica-serve-stale-data no.allow-cross-slot-keys: restores cross-slot access. In a cluster, prefer a hash tag such as{user:42}.
Loading and calling the ratelimit library
Illustrative output for a standalone Valkey 9.1 node; reply formats follow the Functions introduction
Name typos are loud: a misspelled function returns ERR Function not found on the first call instead of a NOSCRIPT that a client library silently retries. The error text is from src/functions.c in 9.1.3.
How do you load functions on every node of a Valkey cluster?
Replication copies a library from a primary to its replicas, but nothing copies it between shards. The Functions introduction says "it is necessary to load functions to all primaries" and points to valkey-cli --cluster call. That subcommand sends one command to every node it finds through the seed node, and --cluster-only-primaries limits it to primaries. Replicas would reject FUNCTION LOAD anyway, because it is a write command.
# Load or replace the library on every primary
valkey-cli --cluster-only-primaries --cluster call 10.0.1.10:6379 \
FUNCTION LOAD REPLACE "$(cat ratelimit.lua)"
# Then check every node, replicas included
valkey-cli --cluster call 10.0.1.10:6379 FUNCTION LIST LIBRARYNAME ratelimitInterpretation: the first command prints one host:port: ratelimit line per primary. --cluster call takes its arguments as given, so pass the file with "$(cat ...)" rather than -x. A node it cannot reach is skipped, not retried. Count the lines against your primary count before you call the deployment done. Add -a or --user and --pass, and --tls, as your cluster requires.
Two events add nodes, and they behave differently:
valkey-cli --cluster add-noderunsFUNCTION DUMPon an existing node andFUNCTION RESTOREon the new primary. It refuses a new node that already has libraries ("New node already contains functions and can not be added to the cluster. Use FUNCTION FLUSH and try again."). A new replica gets libraries from its primary.- Atomic slot migration, new in 9.0, does not move functions. The 9.0.0-rc2 notes list "Do not migrate script functions in atomic slot migration" (PR #2547): copying them made migrations fail when the target already had the library. Load the library on the target before you move slots to it.
A node that joins through CLUSTER MEET sent by your own tooling gets no libraries at all. Add a FUNCTION LIST check to whatever builds new nodes.
# Compare library code across all nodes: one digest per node, all identical
for n in $(valkey-cli -h 10.0.1.10 CLUSTER NODES | awk '{split($2,a,"@"); print a[1]}'); do
printf '%s ' "$n"
valkey-cli -h "${n%:*}" -p "${n#*:}" FUNCTION LIST WITHCODE | sha256sum | cut -c1-12
doneInterpretation: every node should print the same digest. A node with a different digest has an old library version or none at all. Run the loop after each deployment and after each node replacement.
How do functions survive restarts, cache-only nodes, and rebuilds?
On a node with persistence, nothing extra is needed. The RDB writer saves each library as its own record, and the AOF records FUNCTION LOAD like any other write. An AOF rewrite without an RDB preamble writes the libraries back out as FUNCTION LOAD commands. The node loads them before it serves clients.
Cache-only nodes, with save "" and appendonly no, start empty. The Functions introduction suggests valkey-cli --functions-rdb, which writes an RDB file containing only the libraries from a running node. Place it at the node's dir and dbfilename before startup. It uses the replication protocol (REPLCONF, then SYNC), so the ACL user needs those commands.
# Extract only the libraries from a running node
valkey-cli -h 10.0.1.10 --functions-rdb /var/lib/valkey/functions.rdb
# Or copy them in code (valkey-py): DUMP once, RESTORE to each target
python3 - <<'EOF'
import valkey
src = valkey.Valkey(host="10.0.1.10")
payload = src.function_dump()
for host in ["10.0.2.10", "10.0.2.11"]:
valkey.Valkey(host=host).function_restore(payload, "REPLACE")
EOFInterpretation: FUNCTION RESTORE has three policies. APPEND, the default, aborts if a library name already exists. REPLACE overwrites libraries with the same name. FLUSH deletes every library first. The payload is binary, so move it through a client library or --functions-rdb, not through shell variables.
FUNCTION DUMP and RDB files are backups, not a release process. Review, tag, and deploy each .lua library like application code, so FUNCTION LIST WITHCODE on any node can be compared with a known commit.
What are the steps to migrate from EVAL to Functions?
The migration needs no outage, because EVAL and FCALL work side by side. Keep the old path working until the counters show it is idle.
EVAL to Functions migration runbook
Eight steps with no outage window; the old EVAL path stays in place until step 8
-
Inventory every script
Search the code for EVAL, EVALSHA, and script objects, and record script traffic per node. Include job queues and libraries that use scripts internally.
valkey-cli INFO commandstats | \ grep -E 'cmdstat_(eval|evalsha|eval_ro|evalsha_ro)' -
Group scripts into libraries
One library per service or domain, named in the shebang line. Move values out of the script text and into arguments.
-
Set flags and test on staging
Add no-writes to read-only functions. Run every function against staging, including on a replica with FCALL_RO.
cat ratelimit.lua | valkey-cli -x \ FUNCTION LOAD REPLACE -
Load on every primary
Use the cluster call command and count one reply per primary. Repeat for every cluster and environment.
valkey-cli --cluster-only-primaries \ --cluster call 10.0.1.10:6379 \ FUNCTION LOAD REPLACE "$(cat ratelimit.lua)" -
Verify every node
Check primaries and replicas, and compare library code against the tagged source.
valkey-cli --cluster call 10.0.1.10:6379 \ FUNCTION LIST LIBRARYNAME ratelimit -
Switch callers behind a flag
Move one service at a time from EVALSHA to FCALL. On ERR Function not found, alert and fall back to the old EVAL path.
valkey-cli INFO commandstats | \ grep -E 'cmdstat_(fcall|fcall_ro):' -
Add functions to node builds and backups
New nodes, cache-only nodes, and slot-migration targets get the library before they take traffic.
valkey-cli --functions-rdb \ /var/lib/valkey/functions.rdb -
Remove the EVAL path
When the eval and evalsha counters stop growing for a full business cycle, delete the old scripts. Nonzero failed_calls on fcall means a node lacks the library.
Rollback point: until step 8, turning the flag off sends callers back to EVALSHA, and the loaded libraries can stay on the server. The main risk is a primary that missed step 4, so do not skip step 5.
What happens to scripts and functions when Lua is not available?
Both models depend on a scripting engine, and in Valkey 9.1 Lua is one. Valkey 8.1 added support for scripting engines as modules. The 9.1 release notes move Lua into a module in 9.1.0-rc1 and link it statically by default in 9.1.0-rc2, so a standard 9.1 build always has Lua. There is no setting that turns it off at runtime. A server without Lua is a different build, made with make BUILD_LUA=no. Our Valkey hardening guide covers when that is worth it.
# Which scripting engines does this 9.1 node have?
valkey-cli INFO scriptingenginesInterpretation: on a default build, engines_count is at least 1 and an engine_ line names each engine. On a BUILD_LUA=no build, the 9.1.3 source shows what fails. EVAL returns Could not find scripting engine. FUNCTION LOAD returns Engine 'lua' not found. An RDB file that contains a Lua library logs Failed loading library and the load aborts, so test a restore before you replace a node with a Lua-free build. Run the inventory from step 1 first, and remove every library with FUNCTION DELETE before you make the switch.
Frequently asked questions
Why do I get NOSCRIPT No matching script on Valkey?
EVALSHA names a script by its SHA1 digest, and the node you reached does not have that script in its cache. The cache is lost on restart, on SCRIPT FLUSH, and in practice on failover, because replicas receive write effects and never the script source. Since Valkey 8.0, scripts loaded by EVAL are also evicted beyond 500 entries. Clients must resend the source, or you can move the script into a function library.
Does Valkey evict Lua scripts from the script cache?
Yes, since Valkey 8.0. The server keeps at most 500 scripts that were loaded through EVAL and evicts the least recently used one when a new script arrives. Each eviction increments evicted_scripts in INFO stats. Scripts loaded with SCRIPT LOAD are not evicted. Valkey 7.2 has no such limit, and the eval-intro documentation page does not describe the behavior yet.
Are Valkey functions replicated and persisted?
Yes. FUNCTION LOAD is a write command, so it replicates from a primary to its replicas and is recorded in the AOF. Libraries are also saved in RDB snapshots and reloaded at startup. FLUSHALL does not delete them; FUNCTION FLUSH does. In a cluster, replication does not copy libraries between shards, so you load each library on every primary.
How do I load a Valkey function on every node in a cluster?
Run valkey-cli with --cluster-only-primaries and --cluster call against any node, followed by FUNCTION LOAD REPLACE and the library source. Replicas receive the library from their primaries. Then run FUNCTION LIST on every node to confirm, because unreachable nodes are skipped. valkey-cli --cluster add-node copies libraries to a new node, but atomic slot migration in Valkey 9.0 does not.
Key takeaways
- NOSCRIPT after a failover is expected: the script cache is per node, and replicas never receive script source.
- On Valkey 8.0 and later, watch
evicted_scripts. A count above zero means more than 500 EVAL-loaded scripts, usually from script text built at runtime. - Function libraries persist in RDB and AOF and replicate. Set
no-writeson read-only functions soFCALL_ROworks on replicas. - In a cluster, load libraries on every primary, verify every node, and load them on slot-migration targets yourself.
- Migrate service by service behind a flag, and remove the EVAL path only when its counters stop growing.
Plan a Valkey Functions migration with JusDB
JusDB's Valkey consulting team can review your script usage and cache counters, plan the move from EVAL to function libraries, and write the deployment and verification steps for your clusters, replicas, and cache-only nodes.
Bring your Valkey versions and topology, the client libraries you use, and the output of INFO commandstats from a busy primary. Your team approves every production change.