redis/redis-py · warning · LockError

Cannot release a lock that's not owned or is already unlocke

Error message

Cannot release a lock that's not owned or is already unlocked.

What it means

Raised as LockError by Lock.release() when self.local.token is None, meaning this lock instance has no recorded ownership token. Either it was never acquired, was already released, or (with thread_local=True) the releasing thread differs from the acquiring thread. The library refuses to send a release script with no token.

Source

Thrown at redis/asyncio/lock.py:275

        if stored_token and not isinstance(stored_token, bytes):
            try:
                encoder = self.redis.connection_pool.get_encoder()
            except AttributeError:
                # Cluster
                encoder = self.redis.get_encoder()
            stored_token = encoder.encode(stored_token)
        return self.local.token is not None and stored_token == self.local.token

    async def release(self) -> None:
        """Releases the already acquired lock.

        The token is only cleared after the Redis release operation completes
        successfully. This ensures that if the release is cancelled mid-operation,
        the lock state remains consistent and can be retried.
        """
        expected_token = self.local.token
        if expected_token is None:
            raise LockError(
                "Cannot release a lock that's not owned or is already unlocked.",
                lock_name=self.name,
            )
        try:
            await self.do_release(expected_token)
        except LockNotOwnedError:
            # Lock doesn't exist in Redis, safe to clear token
            self.local.token = None
            raise
        # Only clear token after successful release
        self.local.token = None

    async def do_release(self, expected_token: bytes) -> None:
        if not bool(
            await self.lua_release(
                keys=[self.name], args=[expected_token], client=self.redis
            )
        ):

View on GitHub (pinned to da03cdc7e8)

Solutions

  1. Ensure release() is called exactly once after a successful acquire(), ideally in a finally tied to the same acquire.
  2. If releasing from a different task, construct the Lock with thread_local=False so the token is shared.
  3. Track ownership yourself and skip release when not owned (or use owned() to check).

Example fix

// before
lock = Lock(redis, 'k', thread_local=True)
await lock.acquire()  # in task A
await loop.run_in_executor(None, lambda: asyncio.run(lock.release()))  # task B -> error
// after
lock = Lock(redis, 'k', thread_local=False)
await lock.acquire()  # in task A
await lock.release()  # works from task B
Defensive patterns

Strategy: validation

Validate before calling

async def release_if_owned(lock):
    if lock.local.token is not None:
        await lock.release()
    # else: nothing to release

Type guard

def lock_holds_token(lock) -> bool:
    return getattr(getattr(lock, 'local', None), 'token', None) is not None

Try / catch

from redis.exceptions import LockError
try:
    await lock.release()
except LockError as e:
    if 'not owned' in str(e):
        # already released / never acquired; safe to ignore
        ...

Prevention

When it happens

Trigger: Calling lock.release() before lock.acquire() succeeded; calling release() twice; releasing from a different task/thread than the one that acquired when thread_local=True (default). Fires at lock.py:274-275.

Common situations: Double-release in a finally block; releasing in a different asyncio task that did not acquire (thread-local token not visible); a cancelled acquire that still entered a finally; mismatched acquire/release pairing across tasks.

Related errors


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