{"id":"b3e9e023b419f988","repo":"redis/redis-py","slug":"cannot-release-a-lock-that-s-not-owned-or-is-alrea-b3e9e0","errorCode":null,"errorMessage":"Cannot release a lock that's not owned or is already unlocked.","messagePattern":"Cannot release a lock that's not owned or is already unlocked\\.","errorType":"exception","errorClass":"LockError","httpStatus":null,"severity":"warning","filePath":"redis/lock.py","lineNumber":271,"sourceCode":"    def owned(self) -> bool:\n        \"\"\"\n        Returns True if this key is locked by this lock, otherwise False.\n        \"\"\"\n        stored_token = self.redis.get(self.name)\n        # need to always compare bytes to bytes\n        # TODO: this can be simplified when the context manager is finished\n        if stored_token and not isinstance(stored_token, bytes):\n            encoder = self.redis.get_encoder()\n            stored_token = encoder.encode(stored_token)\n        return self.local.token is not None and stored_token == self.local.token\n\n    def release(self) -> None:\n        \"\"\"\n        Releases the already acquired lock\n        \"\"\"\n        expected_token = self.local.token\n        if expected_token is None:\n            raise LockError(\n                \"Cannot release a lock that's not owned or is already unlocked.\",\n                lock_name=self.name,\n            )\n        self.local.token = None\n        self.do_release(expected_token)\n\n    def do_release(self, expected_token: str) -> None:\n        if not bool(\n            self.lua_release(keys=[self.name], args=[expected_token], client=self.redis)\n        ):\n            raise LockNotOwnedError(\n                \"Cannot release a lock that's no longer owned\",\n                lock_name=self.name,\n            )\n\n    def extend(\n        self, additional_time: Number, replace_ttl: bool = False\n    ) -> Literal[True]:","sourceCodeStart":253,"sourceCodeEnd":289,"githubUrl":"https://github.com/redis/redis-py/blob/da03cdc7e8731092b13e395605c3c1fb2de25de1/redis/lock.py#L253-L289","documentation":"Raised as redis.exceptions.LockError by Lock.release() (redis/lock.py:271) when self.local.token is None — meaning this Lock instance never acquired the lock (acquire() was never called or returned False), or already released it. The token is the proof of ownership; without it there is nothing to release. Note this is the client-side guard; a separate LockNotOwnedError is raised by do_release if the token no longer matches the server value.","triggerScenarios":"Calling lock.release() twice; calling release() on a Lock that was never acquired; calling release() after the context manager already exited (which releases once); calling release() when acquire() returned False (non-blocking miss).","commonSituations":"Double-release in overlapping finally blocks; releasing a lock from a different thread/process than the one that acquired it (thread_local=True default hides the token); releasing after __exit__ already released; releasing a lock whose `with` block raised LockError on enter (so it was never held).","solutions":["Release exactly once, ideally via the context manager (`with client.lock(...):`) which handles it automatically.","Before a manual release(), check lock.owned() (or that self.local.token is set) to guard double-release.","If crossing threads/processes, construct the Lock with thread_local=False and manage the token explicitly.","Set raise_on_release_error=False on the Lock to log-and-continue instead of raising on the spurious release (the context manager already does this).","Do not call release() inside an outer finally if the `with` block (or an inner finally) already releases."],"exampleFix":"// before\nlock = client.lock('x')\nlock.release()  # raises: never acquired\n\n// after\nlock = client.lock('x')\nif lock.acquire():\n    try:\n        do_work()\n    finally:\n        lock.release()\n\n# or, for the double-release footgun, guard explicitly:\nif lock.local.token is not None:\n    lock.release()","handlingStrategy":"validation","validationCode":"lock = client.lock('x', thread_local=False)\nif lock.local.token is not None:\n    lock.release()\n# or use the context manager which releases exactly once:\n# with client.lock('x') as lock: ...","typeGuard":"def lock_is_held_locally(lock) -> bool:\n    return getattr(getattr(lock, 'local', None), 'token', None) is not None","tryCatchPattern":"from redis.exceptions import LockError\ntry:\n    lock.release()\nexcept LockError:\n    # already released / never acquired — benign in many flows\n    pass","preventionTips":["Prefer the context manager (`with client.lock(...):`) which releases exactly once.","Guard manual release() with a check that the local token is set, or use lock.owned().","For cross-thread/process release, construct the lock with thread_local=False and manage the token yourself.","Avoid nested finally blocks that both release; set raise_on_release_error=False if a spurious release should be non-fatal.","Never call release() after the `with` block has exited (it already released)."],"tags":["lock","distributed-lock","lockerror","release","programming-error"],"analyzedSha":"da03cdc7e8731092b13e395605c3c1fb2de25de1","analyzedAt":"2026-08-04T20:26:47.563Z","schemaVersion":2}