{"id":"db1b5174f27d812c","repo":"redis/redis-py","slug":"watched-variable-changed-db1b51","errorCode":null,"errorMessage":"Watched variable changed.","messagePattern":"Watched variable changed\\.","errorType":"exception","errorClass":"WatchError","httpStatus":null,"severity":"warning","filePath":"redis/asyncio/cluster.py","lineNumber":3423,"sourceCode":"                    self._annotate_exception(e, i + 1, command.args)\n                    errors.append(e)\n\n        response = None\n        # parse the EXEC.\n        try:\n            response = await redis_node.parse_response(connection, \"EXEC\")\n        except ExecAbortError:\n            if errors:\n                raise errors[0]\n            raise\n\n        self._executing = False\n\n        # EXEC clears any watched keys\n        self._watching = False\n\n        if response is None:\n            raise WatchError(\"Watched variable changed.\")\n\n        # put any parse errors into the response\n        for i, e in errors:\n            response.insert(i, e)\n\n        if len(response) != len(self._command_queue):\n            raise InvalidPipelineStack(\n                \"Unexpected response length for cluster pipeline EXEC.\"\n                \" Command stack was {} but response had length {}\".format(\n                    [c.args[0] for c in self._command_queue], len(response)\n                )\n            )\n\n        # find any errors in the response and raise if necessary\n        if raise_on_error or len(errors) > 0:\n            await self._raise_first_error(\n                response,\n                self._command_queue,","sourceCodeStart":3405,"sourceCodeEnd":3441,"githubUrl":"https://github.com/redis/redis-py/blob/da03cdc7e8731092b13e395605c3c1fb2de25de1/redis/asyncio/cluster.py#L3405-L3441","documentation":"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.","triggerScenarios":"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.","commonSituations":"Optimistic-locking patterns (read-modify-write); high-contention keys; long intervals between WATCH and EXEC that raise the chance of a concurrent write.","solutions":["Treat WatchError as a normal retry signal: re-read the value, re-enter WATCH/MULTI/EXEC, and loop until it commits.","Cap the retry count and back off to avoid livelock under heavy contention.","Reduce the WATCH-to-EXEC window and narrow the watched key set to lower collision probability."],"exampleFix":"// before\nawait pipe.watch('counter')\nv = await pipe.get('counter')\nawait pipe.multi(); await pipe.incr('counter'); await pipe.execute()  # may raise [87]\n// after\nwhile True:\n    try:\n        await pipe.watch('counter')\n        v = int(await pipe.get('counter'))\n        await pipe.multi(); await pipe.incr('counter')\n        await pipe.execute(); break\n    except WatchError:\n        continue","handlingStrategy":"retry","validationCode":"# CAS retry budget — cannot prevent the first failure\nmax_cas_retries = 10","typeGuard":null,"tryCatchPattern":"from redis.exceptions import WatchError\nfor _ in range(max_cas_retries):\n    try:\n        await run_optimistic_lock(client)\n        break\n    except WatchError:\n        continue  # watched key changed; retry the whole block","preventionTips":["Treat WatchError as expected and retry the whole WATCH/MULTI/EXEC block.","Narrow the watched key set and shorten the WATCH-to-EXEC window."],"tags":["cluster","pipeline","watch","cas","retry"],"analyzedSha":"da03cdc7e8731092b13e395605c3c1fb2de25de1","analyzedAt":"2026-08-04T20:26:47.563Z","schemaVersion":2}