How to fix EXEC returns null - WATCH key modified, optimistic lock failed in Redis
Redis returns null from EXEC when a watched key was modified between WATCH and EXEC, indicating the optimistic lock failed and the transaction was aborted. This is expected behavior for Redis optimistic locking, ensuring data consistency when multiple clients access the same keys. This guide explains why EXEC returns null, how to implement proper retry logic, and strategies to minimize transaction conflicts.
Quick fix
Fixes EXEC returns null - WATCH key modified, optimistic lock failed
# In one Redis client, watch a key and start a transaction
redis-cli
WATCH mykey
GET mykey
MULTI
SET mykey "new-value"
# Don't run EXEC yet
# In a second Redis client, modify the watched key
redis-cli SET mykey "modified-by-client-2"
# Back in the first client, run EXEC
EXEC
# Returns: (nil) because mykey was modified- Confirm EXEC is returning null due to watched key modification
- Implement proper retry logic with a maximum attempt limit
- Add exponential backoff for high-contention keys
- Consider using Lua scripts for atomic operations instead of WATCH
# In one Redis client, watch a key and start a transactionredis-cliWATCH mykeyGET mykeyMULTISET mykey "new-value"# Don't run EXEC yet# In a second Redis client, modify the watched keyredis-cli SET mykey "modified-by-client-2"# Back in the first client, run EXECEXEC# Returns: (nil) because mykey was modifiedEXEC returns null - WATCH key modified, optimistic lock failed
What this error means
When Redis returns null from the EXEC command, it means at least one key you watched with WATCH was modified after the WATCH call but before EXEC attempted to execute the transaction. This is Redis's optimistic locking mechanism working as designed: WATCH monitors keys for changes, and if any modification occurs—whether from another client, expiration, eviction, or even your own commands outside the transaction—Redis aborts the entire MULTI/EXEC transaction to prevent working with stale data. The null return value is not an error but a signal that you need to retry the operation. Redis uses this check-and-set (CAS) behavior to guarantee that your transaction only executes if all watched keys remain unchanged, making it safe to perform read-modify-write operations in a multi-client environment without explicit locks.
Typical symptoms
- EXEC command returns null instead of an array of results, and no commands in the transaction are executed
- Application logs show repeated null responses from EXEC during high concurrency or when multiple clients modify the same keys
- Transaction retry logic triggers frequently, especially for hot keys that many clients access simultaneously
- Read-modify-write operations fail silently because the transaction aborted due to concurrent modifications
- Client libraries like node-redis, ioredis, or redis-py raise WatchError or similar exceptions when watched keys change
Common causes
- Another client modified one or more watched keys between your WATCH and EXEC calls, causing the optimistic lock to fail
- Your own code modified a watched key outside the MULTI/EXEC block after calling WATCH, which also triggers the abort condition
- The watched key expired or was evicted by Redis's memory management policies (LRU/LFU) before EXEC ran, counting as a modification
- High contention on hot keys means multiple clients are racing to update the same data, increasing the probability of conflicts
- The retry logic is missing or incorrectly implemented, so the application doesn't attempt the transaction again after failure
How to fix "EXEC returns null - WATCH key modified, optimistic lock failed"
Test the basic WATCH/MULTI/EXEC flow to verify the null response and understand when it occurs:
# In one Redis client, watch a key and start a transaction
redis-cli
WATCH mykey
GET mykey
MULTI
SET mykey "new-value"
# Don't run EXEC yet
# In a second Redis client, modify the watched key
redis-cli SET mykey "modified-by-client-2"
# Back in the first client, run EXEC
EXEC
# Returns: (nil) because mykey was modifiedThis demonstrates that EXEC returns null when a watched key changes. Check your application logs to confirm this is what's happening in production.
Wrap your WATCH/MULTI/EXEC transaction in a retry loop that handles the null response:
// Node.js with node-redis or ioredis
async function incrementCounter(client, key, maxRetries = 10) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await client.watch(key);
// Read current value
const current = await client.get(key);
const newValue = (parseInt(current || '0', 10) + 1).toString();
// Execute transaction
const multi = client.multi();
multi.set(key, newValue);
const results = await multi.exec();
// Check for null (transaction aborted)
if (results === null) {
console.log(`Attempt ${attempt + 1} failed, retrying...`);
continue;
}
// Success
console.log(`Transaction succeeded on attempt ${attempt + 1}`);
return newValue;
} catch (err) {
await client.unwatch();
throw err;
}
}
throw new Error(`Transaction failed after ${maxRetries} attempts`);
}Always unwatch keys if an exception occurs to avoid leaving watches active. Most client libraries handle this automatically on connection errors.
If you're seeing many retries due to hot keys, add a small delay between attempts to reduce thundering herd:
async function incrementWithBackoff(client, key, maxRetries = 10) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
await client.watch(key);
const current = await client.get(key);
const newValue = (parseInt(current || '0', 10) + 1).toString();
const multi = client.multi();
multi.set(key, newValue);
const results = await multi.exec();
if (results !== null) {
return newValue;
}
// Exponential backoff: 2^attempt ms, max 100ms
const delay = Math.min(Math.pow(2, attempt), 100);
await new Promise(resolve => setTimeout(resolve, delay));
}
throw new Error('Max retries exceeded');
}This spreads out retry attempts and reduces the chance of multiple clients retrying simultaneously.
For simple read-modify-write operations, Lua scripts execute atomically without needing WATCH/MULTI/EXEC:
-- Increment counter atomically (increment.lua)
local current = redis.call('GET', KEYS[1]) or '0'
local newValue = tostring(tonumber(current) + 1)
redis.call('SET', KEYS[1], newValue)
return newValue// Execute the Lua script
const result = await client.eval(
fs.readFileSync('./increment.lua', 'utf8'),
1,
'mycounter'
);Lua scripts are atomic and never fail due to concurrent modifications, making them ideal for operations that don't need complex conditional logic based on multiple keys. They also reduce round trips between client and server.
If one key is heavily contested, split it into multiple shards to distribute load:
// Shard counter across multiple keys
async function incrementShardedCounter(client, baseKey, numShards = 10) {
const shard = Math.floor(Math.random() * numShards);
const shardKey = `${baseKey}:shard:${shard}`;
// Simple INCR, no WATCH needed for single key operations
return await client.incr(shardKey);
}
// Read total count by summing all shards
async function getTotalCount(client, baseKey, numShards = 10) {
const pipeline = client.pipeline();
for (let i = 0; i < numShards; i++) {
pipeline.get(`${baseKey}:shard:${i}`);
}
const results = await pipeline.exec();
return results.reduce((sum, [err, val]) => sum + parseInt(val || '0', 10), 0);
}This approach works well for counters, rate limiters, and other aggregate metrics where exact real-time consistency isn't critical.
Advanced notes
The WATCH mechanism in Redis tracks keys at the logical level, not the physical level—this means RENAME, DEL, EXPIRE, and even internal evictions all count as modifications that will cause EXEC to return null. If you're using Redis Cluster, ensure all watched keys and transaction keys hash to the same slot (use hash tags like {user123}) or you'll get CROSSSLOT errors.
For debugging excessive transaction failures, monitor key modification patterns with MONITOR (use sparingly in production) or enable keyspace notifications for the specific keys. In high-throughput scenarios with many competing clients, optimistic locking can lead to livelock where transactions continuously fail—in these cases, consider alternative patterns like Redlock for distributed locking, or restructure your data model to avoid cross-key transactions entirely. Redis 7.0+ also supports functions that can replace complex WATCH/MULTI/EXEC patterns with guaranteed atomic execution.
Frequently asked questions
- What does "EXEC returns null - WATCH key modified, optimistic lock failed" mean?
- When Redis returns null from the EXEC command, it means at least one key you watched with WATCH was modified after the WATCH call but before EXEC attempted to execute the transaction. This is Redis's optimistic locking mechanism working as designed: WATCH monitors keys for changes, and if any modification occurs—whether from another client, expiration, eviction, or even your own commands outside the transaction—Redis aborts the entire MULTI/EXEC transaction to prevent working with stale data. The null return value is not an error but a signal that you need to retry the operation.
- How do I fix "EXEC returns null - WATCH key modified, optimistic lock failed"?
- Redis returns null from EXEC when a watched key was modified between WATCH and EXEC, indicating the optimistic lock failed and the transaction was aborted. This is expected behavior for Redis optimistic locking, ensuring data consistency when multiple clients access the same keys. This guide explains why EXEC returns null, how to implement proper retry logic, and strategies to minimize transaction conflicts.
- What causes "EXEC returns null - WATCH key modified, optimistic lock failed"?
- Another client modified one or more watched keys between your WATCH and EXEC calls, causing the optimistic lock to fail Your own code modified a watched key outside the MULTI/EXEC block after calling WATCH, which also triggers the abort condition The watched key expired or was evicted by Redis's memory management policies (LRU/LFU) before EXEC ran, counting as a modification
- Is the "EXEC returns null - WATCH key modified, optimistic lock failed" error serious?
- It is a medium-severity error — more of a blocker than a risk. The steps above resolve it.