Back to the journalNOTES BY FAJAR
Software Engineering6 min read

PostgreSQL Advisory Locks: What They Do and When to Use Them

Use PostgreSQL advisory locks to coordinate application work, with explicit choices about ownership and lock lifetime.

In this article 15 sections

PostgreSQL advisory locks coordinate operations through keys chosen by the application. They do not lock table rows. I encountered them at work with PgBoss and wanted to understand how they differ from FOR UPDATE.

Quick Refresher: Transactions and Their Limits

A short transaction refresher helps frame advisory locks.

A transaction guarantees atomicity: either every statement in the group succeeds, or none of them do.

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

The transaction commits both updates or neither update.

Transactions only coordinate database work. If your transaction calls an external API (sending an email, hitting a payment gateway), that external call is not part of the transaction. If the transaction rolls back after the API call, the email is already sent. The database cannot undo that.

The Classic Race Condition

I saw this race in production. Two workers read the same account balance at the same time:

mermaid
sequenceDiagram
    participant W1 as Worker 1
    participant DB as Database
    participant W2 as Worker 2
    W1->>DB: SELECT balance WHERE id=1 (gets $40)
    W2->>DB: SELECT balance WHERE id=1 (gets $40)
    W1->>DB: UPDATE balance = 40 - 30 = $10
    W2->>DB: UPDATE balance = 40 - 30 = $10
    Note over DB: Balance is $10, but $60 was withdrawn from $40!

Both workers read $40, both approve a $30 debit, both write $10. The account should be at -$20 (or the second transaction should have been rejected), but instead it is at $10. Classic check then act race condition.

The fix: push the invariant check into the SQL itself.

sql
UPDATE accounts
SET balance = balance - 30
WHERE id = 1 AND balance >= 30;

The check and write now form one atomic operation. If the balance is below $30 when the second update runs, it affects zero rows. The application can reject that debit.

Row Level Locks

Sometimes you need more control than atomic updates. That is where explicit row locking comes in.

FOR UPDATE

sql
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

This acquires an exclusive lock on the selected row. Any other transaction trying to SELECT ... FOR UPDATE or UPDATE the same row will block until the first transaction commits or rolls back.

mermaid
sequenceDiagram
    participant T1 as Transaction 1
    participant DB as Database
    participant T2 as Transaction 2
    T1->>DB: SELECT ... FOR UPDATE (row locked)
    T2->>DB: SELECT ... FOR UPDATE (BLOCKED)
    T1->>DB: UPDATE ... SET balance = 10
    T1->>DB: COMMIT
    T2->>DB: Lock acquired, gets fresh data
    T2->>DB: UPDATE ... SET balance = ...
    T2->>DB: COMMIT

Row locks preserve correctness, but they limit throughput. Every concurrent writer has to wait in line.

FOR SHARE

sql
SELECT * FROM accounts WHERE id = 1 FOR SHARE;

Multiple transactions can hold shared locks simultaneously since they are all just reading. But no transaction can write while shared locks are held.

Shared row locks can delay conflicting writes until their transactions end. Keep these transactions short and measure lock waits. See the PostgreSQL lock conflict rules.

SKIP LOCKED

This one is particularly relevant for job queues (and it is how PgBoss works internally):

sql
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

The SKIP LOCKED option skips rows with conflicting row locks. Multiple workers can then claim different jobs from the same table. It does not eliminate table locks or all contention.

Advisory Locks

Advisory locks apply when the thing being coordinated is not a table row. They can represent:

  • A unique external API call that should not be duplicated
  • A processing pipeline for a specific customer
  • A migration or schema change that only one process should run
  • An application process that must coordinate work across instances

The application chooses integer keys for these locks. PostgreSQL does not automatically apply them to data. Coordination works only when all relevant code acquires the same lock.

Session vs Transaction Scope

Session level locks are held until you explicitly release them or the connection closes:

sql
-- Acquire
SELECT pg_advisory_lock(42);

-- Do your work...

-- Release
SELECT pg_advisory_unlock(42);

If the code fails between acquisition and release, the session can retain the lock until its connection closes.

Transaction level locks are automatically released when the transaction commits or rolls back:

sql
BEGIN;
SELECT pg_advisory_xact_lock(42);

-- Do your work...

COMMIT; -- Lock automatically released

Prefer advisory locks with transaction scope when the work fits within a transaction. PostgreSQL releases them when the transaction ends.

Non blocking Try Variants

Sometimes you do not want to wait for a lock. You want to try and move on if it is taken:

sql
-- Returns true if lock acquired, false if not
SELECT pg_try_advisory_xact_lock(42);

Use try variants for:

  • Worker processes that should skip work another worker is already doing
  • Health check endpoints that should not block
  • Graceful degradation when contention is high
typescript
// Non-blocking advisory lock in application code
async function processCustomerExclusive(customerId: number): Promise<boolean> {
  const result = await db.query(
    "SELECT pg_try_advisory_xact_lock($1) as acquired",
    [customerId]
  );

  if (!result.rows[0].acquired) {
    // Another worker is already processing this customer
    return false;
  }

  // We have the lock, safe to process
  await processCustomerData(customerId);
  return true;
}

Key Packing

Advisory locks take either a single bigint or a pair of int values as the key. But often your lock key is a string (like a customer ID or job type). You need to convert it to an integer:

typescript
import { createHash } from "crypto";

function advisoryKey(namespace: string): bigint {
  const hash = createHash("sha256").update(namespace).digest();
  // Read first 8 bytes as a signed 64-bit integer
  return hash.readBigInt64BE(0);
}

// Usage
const lockKey = advisoryKey("process-customer:cust_12345");
await db.query("SELECT pg_advisory_xact_lock($1)", [lockKey]);

Hash collisions are possible but unlikely. A collision means two unrelated operations wait for each other, reducing throughput but not corrupting data.

For composite keys, you can pack two 32-bit IDs into one 64-bit key:

typescript
function packKeys(a: number, b: number): bigint {
  return (BigInt(a) << 32n) | (BigInt(b) & 0xFFFFFFFFn);
}

// Lock on (tenantId=5, resourceId=42)
const lockKey = packKeys(5, 42);

Deadlock Prevention: Total Ordering

If you need to acquire multiple advisory locks, you must always acquire them in the same order. Otherwise you get deadlocks:

mermaid
graph LR
    T1[Transaction 1] -->|holds Lock A| LA[Lock A]
    T1 -->|wants Lock B| LB[Lock B]
    T2[Transaction 2] -->|holds Lock B| LB
    T2 -->|wants Lock A| LA
    style LA fill:#fecaca
    style LB fill:#fecaca

Transaction 1 holds A, wants B. Transaction 2 holds B, wants A. Neither can proceed. Deadlock.

The fix: sort your lock keys and always acquire in ascending order.

typescript
async function acquireLocksInOrder(keys: bigint[]): Promise<void> {
  // Sort to prevent deadlocks
  const sorted = [...keys].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));

  for (const key of sorted) {
    await db.query("SELECT pg_advisory_xact_lock($1)", [key]);
  }
}

The Fetch Lock Refetch Pattern

The fetch lock refetch pattern matters when you need to lock based on query results. The data might change between the initial query and lock acquisition.

Fetch first, lock the keys, refetch the rows, then compare them.

typescript
async function processReadyJobs(): Promise<void> {
  await db.query("BEGIN");

  try {
    // 1. Unsafe fetch: get candidate rows
    const candidates = await db.query(
      "SELECT id FROM jobs WHERE status = 'ready' LIMIT 10"
    );

    if (candidates.rows.length === 0) {
      await db.query("COMMIT");
      return;
    }

    // 2. Acquire locks in sorted order
    const ids = candidates.rows.map((r) => r.id).sort();
    for (const id of ids) {
      await db.query("SELECT pg_advisory_xact_lock($1)", [id]);
    }

    // 3. Refetch with locks held
    const confirmed = await db.query(
      "SELECT id FROM jobs WHERE id = ANY($1) AND status = 'ready'",
      [ids]
    );

    // 4. Compare: only process jobs that are still ready
    for (const job of confirmed.rows) {
      await processJob(job.id);
    }

    await db.query("COMMIT");
  } catch (error) {
    await db.query("ROLLBACK");
    throw error;
  }
}

Between step 1 and step 2, another transaction may process the jobs. Lock acquisition can wait indefinitely unless a timeout limits it. Read the rows again after acquisition to check their current state.

When to Use What

The decision tree is:

mermaid
graph TD
    A[Need to coordinate concurrent access?] --> B{Operating on existing rows?}
    B -->|Yes| C{Need exclusive write access?}
    C -->|Yes| D[FOR UPDATE]
    C -->|No, readers are fine| E[FOR SHARE]
    B -->|No| F{Need to lock a logical concept?}
    F -->|Yes| G[Advisory Lock]
    F -->|No| H[Maybe you don't need locking]
    G --> I{Inside a transaction?}
    I -->|Yes| J[pg_advisory_xact_lock]
    I -->|No| K[pg_advisory_lock + careful cleanup]

Use row locks (FOR UPDATE, FOR SHARE) when you are protecting actual data in a table.

Use advisory locks when you are coordinating around a concept: a job type, a customer pipeline, a migration process, an external API call.

Use SKIP LOCKED when you need job queue semantics: multiple workers pulling from the same pool without blocking each other.

PgBoss schema locks and a maintenance example

Working with PgBoss at Xendit helped me understand how it uses advisory locks internally.

Current PgBoss documentation identifies pg_advisory_xact_lock as coordination for schema creation and migration. See the database backend documentation.

The following snippet illustrates a separate maintenance leader pattern. It is not the documented current PgBoss implementation:

typescript
// Simplified version of what PgBoss does internally
const result = await db.query(
  "SELECT pg_try_advisory_lock($1) as acquired",
  [MAINTENANCE_LOCK_KEY]
);

if (result.rows[0].acquired) {
  // This instance owns maintenance: archive old jobs,
  // expire stalled jobs, schedule cron jobs
  await runMaintenance();
}

In this illustrative pattern, cooperating instances use the same lock key. One instance runs maintenance while it holds the lock. Other instances continue to process jobs.

The example uses a session lock because maintenance runs across multiple transactions. PostgreSQL releases the lock when the session ends. Another instance can then acquire it.

I also investigated delayed PgBoss maintenance in production. In that setup, job processing overloaded the instance responsible for maintenance, which delayed tasks such as job expiration. We isolated maintenance from the worker pool. That experience describes our deployment, not a guarantee about the internals of every PgBoss version.

Closing Thoughts

I wish I had understood advisory locks earlier. At previous jobs, we built coordination with Redis locks or application mutexes while PostgreSQL was already available. Its advisory locks could have been another option.

I now consider advisory locks when cooperating workers need exclusive access to an operation. The lock key can identify a customer, migration, or external API call.

If you are already using PostgreSQL, advisory locks can coordinate job types, customer pipelines, migrations, or external calls. For a comparison of PgBoss and BullMQ, see PgBoss vs BullMQ.

FILED UNDER

THANKS FOR READING

Did this resonate?

A reaction or a conversation is always welcome.

Loading reactions…

Pass it along

Loading comments...

Related posts

All writing