A single writer is easy; multiple writers are where bugs live. Locks and leases are the two classic answers, and telling them apart decides whether your system can recover from a failed holder.
Lock: a right you hold until you let go
A lock grants exclusive write access and stays held until the owner releases it. That is simple and fast inside one process, but it has a hard failure mode in distributed systems: a holder that crashes or stalls never runs the unlock, so every waiter blocks forever. There is no timeout in the lock itself, so ‘release on failure’ must be supplied externally, and without it a single dead node takes the resource offline. A lock answers ‘who may write?’, but it cannot answer ‘how long?’.
Lease: a right with a time limit
A lease grants the same writing right but for a bounded time: it expires unless renewed, so a crashed holder can no longer block everyone forever. The coordinator issues a deadline, the holder renews before it lapses, and after expiry another writer may take over. The trade-off is a narrow race: a slow or network-partitioned holder may believe it still has the lease while a new writer has started, which is exactly why renewals and fencing matter and why lease-based code must check the lease can still act before each write.
Which one fits
For a single process, an in-memory lock is simpler and correct; there is no failure of a separate holder. Across processes or machines, a lease is the right shape because it bounds your worst-case wait. Deciding between them is really deciding how you handle a dead holder: with a lock you accept waiting forever unless you add a timeout; with a lease the deadline is built in. If you need to break the tie or recover automatically, choose the lease and design renewals and fencing as first-class parts of the protocol.
Renewals decide the real guarantee
A lease is a loop, not a timestamp you fetch once. The time-to-live bounds recovery: ten seconds caps a crash outage at about ten seconds, while ten minutes turns each crash into a ten-minute wait. Renew well before expiry, commonly at one-third of the TTL, so one dropped heartbeat still leaves time to retry. A failed renew means stop writing: pause the task, cancel the in-flight write, and re-acquire first. Each write re-checks ownership, deadline, and fencing token, since the lease may have moved on. Fencing explains how storage rejects a stale writer after handover; lease semantics decide when handover may happen.
When it breaks
An expired task that keeps running is the classic bug. A pause or a job that outlives its TTL lets the coordinator grant the lease to writer B while A still writes. The last writer silently wins, surfacing later as intermittent overwrites.
Skew causes the same double holder with no slowness. When A’s clock runs ahead, A treats its lease as valid after the coordinator expired it for B. NTP steps, VM migrations, and frozen containers are the usual suspects; bound clock uncertainty instead of trusting one clock.
The quietest failure is a renew loop that dies silently. Heartbeats fail while the main thread keeps processing, so logs look healthy while every write lands in someone else’s term. Treat any failed renew as fatal: halt and re-acquire.
Forgetting to release stretches handover into a full TTL wait; releasing twice can free a lease the holder lost. A TTL far shorter than renewal latency flaps ownership before work completes.
Check it quickly
Save this as lease.py and run python3 lease.py. It uses only the standard library with a manual integer clock, so timing cannot flake. One LeaseStore covers acquire, renew, and release, while write enforces expiry plus the fencing token:
class LeaseLostError(RuntimeError):
pass
class LeaseStore:
"""A tiny lease coordinator with a manual clock (integer seconds)."""
def __init__(self):
self.holder = None
self.expiry = 0
self.fence = 0
self.data = None
def acquire(self, owner, ttl, now):
if self.holder is None or now >= self.expiry:
self.holder = owner
self.expiry = now + ttl
self.fence += 1
return self.fence
return None
def renew(self, owner, ttl, now):
if self.holder == owner and now < self.expiry:
self.expiry = now + ttl
return True
return False
def release(self, owner):
if self.holder == owner:
self.holder = None
return True
return False
def write(self, owner, token, value, now):
if self.holder != owner or now >= self.expiry:
raise LeaseLostError(f"lease expired for {owner} at t={now}")
if token != self.fence:
raise LeaseLostError(f"stale token {token} != {self.fence}")
self.data = value
return True
store = LeaseStore()
token_a = store.acquire("A", ttl=5, now=0)
assert token_a == 1, token_a
assert store.write("A", token_a, "a-1", now=2) is True
assert store.renew("A", ttl=5, now=4) is True # expiry moves 5 -> 9
assert store.write("A", token_a, "a-2", now=8) is True
try:
store.write("A", token_a, "a-stale", now=10)
except LeaseLostError as error:
print(f"rejected expired write: {error}")
else:
raise AssertionError("expired lease write was accepted")
assert store.renew("A", ttl=5, now=10) is False # renew after expiry fails
token_b = store.acquire("B", ttl=5, now=10)
assert token_b == 2, token_b
assert store.write("B", token_b, "b-1", now=11) is True
try:
store.write("B", token_a, "b-wrong-token", now=12)
except LeaseLostError as error:
print(f"rejected wrong fencing token: {error}")
else:
raise AssertionError("wrong-token write was accepted")
try:
store.write("A", token_a, "a-older-term", now=12)
except LeaseLostError as error:
print(f"rejected stale holder: {error}")
else:
raise AssertionError("stale holder write was accepted")
assert store.acquire("C", ttl=5, now=12) is None # B still holds it
assert store.release("A") is False # only the holder can release
assert store.release("B") is True
token_c = store.acquire("C", ttl=5, now=13)
assert token_c == 3, token_c
assert store.write("C", token_c, "c-1", now=14) is True
assert store.data == "c-1"
print("PASS: renew extended the lease; expired, wrong-token, and stale writes rejected")
Token 1 writes, the renew moves the deadline from 5 to 9, and the write at time 10 is rejected as expired. Holder B takes token 2; a wrong token and A’s late write fail, and token 3 takes over after release. Expect this output:
rejected expired write: lease expired for A at t=10
rejected wrong fencing token: stale token 1 != 2
rejected stale holder: lease expired for A at t=12
PASS: renew extended the lease; expired, wrong-token, and stale writes rejected
The assertions passed with Python 3.12 on macOS. The fake clock proves the state machine, not NTP behavior or production load.
Further reading
Sources
- Distributed Systems: leases and fencing (Martin Kleppmann), why locks leak and leases help, checked 2026-10-02.
- PostgreSQL: advisory locks, application-level locks, checked 2026-10-02.
- Redis: SET and locks, building a lock with a time-bound key, checked 2026-10-02.