redis/redis-py · warning · WatchError

Watched variable changed.

Error message

Watched variable changed.

What it means

Raised as WatchError after EXEC when the server returns nil for EXEC. A nil EXEC is Redis's signal that a watched key was modified between WATCH and EXEC, so the transaction was aborted and no commands ran. This is expected CAS-failure semantics, not a bug.

Source

Thrown at redis/asyncio/cluster.py:3423

                    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 da03cdc7e8)

Solutions

  1. Treat WatchError as a normal retry signal: re-read the value, re-enter WATCH/MULTI/EXEC, and loop until it commits.
  2. Cap the retry count and back off to avoid livelock under heavy contention.
  3. Reduce the WATCH-to-EXEC window and narrow the watched key set to lower collision probability.

Example fix

// before
await pipe.watch('counter')
v = await pipe.get('counter')
await pipe.multi(); await pipe.incr('counter'); await pipe.execute()  # may raise [87]
// after
while True:
    try:
        await pipe.watch('counter')
        v = int(await pipe.get('counter'))
        await pipe.multi(); await pipe.incr('counter')
        await pipe.execute(); break
    except WatchError:
        continue
Defensive patterns

Strategy: retry

Validate before calling

# CAS retry budget — cannot prevent the first failure
max_cas_retries = 10

Try / catch

from redis.exceptions import WatchError
for _ in range(max_cas_retries):
    try:
        await run_optimistic_lock(client)
        break
    except WatchError:
        continue  # watched key changed; retry the whole block

Prevention

When it happens

Trigger: Any cluster (or standalone) pipeline WATCH/MULTI/EXEC where another client (or another part of the same app) writes to a watched key before EXEC is processed.

Common situations: Optimistic-locking patterns (read-modify-write); high-contention keys; long intervals between WATCH and EXEC that raise the chance of a concurrent write.

Related errors


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