Skip to main content

keyedlock

Lianes8 Certified

Lock by key: concurrent work on the same key serializes, different keys run in parallel, with reference-counted cleanup.

Install

pip install keyedlock

Python 3.10 or newer, no dependencies.

Usage

Serialize work per key while letting unrelated keys proceed at the same time. Use it for per-user or per-id critical sections.

from concurrent.futures import ThreadPoolExecutor

from keyedlock import KeyedLock

locks = KeyedLock()
balances = {"alice": 100, "bob": 100}


def charge(user: str) -> None:
    with locks.lock(user):
        balance = balances[user]
        balances[user] = balance - 1


with ThreadPoolExecutor(8) as pool:
    for _ in range(50):
        pool.submit(charge, "alice")
        pool.submit(charge, "bob")

print(balances)  # {'alice': 50, 'bob': 50}
print(locks.active_keys())  # 0

Each key gets its own lock on first use. When the last holder and waiter leave, the lock is dropped from the table, so the table never grows with the number of distinct keys you have touched. active_keys() reports how many keys are held or waited on right now. A wait that ends early (a timeout, KeyboardInterrupt, a cancelled task) gives its place back too, and cleanup also attempts to recover from a key whose hash changed while held. Keys must keep stable hashes and equality while in use. Keys follow dict rules: 1, 1.0 and True are the same key. Keyed tables and groups accept up to 64 unequal keys per hash. All classes reject tuple/frozenset keys deeper than 64 levels or larger than 10,000 visited values with ValueError. See the key limits and error policy.

Timeouts

Every lock takes timeout: None (the default) waits for ever, 0 tries once, and a number of seconds waits at most that long. When it runs out, TimeoutError is raised and nothing is left locked.

from keyedlock import KeyedLock

locks = KeyedLock()
with locks.lock("report", timeout=0):
    print("acquired")  # acquired
print(locks.active_keys())  # 0

Re-entry

By default the per-key lock is not reentrant. A thread that takes a key it already holds gets RuntimeError straight away instead of deadlocking on itself. Pass reentrant=True to allow nesting on the same key within one thread.

from keyedlock import KeyedLock

plain = KeyedLock()
with plain.lock("job-42"):
    try:
        with plain.lock("job-42"):
            pass
    except RuntimeError:
        print("re-entry refused")  # re-entry refused

nested = KeyedLock(reentrant=True)
with nested.lock("job-42"), nested.lock("job-42"):
    print("nested")  # nested

Several keys at once

Nested lock calls can deadlock when another thread takes the same keys in the opposite order. lock_many deduplicates the keys and takes them in sorted order, so every caller agrees on the order. Distinct keys must sort into a strictly increasing order (all str, all int, ...). Incomparable pairs, such as disjoint frozen sets or a NaN with another value, raise TypeError before anything is locked. Pass a collection: a bare str is refused rather than split into characters. timeout covers the whole acquisition, and if any key cannot be taken, the ones already taken are released.

from keyedlock import KeyedLock

locks = KeyedLock()
accounts = {"alice": 100, "bob": 0}


def transfer(source: str, target: str, amount: int) -> None:
    with locks.lock_many([source, target]):
        accounts[source] -= amount
        accounts[target] += amount


transfer("alice", "bob", 30)
transfer("bob", "alice", 10)
print(accounts)  # {'alice': 80, 'bob': 20}

Striped mode

Use StripedLock to cap the number of locks. Unrelated keys can share a stripe and block each other. Use lock_many for groups: nesting individual calls may reenter a stripe and raise RuntimeError. String stripe indices depend on the process hash seed.

from keyedlock import StripedLock

locks = StripedLock(stripes=256)
with locks.lock("user-7"):
    print(locks.stripes)  # 256
with locks.lock_many(["user-7", "user-9"], timeout=1):
    print("both held")  # both held

asyncio

AsyncKeyedLock serves waiters on each key in FIFO order, including timed waiters. Cancellation and timeout release their place. Use an instance from one event loop; it is not thread-safe. Threaded locks make no FIFO promise.

import asyncio

from keyedlock import AsyncKeyedLock

locks = AsyncKeyedLock()
log: list[str] = []


async def handle(user: str, step: str) -> None:
    async with locks.lock(user, timeout=5):
        log.append(f"{user}:{step}:start")
        await asyncio.sleep(0)
        log.append(f"{user}:{step}:end")


async def main() -> None:
    await asyncio.gather(handle("ann", "a"), handle("ann", "b"))
    async with locks.lock_many(["ann", "ben"]):
        print(locks.active_keys())  # 2


asyncio.run(main())
print(log)  # ['ann:a:start', 'ann:a:end', 'ann:b:start', 'ann:b:end']

API

Name What it does
KeyedLock(*, reentrant=False) One lock per key, dropped when idle. Thread-safe.
KeyedLock.lock(key, *, timeout=None) Context manager holding key.
KeyedLock.lock_many(keys, *, timeout=None) Context manager holding several keys, in sorted order.
KeyedLock.active_keys() Keys held or waited on right now.
StripedLock(stripes=64, *, reentrant=False) Fixed pool of locks; stripes is the pool size.
StripedLock.lock(key, *, timeout=None) Context manager holding the stripe of key.
StripedLock.lock_many(keys, *, timeout=None) Holds the stripes of several keys, each once, in order.
AsyncKeyedLock() asyncio version of KeyedLock; lock, lock_many and active_keys as above.

lock and lock_many raise TimeoutError when the timeout runs out, RuntimeError on re-entry into a non-reentrant lock, ValueError for a negative or NaN timeout and TypeError for a timeout that is not a real number. A timeout above threading.TIMEOUT_MAX means no limit.

Why

Python's threading locks and asyncio locks protect a single resource. They are enough when one mutex covers the work. A dictionary of those locks can separate resources, but the application must synchronize that dictionary, remove idle entries and arrange a consistent order for multi-key operations.

keyedlock supplies that bookkeeping, timeouts and an optional fixed stripe pool using only the standard library. It is an in-process utility: it does not coordinate separate processes or machines, provide distributed leases, or make external database updates transactional. Use a database transaction or a process-shared lock for those needs.

The GUIDE documents key requirements, error types, deadlines and upgrade behavior. See the CHANGELOG for releases. There is no command-line interface; locks are held inside the calling Python process.

MIT. See LICENSE.

Metadata

Release files for keyedlock 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for keyedlock 1.0.0
File Size Uploaded Provenance Actions
keyedlock-1.0.0.tar.gz 43.0 kB Without provenance Details for keyedlock-1.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for keyedlock 1.0.0
File Interpreter ABI Platform Actions
keyedlock-1.0.0-py3-none-any.whl Python 3 none any Details for keyedlock-1.0.0-py3-none-any.whl

Total release size: 59.2 kB

Release files / keyedlock-1.0.0.tar.gz

Download URL keyedlock-1.0.0.tar.gz
Size 43.0 kB
Tags Source
SHA-256 checksum
How to use checksums
c415063c8b13aaa014912dda127e7ab4ca1a6a87ffb85cacebe53f14b65fa2e6
BLAKE2b-256 checksum
How to use checksums
0bcf925d3a718174387f33ea55f8d4a38b5dae083a39d90475358f2766e82af9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.2

Release files / keyedlock-1.0.0-py3-none-any.whl

Download URL keyedlock-1.0.0-py3-none-any.whl
Size 16.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
62e85c08c0a246a43b0130e925e907433ec46db373609f409c8614206f97e1ae
BLAKE2b-256 checksum
How to use checksums
d49dc9d729d06da63e7c94c78730e7f51d924388eda05df891644799225a6880
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.2

Security for keyedlock 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page