JusDB
Free DB Audit
NoSQL Databases

From EVAL to Functions on Valkey: Script-Cache Eviction, NOSCRIPT Storms, and Cluster-Safe Deployment

Fix NOSCRIPT errors after failover, spot the Valkey 8.0 EVAL cache eviction, and move Lua scripts to function libraries that load safely on every cluster node.

JusDB Team
Published November 1, 2026
Updated November 10, 2026
19 min read

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.

TL;DR
  • The EVAL script cache is per process, not persisted, and not replicated. After a restart, a failover, or SCRIPT FLUSH, EVALSHA returns NOSCRIPT.
  • Since Valkey 8.0, scripts first loaded by EVAL are evicted beyond 500 entries and counted in evicted_scripts. Scripts loaded with SCRIPT LOAD are kept. The eval-intro page still says only SCRIPT FLUSH empties the cache.
  • Function libraries are saved in RDB and AOF files and replicated to replicas. FLUSHALL does not remove them. FCALL_RO on a replica needs the no-writes flag.
  • 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=no runs neither EVAL nor 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.

bash
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

Why EVALSHA fails after a Valkey failover Step 1: application instances call a Lua script on the primary with EVALSHA, and the primary's script cache holds it. Step 2: the primary sends only the script's write effects to its replica, so the replica's script cache stays empty. Step 3: the primary fails, the replica is promoted, and every EVALSHA returns NOSCRIPT until each client resends the full script. App instances EVALSHA by digest Primary script cache: full Replica script cache: empty 1 2 write effects only, no script source 3 failover: the replica is promoted and every EVALSHA returns NOSCRIPT until each client resends the full script

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.

bash
# 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 NOSCRIPT

Interpretation: 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 LOAD never enters the list. If a script first arrived through EVAL and is later sent with SCRIPT LOAD, Valkey PR #637 promotes it out of the list, also in 8.0.0.
  • Running a script refreshes it. Each EVAL or EVALSHA of 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 EVAL to 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.

bash
# 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 documentation is out of date here

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

PropertyEVAL and EVALSHA
Where the code livesIn the application; each node keeps a cache
How you call itEVAL with source or EVALSHA with a SHA1 digest
After a restartGone; the cache is not persisted
On replicasNot copied; replicas receive write effects only
After a failoverThe 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 deploymentEach client loads scripts node by node on demand
Read-only calls on replicasEVAL_RO or EVALSHA_RO
Error when code is missingNOSCRIPT No matching script.

Functions

PropertyFunctions
Where the code livesOn the server, as a named library
How you call itFCALL or FCALL_RO with the function name
After a restartReloaded from the RDB or AOF file
On replicasCopied; FUNCTION LOAD is a replicated write
After a failoverThe promoted replica already has the library
Eviction (8.0 and later)Never evicted; removed only by FUNCTION DELETE or FUNCTION FLUSH
Cluster deploymentLoad on every primary; atomic slot migration does not copy it
Read-only calls on replicasFCALL_RO, only for functions flagged no-writes
Error when code is missingERR 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:

bash
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 60

Interpretation: 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
#!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 for FCALL_RO and for read-only replicas; also allows running when out of memory.
  • allow-oom: lets a writing function run over maxmemory. Use it only for functions that free memory.
  • allow-stale: runs on a replica set to replica-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

ops@app-01:~
$ cat ratelimit.lua | valkey-cli -x FUNCTION LOAD REPLACE"ratelimit"127.0.0.1:6379> FCALL rl_take 1 rl:{user:42} 10 60(integer) 1127.0.0.1:6379> FCALL_RO rl_remaining 1 rl:{user:42} 10(integer) 9127.0.0.1:6379> FCALL rl_tkae 1 rl:{user:42} 10 60(error) ERR Function not found

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.

bash
# 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 ratelimit

Interpretation: 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-node runs FUNCTION DUMP on an existing node and FUNCTION RESTORE on 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.

bash
# 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
done

Interpretation: 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.

bash
# 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")
EOF

Interpretation: 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.

Keep the source in git, not only on the server

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

  1. 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)'
  2. 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.

  3. 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
  4. 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)"
  5. 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
  6. 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):'
  7. 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
  8. 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.

bash
# Which scripting engines does this 9.1 node have?
valkey-cli INFO scriptingengines

Interpretation: 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-writes on read-only functions so FCALL_RO works 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.

Share this article

Database engineering notes

Articles like this one, in your inbox. No spam, unsubscribe anytime.

JusDB Team

Official JusDB content team

Keep reading

Moving a Multi-Database Valkey Instance into a 9.x Cluster

Move a Valkey instance that uses SELECT 1 to 15 into a 9.x cluster: keep or remap databases, copy each one, verify counts per database, and keep a way back.

Redis18 minNov 10, 2026
Read

Valkey Keyspace Notifications in Production: Expired Events, hexpired and Cluster Mode

Run Valkey keyspace notifications in production: why expired and hexpired events lag the TTL, how to subscribe on every cluster node, and when a stream is safer.

Redis20 minNov 10, 2026
Read

Valkey GLIDE in Production: AZ-Affinity Reads and Migrating from Legacy Clients

Configure Valkey GLIDE AZ-affinity reads on servers and clients, learn what GLIDE 2.5.3 does not support, and migrate from redis-py, Jedis, ioredis, or go-redis one service at a time.

Redis20 minNov 10, 2026
Read