Advisory locks
Advisory locks are application-defined mutexes keyed by an integer, not tied to any row. They are perfect for leader election and cron dedup — and session-level ones silently leak through a transaction pooler.
A nightly billing cron ran on three app servers for redundancy. One night all three fired at the same minute and every customer was charged three times. The team’s first fix — a SELECT FOR UPDATE on a “lock row” — worked until they moved to PgBouncer in transaction mode, when the lock silently stopped holding across the job’s queries. The real tool was one they’d never used: a Postgres advisory lock, an app-level mutex that isn’t tied to any table at all. But advisory locks have a trap of their own that bit them next.
A mutex that isn’t a row
What do you lock when the thing you’re coordinating isn’t a single row — it’s a whole job, a tenant’s rebuild, or the right to be the only server doing something right now? That’s where advisory locks come in.
Every lock so far protected a row. An advisory lock protects nothing in particular — it’s a named latch the application agrees on, keyed by a 64-bit integer (or two 32-bit ints) that you assign meaning to. Postgres just guarantees at most one holder of a given key at a time, cluster-wide. It’s a distributed mutex you get for free with the database you already have.
-- Try to become the single worker for "nightly-billing" (key 42).
SELECT pg_try_advisory_lock(42);
-- returns true -> you hold it, do the work
-- returns false -> someone else holds it, skip silently
SELECT pg_advisory_unlock(42); -- release when donepg_advisory_lock(key) blocks until it can acquire; pg_try_advisory_lock(key) returns immediately with true/false. The non-blocking try_ form is what you want for “only one of us should do this — the rest just skip.”
Session-scoped vs transaction-scoped
This is the distinction that decides whether your lock works in production.
- Session-level (
pg_advisory_lock,pg_try_advisory_lock) — the lock is held until you explicitly callpg_advisory_unlock, or the session (database connection) ends. It does not release atCOMMIT. - Transaction-level (
pg_advisory_xact_lock,pg_try_advisory_xact_lock) — the lock is automatically released at the end of the transaction (COMMITorROLLBACK). There is no unlock function and no way to leak it.
-- Transaction-scoped: released automatically at COMMIT/ROLLBACK. Pooler-safe.
BEGIN;
SELECT pg_try_advisory_xact_lock(42);
-- ... do the guarded work ...
COMMIT; -- lock gone, guaranteed, even if your code forgot to unlockWhere advisory locks shine
- Leader election / cron dedup — N replicas each
pg_try_advisory_lock(key); the one that getstrueruns the singleton task, the rest skip. No ZooKeeper, no Redis. - App-level mutex over a logical resource — serialize a code path keyed by something that isn’t a single row: “rebuild tenant 99’s search index” →
pg_advisory_xact_lock(99). - Serializing a hot path without locking real data rows, avoiding bloat and FK contention.
Versus row locks: a row lock requires the row to exist and protects exactly that tuple; an advisory lock protects an abstract key that may correspond to no row, a future row, or a whole logical operation. Use row locks for data you’re editing; advisory locks for coordination.
▸Common mistake
The pooler trap, in full. Session-level advisory locks are bound to the database connection, not your application’s logical request. Under a transaction-mode pooler (PgBouncer pool_mode = transaction, RDS Proxy, Supabase pooler), your app borrows a backend connection per transaction and returns it to the pool afterward — so a session lock you took in one transaction may be held on a connection that another request now reuses, or never released because “your” session was handed off. You get phantom contention and locks that outlive their owner. Two rules: (1) prefer pg_advisory_xact_lock so the lock dies with the transaction and can’t be stranded on a pooled connection; (2) if you must use a session lock, don’t run behind a transaction-mode pooler for that code path. Also beware key-space collisions: two unrelated features both hashing a name to the same 64-bit key will block each other for no visible reason — namespace your keys (e.g. use the two-int form with a per-feature classid).
Your service runs behind PgBouncer in transaction mode. Which advisory lock call is safe, and why?
Five app replicas each run the same hourly cleanup job. You want exactly one to execute it per hour, with the others skipping silently. Best primitive?
Fill in the blank: a session-level advisory lock is bound to the database _______, so under a transaction-mode pooler it can be stranded on one a different request later reuses — which is why the transaction-scoped variant is the safer default.
- 01How does an advisory lock differ from a row lock like SELECT FOR UPDATE?
- 02What is the difference between pg_advisory_lock and pg_advisory_xact_lock, and which is pooler-safe?
- 03Name two failure modes specific to advisory locks and how to avoid them.
An advisory lock is a mutex Postgres maintains over an integer key you define, decoupled from any row — the database becomes a distributed lock manager you already operate. The blocking pg_advisory_lock and non-blocking pg_try_advisory_lock cover “wait for it” versus “skip if taken”; for fleet-wide singletons (leader election, cron dedup) the try_ form lets exactly one replica win and the rest bow out. The choice that decides production reliability is scope: session-level locks persist past COMMIT and are bound to the physical connection, so a transaction-mode pooler can strand or leak them; transaction-level pg_advisory_xact_lock releases at transaction end and is pooler-safe, making it the right default. Beyond the pooler trap, namespace your keys to avoid two features colliding on the same integer, and reach for advisory locks only for coordination — row locks remain the tool for guarding data you actually edit. Next, the failure that arises when locks of any kind are taken in conflicting orders: deadlocks. Now when you see a cron job that fired three times, or a session lock that mysteriously held past its transaction, you’ll know to check whether the code is running behind a transaction-mode pooler and switch to pg_advisory_xact_lock.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.