redis/redis-py · error · LockError

Cannot extend an unlocked lock

Error message

Cannot extend an unlocked lock

What it means

Raised by Lock.extend() when self.local.token is None, meaning the current thread has no recorded ownership token for this lock. The token is set only on a successful acquire() and cleared on release(), so extend() refuses to run before the lock is held or after it has been released. This is a client-side precondition check (LockError) that fires before any Redis round-trip.

Source

Thrown at redis/lock.py:301

                "Cannot release a lock that's no longer owned",
                lock_name=self.name,
            )

    def extend(
        self, additional_time: Number, replace_ttl: bool = False
    ) -> Literal[True]:
        """
        Adds more time to an already acquired lock.

        ``additional_time`` can be specified as an integer or a float, both
        representing the number of seconds to add.

        ``replace_ttl`` if False (the default), add `additional_time` to
        the lock's existing ttl. If True, replace the lock's ttl with
        `additional_time`.
        """
        if self.local.token is None:
            raise LockError("Cannot extend an unlocked lock", lock_name=self.name)
        if self.timeout is None:
            raise LockError("Cannot extend a lock with no timeout", lock_name=self.name)
        return self.do_extend(additional_time, replace_ttl)

    def do_extend(self, additional_time: Number, replace_ttl: bool) -> Literal[True]:
        additional_time = int(additional_time * 1000)
        if not bool(
            self.lua_extend(
                keys=[self.name],
                args=[self.local.token, additional_time, "1" if replace_ttl else "0"],
                client=self.redis,
            )
        ):
            raise LockNotOwnedError(
                "Cannot extend a lock that's no longer owned",
                lock_name=self.name,
            )
        return True

View on GitHub (pinned to 6a6b581b48)

Solutions

  1. Ensure lock.acquire() returns True before calling extend(); for non-blocking acquire, check the boolean return value.
  2. Use the context manager form (with lock:) so acquire/release are balanced automatically.
  3. Guard with lock.owned() before extending, and re-acquire if it returns False.
  4. If extending from a different thread than the one that acquired, construct the Lock with thread_local=False.

Example fix

# before
lock = client.lock('mylock', timeout=10)
lock.extend(5)

# after
lock = client.lock('mylock', timeout=10)
if lock.acquire():
    try:
        lock.extend(5)
    finally:
        lock.release()
Defensive patterns

Strategy: validation

Validate before calling

# Validate ownership before extending
if lock.local.token is None or not lock.owned():
    raise RuntimeError('cannot extend: lock not held by this thread')
lock.extend(additional_time)

Type guard

from redis.lock import Lock

def is_held(lock: Lock) -> bool:
    """True only when the current thread holds the lock server-side."""
    return lock.local.token is not None and lock.owned()

Try / catch

from redis.exceptions import LockError

try:
    lock.extend(additional_time)
except LockError as e:
    if 'unlocked' in str(e):
        # not held locally — re-acquire before retrying
        if lock.acquire(blocking=True, blocking_timeout=5):
            lock.extend(additional_time)
    else:
        raise

Prevention

When it happens

Trigger: Calling lock.extend(additional_time) before lock.acquire() has returned True; calling extend() after lock.release() (which sets self.local.token = None at lock.py:275); calling extend() from a worker thread different from the acquiring thread when thread_local=True (default), because the token lives in thread-local storage and is invisible to other threads.

Common situations: Forgetting to acquire before extending; an earlier acquire() that returned False (non-blocking) being silently ignored; extending inside an except/finally block that already released the lock; passing a Lock instance across threads without thread_local=False.

Related errors


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