Skip to content

Note

Postgres SKIP LOCKED for Work Queues

SELECT ... FOR UPDATE SKIP LOCKED can support a modest Postgres work queue: workers claim rows without blocking, while leases, fencing, retries, and indexes provide the recovery behavior.

Published 26 Mar 2026Updated 2 Oct 20262 min read
Databases · Postgres · Backend · Queues
Two Postgres workers claiming different due rows while an attempt number rejects a stale completion after lease expiry.
On this page (4)

A work queue stores tasks until background processes, called workers, are ready to run them. In a Postgres-backed queue, each task is a row. Two workers must not claim the same row at the same time, and a task must become available again if its worker crashes.

SELECT ... FOR UPDATE locks a chosen row for the current transaction. SKIP LOCKED tells another worker to move past that row instead of waiting for it. Together they solve the short claim transaction. They do not keep a job owned after the transaction commits, so the row also needs a lease and a fencing token.

one queue row needs four protections
mechanism
Row lock
job it performs
Stops two transactions from changing the same candidate together
what it does not solve
Ends when the claim transaction ends
mechanism
SKIP LOCKED
job it performs
Lets another worker claim a different ready row instead of waiting
what it does not solve
Does not recover a job after a worker crash
mechanism
Lease
job it performs
Makes abandoned work eligible again after a deadline
what it does not solve
Cannot stop an old worker that wakes up late
mechanism
Attempt number
job it performs
Rejects a late write from an older owner
what it does not solve
Cannot undo an email or payment already sent outside Postgres
Sketch: claim one due or abandoned job
WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE (status = 'pending' AND run_at <= now())
     OR (status = 'running' AND lease_until < now())
  ORDER BY run_at, id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE jobs AS j
SET status = 'running',
    claimed_by = $1,
    claimed_at = now(),
    lease_until = now() + $2::interval,
    attempt = j.attempt + 1
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;

The common table expression selects and locks one candidate. Other claimers skip that row. The UPDATE records the owner, expiry, and a new attempt number before the same statement commits. The row lock then disappears; lease_until is the durable evidence that the worker may still be alive.

Two partial indexes keep the claim from scanning completed history:

Sketch: index ready and expired work
CREATE INDEX jobs_pending_due_idx ON jobs (run_at, id)
WHERE status = 'pending';
 
CREATE INDEX jobs_running_lease_idx ON jobs (lease_until, id)
WHERE status = 'running';

The crash that the row lock cannot solve

Worker A claims job 42 with attempt 7. The transaction commits, so Worker A no longer holds a row lock. It then pauses long enough for the lease to expire. Worker B reclaims the same row as attempt 8 and finishes first. If Worker A wakes up and writes without checking its attempt, its stale result can replace Worker B's result.

A lease expires while the first worker is paused

Worker A
Postgres
Worker B

claim job 42

attempt 7 · lease until 10:05

worker pauses

no renewal before 10:05

reclaim expired job 42

attempt 8

complete with attempt 8

accepted

complete with attempt 7

rejected as stale

The attempt number is the fence. Attempt 8 may commit; the older attempt 7 must be rejected even if Worker A is running again.

Completion therefore includes the returned attempt number:

Sketch: settle only the current attempt
UPDATE jobs
SET status = 'done',
    result = $3,
    completed_at = now(),
    lease_until = NULL
WHERE id = $1
  AND status = 'running'
  AND attempt = $2
RETURNING id;

No returned row means the worker lost ownership. It must discard its local result. Long jobs renew the lease with the same WHERE id = $1 AND attempt = $2 fence; renewal failure tells the worker to stop committing progress.

What the pattern guarantees

The claim gives workers different rows without a central dispatcher. The lease makes a crashed worker's row eligible again. The attempt number fences stale database writes. RETURNING gives the worker the row and its fence in one statement.

The fence protects Postgres state only. If Worker A called an email or payment API before it lost the lease, Worker B cannot undo that call. Outside effects still need a stable idempotency key, provider lookup, or an explicit outcome_unknown state before retry.

Caveats

  • There is no built-in retry policy or dead-letter state. Classify errors, bound attempts, and keep the final reason on the row.
  • SKIP LOCKED is a throughput tool, not a fairness guarantee. A row that is repeatedly locked can be skipped for many claim cycles; use a stable order, monitor oldest-ready age, and add explicit tenant fairness when needed.
  • Status, heartbeat, and retry updates create dead tuples. Watch vacuum and index growth on a hot queue table.
  • Polling adds database load even when no work is ready. Back off empty polls or pair the table with a notification that is only a wake-up hint.
  • One hot table has a ceiling. Measure claim latency and database headroom for the real workload, then move delivery to a broker when the database is doing more queue work than business work.

Use this pattern for a modest queue when Postgres is already the durable source of truth and the team can own leases, retries, fairness, and cleanup. A broker is a better fit for high fan-out, large backlogs, or independent replay needs. Neither choice removes the outside-effect boundary.

Sources