redis/redis-py · warning · WatchError

Watched variable changed.

Error message

Watched variable changed.

What it means

WatchError raised in TransactionStrategy._execute_transaction (redis/asyncio/cluster.py:3455) when EXEC returns nil. A nil EXEC means Redis aborted the transaction because a watched key was modified between WATCH and EXEC by another client. This is the normal optimistic-locking signal - the application must retry the whole WATCH/transaction sequence.

Solutions

  1. Retry the full WATCH/MULTI/EXEC loop on WatchError until it succeeds or a bound is hit
  2. Reduce the window between WATCH and EXEC (do minimal work there)
  3. Consider a distributed lock (redis.lock) instead of WATCH if contention is high

Example fix

// before
await pipe.watch('k1'); pipe.multi(); ...; await pipe.execute()
// after
while True:
    try:
        await pipe.watch('k1'); pipe.multi(); ...; await pipe.execute(); break
    except WatchError:
        await pipe.reset()
Defensive patterns

Strategy: retry

Validate before calling

# intrinsic to WATCH; cannot prevent - must retry on contention

Try / catch

from redis.exceptions import WatchError
for _ in range(MAX_RETRIES):
    try:
        await pipe.watch('k1'); pipe.multi(); ...; await pipe.execute(); break
    except WatchError:
        await pipe.reset()

Prevention

When it happens

Trigger: Another client/connection writes to a watched key after your WATCH but before your EXEC completes; the watched value changed out from under the transaction.

Common situations: Concurrent writers to the same key; CAS (compare-and-set) patterns under contention.

Related errors


AI-assisted analysis of redis/redis-py@6a6b581b48 (2026-08-10). Data as JSON: /api/errors/db1b5174f27d812c. Report an issue: GitHub.

Appendix: source

Thrown at redis/asyncio/cluster.py:3455

                    self._annotate_exception(e, i + 1, command.args)
                    errors.append(e)

        response = None
        # parse the EXEC.
        try:
            response = await redis_node.parse_response(connection, "EXEC")
        except ExecAbortError:
            if errors:
                raise errors[0]
            raise

        self._executing = False

        # EXEC clears any watched keys
        self._watching = False

        if response is None:
            raise WatchError("Watched variable changed.")

        # put any parse errors into the response
        for i, e in errors:
            response.insert(i, e)

        if len(response) != len(self._command_queue):
            raise InvalidPipelineStack(
                "Unexpected response length for cluster pipeline EXEC."
                " Command stack was {} but response had length {}".format(
                    [c.args[0] for c in self._command_queue], len(response)
                )
            )

        # find any errors in the response and raise if necessary
        if raise_on_error or len(errors) > 0:
            await self._raise_first_error(
                response,
                self._command_queue,

View on GitHub (pinned to 6a6b581b48)