redis/redis-py · warning · WatchError
Watched variable changed.
Error message
Watched variable changed.
What it means
Raised by Pipeline._execute_transaction (redis/asyncio/client.py:2073) when the EXEC reply is None. In Redis, EXEC returns nil when a watched key was modified between WATCH and EXEC, aborting the transaction. redis-py surfaces this as WatchError ('Watched variable changed.') — the intended optimistic-concurrency signal.
Source
Thrown at redis/asyncio/client.py:2073
try:
await self.parse_response(connection, "_")
except ResponseError as err:
self.annotate_exception(err, i + 1, command[0])
errors.append((i, err))
# parse the EXEC.
try:
response = await self.parse_response(connection, "_")
except ExecAbortError as err:
if errors:
raise errors[0][1] from err
raise
# EXEC clears any watched keys
self.watching = False
if response is None:
raise WatchError("Watched variable changed.") from None
# put any parse errors into the response
for i, e in errors:
response.insert(i, e)
if len(response) != len(commands):
if self.connection:
await self.connection.disconnect()
raise ResponseError(
"Wrong number of response items from pipeline execution"
) from None
# find any errors in the response and raise if necessary
if raise_on_error:
self.raise_first_error(commands, response)
# We have to run response callbacks manually
data = []View on GitHub (pinned to da03cdc7e8)
Solutions
- Retry the entire WATCH-read-modify-EXEC sequence on WatchError until it succeeds or a bound is hit.
- Minimize the window between WATCH and EXEC (avoid await points on other I/O in between).
- For hot keys, switch to an atomic primitive (INCR, Lua script, or SET NX) to avoid watch contention.
Example fix
// before
pipe = r.pipeline(transaction=True)
await pipe.watch('k')
val = int(await pipe.get('k'))
await pipe.multi()
await pipe.set('k', val + 1)
await pipe.execute() # may raise WatchError
// after
for _ in range(MAX_RETRIES):
try:
pipe = r.pipeline(transaction=True)
await pipe.watch('k')
val = int(await pipe.get('k'))
await pipe.multi()
await pipe.set('k', val + 1)
await pipe.execute()
break
except redis.exceptions.WatchError:
continue
# or, contention-free: await r.incr('k') Defensive patterns
Strategy: retry
Try / catch
from redis.exceptions import WatchError
for _ in range(MAX_RETRIES):
try:
pipe = r.pipeline(transaction=True)
await pipe.watch('k')
val = int(await pipe.get('k'))
await pipe.multi()
await pipe.set('k', val + 1)
await pipe.execute()
break
except WatchError:
continue Prevention
- Always retry the full WATCH-read-modify-EXEC loop on WatchError.
- For contended counters, prefer INCR or a Lua script.
- Minimize awaits between WATCH and EXEC.
When it happens
Trigger: Standard OPTIMISTIC LOCKING contention: WATCH a key, read it, build a transaction, and another client modifies the key before EXEC runs. The server returns nil for EXEC and no queued commands execute.
Common situations: Concurrent writers racing on a shared key (counters, locks, read-modify-write); high contention where the watch window overlaps another client's commit.
Related errors
- A {type(error).__name__} occurred while watching one or more
- Cannot issue a WATCH after a MULTI
- Cannot issue nested calls to MULTI
- Commands without an initial WATCH have already been issued
- Wrong number of response items from pipeline execution
AI-assisted analysis of redis/redis-py@da03cdc7e8 (2026-08-04).
Data as JSON: /data/errors/ac9e32c6a8a94712.json.
Report an issue: GitHub.