JusDB
Free DB Audit
NoSQL Databases

Distributed Locks on Valkey 9 Without Lua: SET IFEQ and DELIFEQ

Build a distributed lock on Valkey 9 without Lua: acquire with SET NX PX, extend with SET IFEQ, release with DELIFEQ, and add fencing tokens for failover.

JusDB Team
Published November 5, 2026
Updated November 10, 2026
18 min read

Valkey's own Distributed Locks page opens with a banner saying it is under review and "likely incorrect", and it still extends locks with a Lua script. Meanwhile, Valkey 8.1 added SET ... IFEQ and Valkey 9.0 added DELIFEQ. Together they cover the whole single-instance lock with plain commands. Redis-era lock libraries still bring the old problems: NOSCRIPT errors after a failover, or EVAL failing on a Valkey 9.1 build made without Lua. This post assembles the native pattern, then covers what no command fixes: a failover can still hand one lock to two clients, so you need fencing tokens wherever a double holder would corrupt data.

TL;DR
  • Acquire with SET lock:{job} <token> NX PX 30000, using a new random token for every acquisition.
  • Extend (Valkey 8.1 and later) with SET lock:{job} <token> IFEQ <token> PX 30000. It returns nil if the lock expired or belongs to someone else. Always pass PX: a successful SET drops the old TTL.
  • Release (Valkey 9.0 and later) with DELIFEQ lock:{job} <token>, which returns 1 or 0. DELEX lock:{job} IFEQ <token> does the same on the 9.2 release candidate.
  • Replication is asynchronous, so a failover can give the lock to a second client. WAIT and WAITAOF narrow that window but do not close it.
  • Where a double holder corrupts data, check a fencing token at the resource. Redlock does not remove that need.

Scope and review date: November 2026. This post covers Valkey 8.1, 9.0 and 9.1 (latest patches 8.1.11, 9.0.7 and 9.1.3) and the 9.2.0-rc2 release candidate, in standalone, Sentinel and cluster deployments. Commands, replies and version numbers were checked against the Valkey documentation, release notes and source code on 10 November 2026. They were not run in a lab, so try them on a staging instance first.


What does a correct single-instance lock look like on Valkey 9?

A lock is one string key whose value is a token only the holder knows. The distlock page says the value "must be unique across all clients and all lock requests" and suggests 20 bytes from /dev/urandom. The TTL is the lock validity time: finish or extend before it runs out. Each of the three commands checks the token on the server in one step.

bash
# One token per acquisition, never reused
TOKEN=$(openssl rand -hex 20)

# Acquire: OK if you got the lock, nil if another client holds it
valkey-cli SET "lock:{invoice-run}" "$TOKEN" NX PX 30000

# Extend while working (Valkey 8.1+): OK if you still hold it, nil if not
valkey-cli SET "lock:{invoice-run}" "$TOKEN" IFEQ "$TOKEN" PX 30000

# Release (Valkey 9.0+): 1 if deleted, 0 if you no longer held it
valkey-cli DELIFEQ "lock:{invoice-run}" "$TOKEN"

Interpretation: every reply tells the client whether it still owns the lock. A nil on extend or a 0 on release means the TTL ran out and another client may have done the work meanwhile. Log it as an incident, not a routine retry.

The braces in lock:{invoice-run} are a cluster hash tag. In cluster mode they keep the lock key and the fencing counter used later in one hash slot.

A lock acquired, extended and released with native commands

Illustrative output in the shape valkey-cli prints; the short tokens stand in for 40-character random ones

valkey-cli: Valkey 9.1 primary
127.0.0.1:6379> SET lock:{invoice-run} a3f9c2e1d4 NX PX 30000OK127.0.0.1:6379> SET lock:{invoice-run} a3f9c2e1d4 IFEQ a3f9c2e1d4 PX 30000OK127.0.0.1:6379> DELIFEQ lock:{invoice-run} 77b0e5f1c2(integer) 0127.0.0.1:6379> DELIFEQ lock:{invoice-run} a3f9c2e1d4(integer) 1

The 0 is the point: a stale worker holding token 77b0e5f1c2 cannot delete the current holder's lock. The reply semantics are from the DELIFEQ reference.

How do you extend a lock with SET IFEQ?

The SET reference lists IFEQ as added in 8.1.0. It sets the key only when the stored value equals the comparison value, so SET key token IFEQ token PX 30000 means "if I still hold the lock, give me another 30 seconds". On a mismatch the reply is nil and nothing changes.

The reference does not say what IFEQ does when the key is missing (it does say so for IFNE). The 9.1.3 source answers it: a missing key aborts the SET with a nil reply. An extend after expiry therefore cannot bring a lost lock back to life, which is the behavior you want.

The same source and its tests show two more details. IFEQ sits in the same option group as NX and XX, so combining them returns ERR syntax error. A key holding a non-string fails with WRONGTYPE.

bash
# Wrong: succeeds, but removes the TTL, so the lock never expires
SET "lock:{invoice-run}" "$TOKEN" IFEQ "$TOKEN"

# Right: replace the TTL with a fresh 30 seconds
SET "lock:{invoice-run}" "$TOKEN" IFEQ "$TOKEN" PX 30000
PTTL "lock:{invoice-run}"

Interpretation: the SET reference says any previous TTL "is discarded on successful SET operation". An extend without PX turns the lock into a permanent key that only a manual delete removes. PTTL returns -1 for a key with no expiry and -2 for a missing key, so a -1 on a lock key is a bug in the extend call. KEEPTTL does not help either: it keeps the old deadline instead of moving it.

A nil on extend means stop

The lock expired or another client holds it. Stop writing to the protected resource and abandon or restart the task. Do not acquire again quietly in the middle of the work: the other holder may have done part of it.

Renew well before the deadline, for example every 10 seconds on a 30-second TTL, so one slow round trip does not lose the lock.

Should you release with DELIFEQ or DELEX?

On Valkey 9.0 and 9.1, use DELIFEQ. The reference lists it since 9.0.0. It deletes the key only if the stored string equals the given value and returns 1, and otherwise returns 0. A non-string key returns WRONGTYPE. The server propagates a successful DELIFEQ to replicas and the AOF as a plain DEL, which the 9.1.3 test suite checks.

DELEX arrived in 9.2.0-rc2 on 4 November 2026, so it is release-candidate only. Its syntax is DELEX key IFEQ|IFNE value. The pull request that added it, #4607, says "DELEX IFEQ is equivalent to the existing DELIFEQ command" and that the name was chosen for compatibility with Redis. Redis Open Source has had DELEX since 8.4.0, with two extra digest conditions that Valkey's version does not have.

bash
# Valkey 9.0 and later
DELIFEQ "lock:{invoice-run}" "$TOKEN"

# Valkey 9.2 release candidate only (Redis Open Source 8.4+ also accepts it)
DELEX "lock:{invoice-run}" IFEQ "$TOKEN"

# Check which of the two the connected server knows
COMMAND INFO DELIFEQ DELEX

Interpretation: COMMAND INFO returns nil in the position of a command the server does not know. On 9.0 and 9.1 you get details for DELIFEQ and nil for DELEX.

The 9.2.0-rc2 definition of DELIFEQ carries no deprecation marker. Write DELIFEQ today, and consider DELEX once 9.2 is GA if one code path must also serve Redis 8.4+. Never release with a plain DEL, which the SET page's lock pattern still mentions: it deletes whichever client's lock is there.

Why is the Lua compare-and-delete no longer needed?

Before 9.0, comparing a token and deleting or re-expiring the key atomically needed a script, and the distlock and SET pages still show one. Scripts work, but they carry operational baggage.

  • Script cache loss. The scripting introduction says the cache "may be cleared when the server restarts, during fail-over when a replica assumes the primary role". EVALSHA then returns NOSCRIPT until the client loads the script again, which is the moment your lock code is busiest.
  • Builds without Lua. Valkey 9.1 moved Lua into a module, and the 9.1 README documents make BUILD_LUA=no to build without it. On such a build, every EVAL-based lock fails.

Lua lock scripts against native lock commands

Switch tabs to see what the client sends at each stage, and what it depends on

Lua scripts

StageLua scripts
AcquireSET k token NX PX 30000
ExtendEVAL script: GET, compare, PEXPIRE
ReleaseEVAL script: GET, compare, DEL
Needs the Lua engineYes; fails on a build made with BUILD_LUA=no
After a failoverEVALSHA may return NOSCRIPT until the script is loaded again
Lowest Valkey versionAny

Native, 9.0 and 9.1

StageNative, 9.0 and 9.1
AcquireSET k token NX PX 30000
ExtendSET k token IFEQ token PX 30000
ReleaseDELIFEQ k token
Needs the Lua engineNo
After a failoverPlain commands; nothing to reload
Lowest Valkey version9.0 on every node that can become primary (8.1 for extend alone)

9.2 release candidate

Stage9.2 release candidate
AcquireSET k token NX PX 30000
ExtendSET k token IFEQ token PX 30000
ReleaseDELEX k IFEQ token, or DELIFEQ k token
Needs the Lua engineNo
After a failoverPlain commands; nothing to reload
Lowest Valkey version9.2.0-rc2; not GA as of November 2026

Rolling upgrades: if a failover can promote a node still on 8.x, DELIFEQ fails there as an unknown command. Switch the release path only after every node runs 9.0 or later.

bash
# Which version is this node, and which scripting engines are loaded?
valkey-cli INFO server | grep -E '^valkey_version'
valkey-cli INFO scriptingengines

Interpretation: read valkey_version; redis_version stays at 7.2.4 for compatibility. The 9.1 scriptingengines section shows engines_count, which must not be 0 if your lock library uses Lua.

What does a failover do to lock safety?

The distlock page describes the race: client A locks the primary, the primary crashes before the write reaches the replica, the replica is promoted, and client B takes the same lock. The page calls it a "SAFETY VIOLATION". The native commands change nothing here, because the cause is asynchronous replication.

How a failover hands one lock to two clients

Follow the numbers: the lock write never reaches the replica that becomes the new primary

Lock lost in a primary failover Step 1: client A sets the lock key on the primary with SET NX PX and gets OK. Step 2: the primary crashes before asynchronous replication sends the lock key to the replica, so the copy is lost. Step 3: the replica is promoted to primary, and client B sets the same lock key with SET NX PX and also gets OK. Both clients now believe they hold the lock. WAIT narrows the window in step 2, and a fencing token checked by the protected resource rejects the stale holder. Client A SET NX PX: OK Primary crashes Replica promoted Client B SET NX PX: OK 1 2 async copy never arrives 3 Result: A and B both hold lock:{invoice-run} WAIT narrows step 2; a fencing check at the resource rejects A

The commands are not the weak point: the race comes from asynchronous replication, so it applies equally to Lua scripts, DELIFEQ and DELEX.

Three tools narrow the window. None of them closes it.

bash
SET "lock:{invoice-run}" "$TOKEN" NX PX 30000
WAIT 1 100        # replicas that acknowledged, after at most 100 ms

# With AOF on the primary and its replicas:
WAITAOF 1 1 100   # [local fsyncs, replica fsyncs]

Interpretation: WAIT returns how many replicas acknowledged the connection's writes, even on timeout. A 0 means the lock exists only on the primary: release and retry, or carry on knowingly. The WAIT reference says it "does not make Valkey a strongly consistent store", because Sentinel and Cluster only make a best-effort attempt to promote the most up-to-date replica. WAITAOF (since 7.2.0) returns two counts, cannot run on a replica, and needs a first argument of 0 if AOF is off.

ini
# valkey.conf: refuse writes when no replica is close behind
min-replicas-to-write 1
min-replicas-max-lag 10

# fsync every AOF write (slower; the default is everysec)
appendonly yes
appendfsync always

Interpretation: per valkey.conf, min-replicas-to-write limits "the window of exposure for lost writes". It applies to every write on that primary, so it suits a dedicated lock instance better than a shared cache. The distlock page calls the fsync setting fsync=always; the real directive is appendfsync always, and it decides whether a lock survives a crash and restart.

Clocks are the last gap. The distlock page warns that Valkey does not use a monotonic clock for TTL expiry, so a wall-clock jump can expire a lock early. In the 9.1.3 source, SET ... PX also reaches replicas as an absolute PXAT timestamp, so after a failover the new primary's clock decides when the lock ends. Keep NTP healthy on every node. For choosing between Sentinel and Cluster in the first place, see Valkey Cluster vs Sentinel.

How do you add fencing tokens without Lua?

A fencing token is a number that grows with every acquisition. The holder sends it with each write, and the resource rejects any number not higher than the last one it accepted. Martin Kleppmann's analysis of Redlock explains why: a paused client can wake after its lease expired and still send a write, and only the resource can tell it is stale. The distlock page agrees: "You should implement fencing tokens."

The number must come from the same atomic step as the acquire. If a client sets the lock and then calls INCR separately, it can pause between the two, lose the lock, and still draw a higher number than the client that took over. A MULTI block does both without Lua:

bash
MULTI
SET "lock:{invoice-run}" "$TOKEN" NX PX 30000
INCR "fence:{invoice-run}"
EXEC
# 1) OK            you hold the lock
# 2) (integer) 42  your fencing token

Interpretation: the transactions page says no other client's command runs in the middle of an EXEC. If the first reply is nil, you did not get the lock, so ignore the number. The counter grows anyway, which does no harm. In cluster mode, the shared {invoice-run} hash tag keeps both keys in one slot, which a transaction requires.

sql
-- The protected resource checks the fence on every write
UPDATE invoice_runs
SET    status = 'posted', last_fence = 42
WHERE  run_id = 'INV-2026-11' AND last_fence < 42;
-- 0 rows updated: a newer holder has written; stop and log it

Interpretation: the strict < matters. The counter lives on Valkey too, so a failover can lose increments and issue a number twice; a strict check rejects the duplicate. If the resource is a database, its own sequence or a version column is a stronger source for the number.

Implementing a Valkey lock with a fencing token

Six steps; step 4 is the one that keeps a stale holder out

  1. Make a new token

    Random and unique per acquisition, never a hostname or a thread ID.

    TOKEN=$(openssl rand -hex 20)
  2. Acquire and draw a fence in one transaction

    Both keys share a hash tag. A nil first reply means you do not hold the lock.

    MULTI SET lock:{invoice-run} $TOKEN NX PX 30000 INCR fence:{invoice-run} EXEC
  3. Wait for a replica

    Optional, and it only narrows the failover window. A reply of 0 means only the primary has the lock.

    WAIT 1 100
  4. Write with the fence

    The resource accepts the write only if the fence is higher than the last one it saw.

    UPDATE invoice_runs SET last_fence = 42 WHERE run_id = 'INV-2026-11' AND last_fence < 42;
  5. Extend before the TTL runs out

    Always pass PX. A nil reply means stop work now.

    SET lock:{invoice-run} $TOKEN \ IFEQ $TOKEN PX 30000
  6. Release with DELIFEQ

    A reply of 0 means the lock expired during the work. Record it.

    DELIFEQ lock:{invoice-run} $TOKEN

Main risk: skipping step 4. Without a check at the resource, steps 1 to 3 and 5 to 6 only make a double holder less likely.

When should you not use Redlock-style multi-node locks?

Redlock acquires the same key on a majority of N independent primaries (five in the distlock page's example). It answers the single-point-of-failure problem, not the paused-client problem, and the page itself points readers to Kleppmann's critique and antirez's reply. A page marked under review is a weak basis for a correctness-critical design.

Redlock also assumes clocks drift only slightly, and needs either fsync on every write or crashed nodes kept out of service for longer than the maximum TTL. Your team has to run and monitor all of that on five primaries.

A practical way to decide:

If a double holder meansUse
Duplicate work, such as two workers rebuilding the same cache entryA single-instance lock with replicas, as in this post
Wrong data, such as two invoice runs posting the same batchA single-instance lock plus a fencing check at the resource
Wrong data, and the resource cannot check a fenceA consensus-based lock service such as etcd or ZooKeeper, or the database's own locking

Redlock is missing from the "use" column on purpose: a paused client still needs the fence, and with the fence in place one primary is usually enough.

Which client libraries support SET IFEQ and DELIFEQ?

Support is uneven, mostly on the release side. Every client can send a raw command, so a missing method is never a blocker.

ClientSET with IFEQDELIFEQ or DELEX
Valkey GLIDE (Python, Node.js, Java, Go)OnlyIfEqual condition; added to Node.js, Python and Java in 1.3.0DELIFEQ listed under the pending 2.6 release; latest release 2.5.3 (25 September 2026), so use custom_command
redis-pyset(..., ifeq=...) since 7.1.0, marked experimentaldelex() since 7.1.0, which needs Valkey 9.2 RC; no DELIFEQ method, so use execute_command
Redsync, node-redlockLua scriptLua script
python
from glide import ExpirySet, ExpiryType, OnlyIfEqual

# Extend (Valkey 8.1+): "OK" if we still hold the lock, None if not
ok = await client.set(
    "lock:{invoice-run}", token,
    conditional_set=OnlyIfEqual(token),
    expiry=ExpirySet(ExpiryType.MILLSEC, 30000),
)

# Release (Valkey 9.0+): GLIDE 2.5.x has no DELIFEQ method yet
released = await client.custom_command(["DELIFEQ", "lock:{invoice-run}", token])

Interpretation: treat None from the extend, or 0 from the release, as a lost lock. The pending GLIDE 2.6 release rejects SET with IFEQ when client-side compression is on, so keep compression off for the lock client. Check the GLIDE changelog for when DELIFEQ ships.

Frequently asked questions

Can I implement a distributed lock on Valkey without Lua?

Yes, on Valkey 9.0 and later. Acquire with SET key token NX PX, extend with SET key token IFEQ token PX (available since 8.1), and release with DELIFEQ key token. Each step checks the token on the server atomically. For a fencing token, wrap the acquire and an INCR in MULTI and EXEC, with both keys under one hash tag in cluster mode.

What happens if SET IFEQ runs after the lock has expired?

It returns nil and sets nothing. In the Valkey 9.1.3 source, IFEQ aborts when the key is missing, so a late extend cannot recreate a lost lock. Treat nil as a lost lock and stop work. Also pass PX on every extend: a successful SET discards the old TTL, so an extend without PX makes the lock permanent.

Is DELIFEQ the same as DELEX in Valkey?

For lock release, yes. The pull request that added DELEX says DELEX IFEQ is equivalent to DELIFEQ and that the name was chosen for Redis compatibility. DELIFEQ is GA since 9.0.0. DELEX exists only in the 9.2 release candidate as of November 2026, and DELIFEQ is not marked deprecated there.

Does WAIT make a Valkey lock safe during failover?

No. WAIT reports how many replicas acknowledged your writes, and the Valkey documentation says it does not make Valkey strongly consistent: a write acknowledged by replicas can still be lost in a failover. WAIT and WAITAOF narrow the window. A fencing token checked by the protected resource is what rejects the stale holder.

Should I use Redlock with Valkey?

Usually not. Valkey's distlock page is marked under review, Redlock needs five independent primaries in its example, and it still lacks fencing tokens. If a double holder only causes duplicate work, a single-instance lock is enough. If it corrupts data, add a fencing check at the resource or use a consensus-based lock service.

Key takeaways

  • On Valkey 9.0+, a lock needs no Lua: SET NX PX to acquire, SET IFEQ PX to extend, DELIFEQ to release.
  • Always pass PX when extending, and treat a nil extend or a 0 release as a lost lock.
  • Use DELIFEQ today; DELEX is equivalent but only in the 9.2 release candidate.
  • Failover can still give the lock to two clients. WAIT, WAITAOF and min-replicas-to-write narrow the window but do not close it.
  • Draw a fencing token in the same MULTI as the acquire, and have the resource reject stale numbers.

Review your Valkey locking with JusDB

JusDB's Valkey consulting team can review how your services acquire, extend and release locks, check your client libraries and Valkey versions against the native commands, and plan where fencing checks belong in your data path.

Bring your topology, Valkey versions, lock library and the jobs those locks protect. 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