Tutorials¶
This section guides you through the fundamentals of file locking, starting with the basics and building up to advanced patterns. Each lesson is built around a job real programs hand to a file lock: guarding a download cache, memoizing an expensive probe, coordinating readers and writers on a shared filesystem.
Your first lock¶
Start with the job that pulls most projects to a file lock in the first place: two copies of a program racing to populate the same cache entry.
huggingface_hub downloads model weights into a shared cache directory. When two processes want the same file, one should fetch it while the other waits, rather than both streaming gigabytes over the same connection. It puts a lock beside the cache entry, keyed on the file’s ETag:
from filelock import FileLock
lock = FileLock("cache/.locks/models/bert-base/a1b2c3.lock")
The lock object represents a lock file on disk. Nothing is locked yet. Use it with a context manager (the with
statement):
with lock:
# Inside this block, we hold the lock
print("I have the lock!")
# Outside this block, the lock is released
Run this in several terminals at once. Only one process prints at a time; the others wait their turn.
Two details of that path are deliberate, and both come from real deployments:
The lock lives beside the cache entry, not on it. Locking the target file itself would mean creating the very file whose absence you are testing for.
The lock file name is derived from a hash. Cache keys can be long, and a filesystem caps a single name component (255 bytes on ext4 and on Windows). datasets hashes lock names against
os.statvfs(path).f_namemaxfor exactly this reason. Hash long keys instead of pasting them into the name.
Reentrant locks¶
Sometimes you need to acquire the same lock again from code already holding it. The lock allows this:
from filelock import FileLock
lock = FileLock("py_info/5/a1b2c3.json.lock")
def read_cached_info():
with lock:
return load_info()
with lock:
refresh_cache()
info = read_cached_info() # acquires the same lock again
No deadlock occurs. The lock counts acquisitions and releases only when the count reaches zero. This is what lets a helper take the lock defensively without knowing whether its caller already holds it. You can inspect the counter and state at any time:
lock = FileLock("reentrant.lock")
print(lock.is_locked) # False
print(lock.lock_counter) # 0
lock.acquire()
print(lock.is_locked) # True
print(lock.lock_counter) # 1
lock.acquire()
print(lock.lock_counter) # 2
lock.release()
print(lock.lock_counter) # 1, still locked
print(lock.is_locked) # True
lock.release()
print(lock.lock_counter) # 0, fully released
print(lock.is_locked) # False
virtualenv leans on this directly: its util/lock.py wraps FileLock in a counted subclass so nested acquisitions
of the same app-data entry are cheap.
Multiple ways to use a lock¶
So far we’ve used the with statement. There are other ways:
Manual acquire and release:
lock.acquire()
try:
print("I have the lock")
finally:
lock.release()
Always use a try/finally block to guarantee the lock is released, even if an exception occurs.
As a decorator:
@lock
def protected_operation():
print("This function runs with the lock held")
protected_operation() # Lock is acquired, function runs, lock is released
Choose whichever feels most natural for your code. The with statement reads clearest.
Important: Always use the context manager¶
Avoid this pattern:
FileLock("my.lock").acquire() # ⚠️ Don't do this
# The lock might be released during garbage collection
# before your code finishes
This doesn’t work reliably because if you don’t assign the lock to a variable, Python’s garbage collector might release it before you’re done with it.
Instead, always keep a reference to the lock object:
lock = FileLock("my.lock")
with lock: # ✓ Good
# your code here
pass
Thread-local by default¶
By default, each lock uses thread-local state (thread_local=True): each thread tracks its own lock counter. Two
threads holding the same FileLock object each get their own reentrant state.
Async locks default to thread_local=False because they run in a thread pool where the acquiring thread may differ
from the releasing thread.
See Use locks with multiple threads for practical examples of controlling this behavior.
Your first async lock¶
An async program cannot afford a blocking acquire(): while one coroutine waits on the filesystem, the event loop
should keep serving everything else. Use the async variants with async with:
from pathlib import Path
from filelock import AsyncFileLock
lock = AsyncFileLock("cache/.locks/models/bert-base/a1b2c3.lock")
async def fetch_once(url: str) -> bytes:
async with lock:
blob = Path("cache/models/bert-base/a1b2c3.bin")
if not blob.exists():
blob.write_bytes(await download(url))
return blob.read_bytes()
The lock runs its blocking filesystem calls in a thread pool, so the event loop stays free while you wait.
Warning
Plain with does not work on an async lock. acquire and release are coroutines; use async with.
Every lock type in this tutorial has an async peer: AsyncSoftFileLock,
AsyncStrictSoftFileLock,
AsyncSoftFileLease, AsyncReadWriteLock,
and AsyncSoftReadWriteLock. They share the on-disk protocol of their
synchronous counterparts, so a sync and an async process contend correctly with each other. See
Use async locks.
Reading who holds a soft lock¶
A native lock lives in the kernel, so there is nothing on disk to read: the file is a handle, not a record. A soft lock is the opposite. Its marker file is the protocol, and it names its owner.
This is what lets an operator answer “what is holding this thing?” without attaching a debugger. PostgreSQL is the
canonical example: it writes postmaster.pid into the data directory so a second server, and a human, can see who
claims it.
SoftFileLock publishes a PID and hostname:
from filelock import SoftFileLock
lock = SoftFileLock("server.lock")
with lock:
print(lock.pid) # e.g. 12345
print(lock.is_lock_held_by_us) # True
SoftFileLease publishes more. It derives from
MarkerSoftFileLock, which writes a richer protocol 2 record and hands it back
as an OwnerRecord:
from filelock import SoftFileLease
lease = SoftFileLease("server.lock", lease_duration=30)
with lease:
if (owner := lease.owner) is not None: # None if the marker is missing or unreadable
print(owner.pid, owner.hostname)
print(owner.mode) # "lease"
print(owner.token) # names this particular claim
print(owner.lease_duration) # 30.0
print(owner.start) # process start token, or None on platforms without one
StrictSoftFileLock keeps its owner metadata differently, one record per claim
read through lock.claims, so it has no owner property.
That start field is what keeps a recycled PID from reading as a live owner. The operating system reuses PIDs, so
“PID 12345 exists” does not mean “the process that wrote this marker exists”. filelock folds in a per-platform start
token (Linux /proc/<pid>/stat plus the boot id, macOS sysctl, Windows GetProcessTimes) so a contender can
tell the difference.
Note
PostgreSQL writes a start time into postmaster.pid but does not compare it; it defends against PID reuse
structurally instead. It is a precedent for the problem, not for this solution.
See Inspect a protocol 2 owner record for using these records in recovery tooling.
Migrating from lockfile.PIDLockFile¶
If you’re migrating from the deprecated lockfile library,
SoftFileLock is the direct replacement for PIDLockFile. It writes the process ID to
the lock file and can detect stale locks.
# Before (lockfile):
from lockfile.pidlockfile import PIDLockFile
lock = PIDLockFile("/tmp/myapp.lock")
lock.acquire()
print(lock.read_pid())
print(lock.is_lock_held_by_us())
lock.release()
# After (filelock):
from filelock import SoftFileLock
lock = SoftFileLock("/tmp/myapp.lock")
with lock:
print(lock.pid)
print(lock.is_lock_held_by_us)
Key differences from PIDLockFile:
read_pid()is now a property:lock.pidis_lock_held_by_us()is now a property:lock.is_lock_held_by_usbreak_lock()is nowlock.break_lock()(same name)Stale lock detection happens automatically on acquire (all platforms)
Supports context managers, reentrant locking, timeouts, and all other filelock features
Stronger soft-lock contracts¶
A plain SoftFileLock is a cooperative marker: it reclaims a marker only once its owner
is provably gone, but a shared marker cannot promise that two processes never overlap. When you need more, two soft locks
state an explicit contract.
StrictSoftFileLock gives mutual exclusion without a native lock. It never breaks a
claim by age, so a crashed holder’s claim is cleared only by an operator:
from filelock import StrictSoftFileLock
lock = StrictSoftFileLock("work.lock")
with lock:
do_exclusive_work()
# After a crash, an operator inspects and clears the stale claim by name:
stale = StrictSoftFileLock("work.lock")
for claim in stale.claims:
stale.force_break(claim.name)
SoftFileLease trades exclusion for progress. Its claim expires, so a peer can take over
if a holder wedges; a callback fires when this process loses its claim. A lease token names a claim but does not fence
the protected resource, so fence any resource an expired holder could still write:
from filelock import SoftFileLease
lease = SoftFileLease("work.lock", lease_duration=30, on_compromise=lambda c: stop_work())
with lease:
do_resumable_work()
restic and Terraform resolve this choice in opposite directions. Terraform holds its state lock until an operator runs
force-unlock, preferring to hang over risking two concurrent applies, while a lease keeps the work moving and leaves
you to fence the resource. Choose according to which failure your system can absorb.
See Choose a soft-lock contract to pick between them.
Next steps¶
Waiting on a lock a user can see? See Report progress while waiting and Probe a lock before blocking on it.
Need strict exclusion or an expiring lease? See Use fail-closed soft locks and Choose a soft-lock contract.
Want to handle timeouts, cancellation, or force-release? See How-to guides.
Curious about how locks work across different platforms? Read Concepts and design.