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

  1. Retry the entire WATCH-read-modify-EXEC sequence on WatchError until it succeeds or a bound is hit.
  2. Minimize the window between WATCH and EXEC (avoid await points on other I/O in between).
  3. 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

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


AI-assisted analysis of redis/redis-py@da03cdc7e8 (2026-08-04). Data as JSON: /data/errors/ac9e32c6a8a94712.json. Report an issue: GitHub.