A Reliable Job Queue in PostgreSQL with SKIP LOCKED

Photo by Tbatb via Wikimedia Commons (CC BY-SA 4.0)
It acquires a row-level lock while scanning, but instead of waiting on rows another transaction has already locked, it silently skips them and moves to the next candidate. This lets many workers query the same table at once and each grab a different unclaimed row. It has been available in PostgreSQL since version 9.5.
You claim a job with a single UPDATE whose WHERE clause targets the id returned by a subquery using FOR UPDATE SKIP LOCKED. The lock, the status change, and the payload return all happen atomically in one statement, so there is no window where a second worker can read the same pending row. The row is marked processing before any other worker can see it.
Usually one of two causes: a missing or wrong index, or table bloat. Without a partial index on pending rows, the scan wades through completed rows and trends toward a sequential scan. A queue also churns constantly, so tune autovacuum aggressively and archive finished jobs on a schedule to stop dead tuples from bloating the table and its indexes.
Reach for RabbitMQ, SQS, or Kafka when you need pub/sub fan-out to many independent consumers, multi-day message replay, dead-letter queues out of the box, or sustained throughput well past ten thousand jobs per second. For pull-based work under that ceiling that already touches your database, Postgres with SKIP LOCKED is simpler and keeps everything transactional.
You can absolutely roll your own; the core is just the UPDATE-with-locking-subquery pattern plus an index and a reaper. Libraries like pg-boss and River add scheduling, retries, dead-letter handling, and archiving for you, built on the same SKIP LOCKED mechanism. Roll your own to learn or for a tiny queue; use a library once you need retries, cron jobs, and monitoring.

Photo by Tbatb via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
A PostgreSQL job queue built on SELECT FOR UPDATE SKIP LOCKED lets many workers pull jobs concurrently without ever grabbing the same row twice. Claim each job in a single UPDATE with a locking subquery, add a partial index on pending rows, keep transactions short, and reach for a real broker only past roughly ten thousand jobs per second.
Every backend I run eventually grows a queue: send this email, resize that image, sync this record to an external ERP. The reflex is to reach for Redis, RabbitMQ, or SQS. But if PostgreSQL is already the source of truth, adding a broker means one more service to deploy, monitor, and keep consistent with your database. For a huge range of workloads you do not need it. A single SQL clause, SKIP LOCKED, turns an ordinary table into a queue that safe, parallel workers can drain without stepping on each other.
SKIP LOCKED has been in PostgreSQL since version 9.5, and it is the same mechanism libraries like pg-boss and River build on. Understanding the raw pattern first means you can debug those libraries when they misbehave, and roll your own when a dependency is overkill. Here is how I build it, the mistakes that cost me hours, and the honest line where I stop and pay for a real broker instead.
The obvious approach is to read the oldest pending row, then mark it as processing. The problem is that reading and marking are two separate steps. Two workers polling at the same millisecond both read job 42 before either has marked it, and both go on to process it. You send the same invoice email twice. Plain FOR UPDATE fixes correctness but destroys throughput: the second worker blocks and waits for the first to finish its transaction, so your parallel workers degrade into a single-file line.
-- Naive queue: two workers can claim the same row
BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1; -- Worker A and Worker B both read job 42
UPDATE jobs SET status = 'processing' WHERE id = 42;
COMMIT;
-- Both workers now believe they own job 42 -> double processingSKIP LOCKED does something subtle: when a worker asks to lock a row that another transaction already holds, it does not wait and it does not error. It pretends the row is not there and moves to the next candidate. Combine that with an UPDATE driven by a locking subquery, and each worker atomically finds an unclaimed job, locks it, marks it, and gets its payload back in a single round trip. No window exists for a second worker to see the same row.
-- Atomic claim: lock, mark, and return in one statement
UPDATE jobs
SET status = 'processing',
locked_at = now()
WHERE id = (
SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, payload;Put FOR UPDATE SKIP LOCKED on the inner SELECT, not the outer UPDATE. The subquery is what scans and skips locked rows; the outer UPDATE then acts on the single id it returns. Ordering by created_at inside the subquery gives you FIFO; swap in priority, created_at for a priority queue.
This is the mistake that cost me the most time. SKIP LOCKED still has to scan rows to find an unlocked one, and every locked or already-done row it walks past is wasted work. Without the right index, a busy queue table degrades toward a sequential scan and latency climbs as the table grows. A partial index that covers only pending rows keeps the scan tiny and, as a bonus, shrinks automatically as jobs complete.
-- Partial index: only pending rows are indexed
CREATE INDEX idx_jobs_pending
ON jobs (created_at)
WHERE status = 'pending';A queue is a churn machine, and every completed or deleted job leaves a dead tuple behind. If autovacuum cannot keep up, the table and its indexes bloat and even indexed scans slow down. Tune autovacuum to run aggressively on this table, archive or delete finished jobs on a schedule, and never let a worker hold its transaction open longer than the job actually needs.
There are two shapes for the worker. The simple one keeps a single transaction open for the whole job: the row lock releases automatically if the worker crashes, but a long-running job holds that lock and blocks vacuum the entire time. The scalable one commits the claim immediately, processes outside any transaction, then marks the job done in a second short transaction. That keeps locks brief, but a crashed worker leaves a job stranded in processing forever, so you need a reaper.
// TypeScript worker with node-postgres: claim, run, mark done
async function claimAndRun(pool) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(`
UPDATE jobs
SET status = 'processing', locked_at = now()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, payload
`);
if (rows.length === 0) {
await client.query('COMMIT');
return null; // queue empty this tick
}
const job = rows[0];
await handle(job); // your business logic (must be idempotent)
await client.query(
"UPDATE jobs SET status = 'done' WHERE id = $1",
[job.id]
);
await client.query('COMMIT');
return job.id;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}-- Reaper: requeue jobs stuck in 'processing' past a timeout
UPDATE jobs
SET status = 'pending',
locked_at = NULL,
attempts = attempts + 1
WHERE status = 'processing'
AND locked_at < now() - interval '5 minutes';One more refinement: constant polling wastes queries when the queue is empty. Pair the pattern with LISTEN and NOTIFY so producers wake workers the moment a job arrives, and fall back to a slow poll every few seconds as a safety net. That gives you near-instant pickup without hammering the database with empty SELECTs.
I run this pattern happily in production, but I stay honest about its ceiling. Postgres-as-a-queue is a pull model with one consumer per job. The moment you need fan-out to many independent consumers, multi-day replay, or sustained throughput far past ten thousand jobs per second, a purpose-built broker earns its keep. Here is the trade-off I weigh before adding infrastructure.
| Dimension | Postgres SKIP LOCKED | Dedicated broker |
|---|---|---|
| Throughput ceiling | Comfortable to roughly 10k jobs/sec, strains beyond | 100k+ messages/sec sustained |
| Delivery model | Pull-based, one consumer per job | Pub/sub and fan-out to many consumers |
| Transactional integrity | Job and business data commit atomically together | Needs an outbox pattern to stay consistent |
| Operational cost | Zero new infra if you already run Postgres | Extra service to deploy, monitor, and patch |
| Replay and retention | Only rows you keep; manual archiving | Built-in replay, dead-letter queues, retention windows |
My rule of thumb is simple. If the work already touches Postgres and my throughput is in the hundreds-to-thousands of jobs per second, SKIP LOCKED is the boring, correct choice, and it deletes an entire moving part from my architecture. When the numbers or the delivery semantics outgrow that, I do not fight it; I add Kafka or SQS and move on. Start with the database you already have, and only pay for a broker when the workload actually demands one.