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.
- 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 passPX: 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.
WAITandWAITAOFnarrow 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.
# 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
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.
# 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.
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.
# 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 DELEXInterpretation: 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".
EVALSHAthen returnsNOSCRIPTuntil 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=noto build without it. On such a build, everyEVAL-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
| Stage | Lua scripts |
|---|---|
| Acquire | SET k token NX PX 30000 |
| Extend | EVAL script: GET, compare, PEXPIRE |
| Release | EVAL script: GET, compare, DEL |
| Needs the Lua engine | Yes; fails on a build made with BUILD_LUA=no |
| After a failover | EVALSHA may return NOSCRIPT until the script is loaded again |
| Lowest Valkey version | Any |
Native, 9.0 and 9.1
| Stage | Native, 9.0 and 9.1 |
|---|---|
| Acquire | SET k token NX PX 30000 |
| Extend | SET k token IFEQ token PX 30000 |
| Release | DELIFEQ k token |
| Needs the Lua engine | No |
| After a failover | Plain commands; nothing to reload |
| Lowest Valkey version | 9.0 on every node that can become primary (8.1 for extend alone) |
9.2 release candidate
| Stage | 9.2 release candidate |
|---|---|
| Acquire | SET k token NX PX 30000 |
| Extend | SET k token IFEQ token PX 30000 |
| Release | DELEX k IFEQ token, or DELIFEQ k token |
| Needs the Lua engine | No |
| After a failover | Plain commands; nothing to reload |
| Lowest Valkey version | 9.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.
# Which version is this node, and which scripting engines are loaded?
valkey-cli INFO server | grep -E '^valkey_version'
valkey-cli INFO scriptingenginesInterpretation: 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
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.
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.
# 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 alwaysInterpretation: 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:
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 tokenInterpretation: 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.
-- 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 itInterpretation: 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
-
Make a new token
Random and unique per acquisition, never a hostname or a thread ID.
TOKEN=$(openssl rand -hex 20) -
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 -
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 -
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; -
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 -
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 means | Use |
|---|---|
| Duplicate work, such as two workers rebuilding the same cache entry | A single-instance lock with replicas, as in this post |
| Wrong data, such as two invoice runs posting the same batch | A single-instance lock plus a fencing check at the resource |
| Wrong data, and the resource cannot check a fence | A 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.
| Client | SET with IFEQ | DELIFEQ or DELEX |
|---|---|---|
| Valkey GLIDE (Python, Node.js, Java, Go) | OnlyIfEqual condition; added to Node.js, Python and Java in 1.3.0 | DELIFEQ listed under the pending 2.6 release; latest release 2.5.3 (25 September 2026), so use custom_command |
| redis-py | set(..., ifeq=...) since 7.1.0, marked experimental | delex() since 7.1.0, which needs Valkey 9.2 RC; no DELIFEQ method, so use execute_command |
| Redsync, node-redlock | Lua script | Lua script |
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 PXto acquire,SET IFEQ PXto extend,DELIFEQto release. - Always pass
PXwhen extending, and treat a nil extend or a 0 release as a lost lock. - Use
DELIFEQtoday;DELEXis equivalent but only in the 9.2 release candidate. - Failover can still give the lock to two clients.
WAIT,WAITAOFandmin-replicas-to-writenarrow the window but do not close it. - Draw a fencing token in the same
MULTIas 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.