What FOR UPDATE SKIP LOCKED does and why naive dequeues fail
FOR UPDATE SKIP LOCKED is a row-level locking clause that tells Postgres to lock the rows a SELECT returns and to leave out any row another transaction has already locked. For a job queue, this lets many workers ask for "the next job" at the same time without claiming the same job or waiting on each other.
The naive dequeue: duplicate claims
A common first version reads the oldest pending job, then marks it running in a second statement. Nothing stops two workers from reading the same row between those steps:
| Time | Worker A | Worker B |
|---|---|---|
| t1 | selects oldest pending → job 42 | |
| t2 | selects oldest pending → job 42 | |
| t3 | updates job 42 to running | |
| t4 | updates job 42 to running |
Both workers run job 42.
Plain FOR UPDATE: correct but serialized
Adding FOR UPDATE to the select makes the second reader wait for the first one's lock:
| Time | Worker A | Worker B |
|---|---|---|
| t1 | locks job 42 | |
| t2 | tries job 42, blocks | |
| t3 | commits | wakes, rechecks job 42 |
| t4 | job 42 no longer matches; may get no row |
Every worker queues behind the same head row. Adding workers adds waiting, not throughput.
SKIP LOCKED: different rows, no waiting
With SKIP LOCKED, worker B ignores job 42 at t2 and locks job 43 straight away. The cost is that each worker sees a deliberately incomplete view of the table, because locked rows are treated as if they weren't there. That suits a queue, where any free job will do. For reports, balances, or uniqueness checks it gives wrong answers.
-- Example table: one row per job.
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
status text NOT NULL DEFAULT 'pending',
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 1. Naive dequeue (racy): two statements, no lock between them.
-- Two workers can both read job 42 before either updates it.
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1;
-- ...later, in a separate statement:
UPDATE jobs SET status = 'running' WHERE id = 42;
-- 2. FOR UPDATE: no duplicate claims, but a second worker
-- blocks on the same head row until the first commits.
BEGIN;
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE;
-- ...process, mark running/done, then COMMIT.
COMMIT;
-- 3. FOR UPDATE SKIP LOCKED: each worker claims a different
-- row without waiting. Locked rows are simply left out.
UPDATE jobs SET status = 'running'
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, payload; -- no row returned means no free job right now
The three dequeue patterns side by side: a racy select-then-update, a correct but serializing FOR UPDATE, and a single-statement FOR UPDATE SKIP LOCKED claim that lets concurrent workers take different jobs.
Schema and the canonical dequeue query
Shape the table and index around the dequeue
A minimal jobs table needs id (a bigint identity), payload jsonb, status text (pending, running, done, failed), priority int, run_at timestamptz defaulting to now(), attempts int defaulting to 0, locked_at timestamptz, locked_by text, and last_error text. Keeping run_at separate from creation time lets you delay a job or back off a retry with a single column update.
Index only what workers search: a partial index on (priority, run_at) WHERE status = 'pending'. Completed rows are never in it, so the index stays roughly the size of the backlog, and dequeues stay index scans even as finished rows pile up. The planner uses a partial index only when the query's own predicate implies the index predicate, so the dequeue must filter on status = 'pending' literally.
The canonical dequeue is a single statement: UPDATE jobs SET status = 'running', locked_at = now(), locked_by = $1, attempts = attempts + 1 WHERE id = (SELECT id FROM jobs WHERE status = 'pending' AND run_at <= now() ORDER BY priority, run_at LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING *. Order by both indexed columns so the index also supplies the sort.
The lock has to live in the subquery because that is where the row is chosen. If the subquery doesn't lock, two workers can read the same id. The second then blocks on the outer UPDATE and either claims a job that is already running or updates nothing. With FOR UPDATE SKIP LOCKED on the inner SELECT, each worker skips rows that another worker has already locked and moves on to the next one.
Enqueue by inserting into jobs in the same transaction as the business change, for example the INSERT that creates an order. They commit or roll back together, so you never get a job for an order that doesn't exist, and you never get an order with no job. Workers can't see the job until that transaction commits.
-- Jobs table: run_at is separate from creation time so delays/backoff are one UPDATE.
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'done', 'failed')),
priority int NOT NULL DEFAULT 0,
run_at timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0,
locked_at timestamptz,
locked_by text,
last_error text
);
-- Only pending rows are indexed, so the index tracks the backlog, not history.
CREATE INDEX jobs_pending_idx ON jobs (priority, run_at) WHERE status = 'pending';
-- Dequeue: the lock is taken in the subquery, where the row is chosen.
-- Rows locked by other workers are skipped instead of waited on.
UPDATE jobs
SET status = 'running', locked_at = now(), locked_by = 'worker-1',
attempts = attempts + 1
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending' AND run_at <= now() -- literal predicate matches the partial index
ORDER BY priority, run_at -- index also supplies the sort
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;
-- Transactional enqueue: the job exists only if the business change commits.
-- Assumes an existing orders table with an identity id column.
BEGIN;
WITH new_order AS (
INSERT INTO orders (customer_id, total_cents) VALUES (42, 1999)
RETURNING id
)
INSERT INTO jobs (payload)
SELECT jsonb_build_object('type', 'send_receipt', 'order_id', id) FROM new_order;
COMMIT;
The jobs table with its partial index on pending rows, the single-statement SKIP LOCKED dequeue, and an enqueue that commits atomically with the order insert.
Choosing a worker model: held lock vs. lease
Once a worker has claimed a row, the next question is how long it keeps that claim and what happens when it dies mid-job.
Model one: hold the lock for the whole job
The worker opens a transaction, runs the FOR UPDATE SKIP LOCKED dequeue, does the work, marks the row done (or deletes it), and commits. If the process crashes, the connection drops, Postgres rolls back the transaction, and the row lock disappears. The next worker picks the job up with no extra code. The cost is a transaction that stays open for as long as the job runs. That transaction holds back the xmin horizon, so vacuum cannot remove newer dead tuples anywhere in the database, not only in the queue table. Each in-flight job also pins a connection.
Model two: commit a lease
The worker claims the job and commits right away: UPDATE jobs SET status = 'running', locked_until = now() + interval '5 minutes' WHERE id IN (…SKIP LOCKED subquery…) RETURNING *. It runs the job outside any transaction, then issues a separate UPDATE to set status = 'done'. Transactions stay short, but a crash leaves the row stuck in running. A periodic reclaim fixes that: UPDATE jobs SET status = 'queued' WHERE status = 'running' AND locked_until < now().
The risk is a slow job that outlives its lease. The reclaim requeues it while the first worker is still running, so a second worker runs it concurrently. To limit this, long jobs should heartbeat by extending locked_until. The final UPDATE should check ownership, for example with a worker_id or attempt counter in its WHERE clause.
| Held lock | Lease | |
|---|---|---|
| Crash recovery | Automatic, on rollback | Reclaim query after lease expires |
| Transaction length | Entire job | Milliseconds |
| Vacuum impact | Blocks cleanup while running | Negligible |
| Duplicate-run risk | None from the queue itself | Yes, if a job outlives its lease |
| Suitable job duration | Seconds | Minutes or longer |
-- Model one: hold the row lock for the whole job (one transaction)
BEGIN;
SELECT id, payload FROM jobs
WHERE status = 'queued'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- ... application runs the job; a crash rolls back and frees the row ...
UPDATE jobs SET status = 'done' WHERE id = $1;
COMMIT;
-- Model two: claim with a lease and commit immediately (autocommit)
UPDATE jobs
SET status = 'running', worker_id = $1, attempts = attempts + 1,
locked_until = now() + interval '5 minutes'
WHERE id = (SELECT id FROM jobs WHERE status = 'queued'
ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id, payload, attempts; -- keep id and attempts as the claim token
-- Heartbeat: extend the lease only if this claim still owns the row
UPDATE jobs SET locked_until = now() + interval '5 minutes'
WHERE id = $1 AND status = 'running' AND worker_id = $2 AND attempts = $3;
-- Finish: zero rows updated means the lease was lost and the job was requeued
UPDATE jobs SET status = 'done', locked_until = NULL
WHERE id = $1 AND status = 'running' AND worker_id = $2 AND attempts = $3;
-- Reclaim (run periodically): requeue rows whose lease has expired
UPDATE jobs SET status = 'queued', worker_id = NULL, locked_until = NULL
WHERE status = 'running' AND locked_until < now();
Sketches of the held-lock worker transaction, the lease-based claim, heartbeat and ownership-checked finish, and the periodic reclaim query. The $1/$2/$3 placeholders are parameters bound by the application driver.
Batching, ordering, retries and idempotency
Batch dequeue and ordering
Claiming one row per round trip wastes most of each query on network and planning overhead. Adding LIMIT n to the claim lets a worker lock several rows in one statement. Keep n modest: a worker that grabs fifty slow jobs holds them while idle workers find nothing to claim.
ORDER BY decides which rows a worker tries first, but it does not give you strict FIFO. Two workers can scan concurrently, and the second skips rows the first has locked, so jobs commit out of order. Treat ordering as a preference. Sorting on priority, run_at with an index matching that order is the practical version. It is cheap to scan and pushes urgent work ahead. If two jobs for the same customer must never run in parallel or out of sequence, you need extra design. Two common options are a per-key advisory lock taken inside the handler, or a partial unique index that allows only one running row per entity key.
Retries and delivery guarantees
On failure, increment attempts, record the error, and set run_at to now plus an exponential backoff with jitter so retries don't cluster. Once attempts reaches a maximum, set status = 'failed' so the row leaves the claimable set and a human can inspect it.
Delivery is at-least-once. A worker can finish the side effect and then crash before it commits. Handlers therefore need idempotency keys checked at the destination, or operations such as upserts that are naturally safe to repeat.
-- Partial index matching the claim's ORDER BY, limited to claimable rows.
CREATE INDEX jobs_claim_idx ON jobs (priority, run_at) WHERE status = 'queued';
-- Single-flight per entity: at most one running job per customer.
-- A claim that would break this raises unique_violation, so catch it and retry.
CREATE UNIQUE INDEX jobs_one_running_per_key ON jobs (entity_key) WHERE status = 'running';
-- Batch claim: lock up to 10 due jobs, lower priority number first.
-- This is a preference, not strict FIFO, because rows locked by others are skipped.
WITH next AS (
SELECT id FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY priority, run_at
LIMIT 10
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running', locked_at = now()
FROM next
WHERE j.id = next.id
RETURNING j.id, j.entity_key, j.payload, j.attempts;
-- On handler failure ($1 = job id, $2 = error text).
-- In SET, "attempts" is the pre-update value in every expression.
UPDATE jobs
SET attempts = attempts + 1,
last_error = $2,
locked_at = NULL,
-- Terminal state once the attempt budget is spent, so the row leaves the claimable set.
status = CASE WHEN attempts + 1 >= max_attempts THEN 'failed' ELSE 'queued' END,
-- Exponential backoff capped at 1 hour, with +/-25% jitter to spread retries.
run_at = CASE WHEN attempts + 1 >= max_attempts THEN run_at
ELSE now() + make_interval(secs => least(3600, power(2, attempts + 1))
* (0.75 + random() * 0.5))
END
WHERE id = $1;
A batch claim with FOR UPDATE SKIP LOCKED ordered by priority and run_at, a partial unique index that allows one running job per entity, and a failure update that applies jittered exponential backoff and moves rows to 'failed' after max_attempts.
Running it in production: wakeups, connections and table health
Wakeups
Polling with a short sleep, say every second, is simple and correct, but it trades latency against idle query load. LISTEN/NOTIFY cuts latency: the enqueueing transaction issues NOTIFY, and Postgres delivers it to listeners only after that transaction commits. Treat the notification as a hint, not a guarantee. A worker that is disconnected, restarting, or busy can miss it, and one notification may wake several workers when only one job is available. Keep a fallback poll so jobs are never stranded.
Connections
Each worker usually holds a connection, often inside an open transaction while it works. Your total worker count across all hosts is therefore capped by max_connections, and each Postgres connection is a backend process with its own memory. A pooler helps, but transaction-level pooling hands the server connection to another client after each transaction. LISTEN and session-level advisory locks are tied to a session, so they can break silently through such a pooler. Use session pooling or a direct connection for listeners.
Table health
Every update or delete leaves a dead tuple. Delete completed jobs or move them to an archive table. Tune autovacuum per table so it runs more often on the queue table. Watch for long-running or idle-in-transaction sessions anywhere in the database, because they hold back cleanup on every table, including this one.
SKIP LOCKED does not remove scan cost. With a large backlog or no partial index on eligible rows, the dequeue still reads past many locked, scheduled-for-later, or dead rows before it finds one it can claim.
When to use a Postgres queue and when to avoid it
Good fits
A Postgres queue works best for teams that already run Postgres, process jobs at moderate rates, and want fewer moving parts. The main benefit is transactional enqueue. If you insert the job row in the same transaction as the business write, both commit or neither does. You don't need an outbox, a dual-write reconciliation job, or a second system to monitor, back up, and secure. Your existing tooling also covers the queue: plain SQL for inspection and the same backups, access controls, and on-call runbooks you already use.
Poor fits
Avoid this design when the requirements describe a log rather than a work queue:
- Several consumers must each see the same message. SKIP LOCKED gives a row to exactly one worker. Fan-out means duplicating rows per consumer or building subscription tracking yourself.
- You need replay. Once a job is deleted or marked done, re-reading history from an arbitrary point is not a native operation.
- Ordering and offsets matter. Per-partition ordering and consumer offsets come with streaming platforms. A locking queue intentionally gives up strict order to get parallelism.
- Write load becomes the limit. Every enqueue, claim, and completion is a write to the primary. Each one generates WAL and I/O, and each worker typically holds a connection. At high rates, that load competes with your application traffic, and adding workers makes it worse.
Write down your fan-out, replay, ordering, and peak-throughput requirements before you choose. If none of them apply, Postgres is usually enough.
Alternatives and impact on cost and engineering time
The comparison below is what you are trading against.
| Option | Transactional enqueue | Delivery semantics | Fan-out and replay | Throughput ceiling | Operational overhead | Additional systems |
|---|---|---|---|---|---|---|
| Hand-rolled SKIP LOCKED queue | Yes, same transaction as app writes | At-least-once | Manual (extra tables or columns) | Primary's write and vacuum capacity | Low infra, high code | 0 |
| Postgres-based job library | Yes | At-least-once | Limited | Same as above | Low | 0 |
| Dedicated message broker | No, needs an outbox | At-least-once, or at-most-once if configured | Native fan-out, limited replay | Higher, scales separately | Cluster to run and upgrade | 1+ |
| Streaming log | No, needs an outbox | At-least-once per consumer group | Native fan-out and replay | Highest, partitioned | Highest | 1+ |
| Managed cloud queue | No, needs an outbox | At-least-once | Varies by service | High, provider-managed | Low, but usage-billed | 1 (external) |
Staying in Postgres saves the most engineering time where it matters most. You add no new infrastructure, so backups, failover, access control and monitoring stay on one path. You also avoid the dual-write problem, where a committed row has no matching message or a sent message has no row. Every other option needs an outbox table and a relay to close that gap, which means building half of a Postgres queue anyway.
The cost moves into code you own. That includes lease expiry, retry backoff, dead-lettering and pruning of finished rows. Each needs tests and on-call knowledge. An existing Postgres job library absorbs most of that work, so prefer one unless you have a specific reason not to.
Keep the cost of migrating later bounded. Put enqueue and dequeue behind a narrow interface, and make your handlers idempotent from the start. If your requirements later outgrow the database, the broker becomes a swap behind that interface instead of a rewrite.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to postgres skip locked queue, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Adding or removing a column goes through one review with the inheriting services listed, instead of ad-hoc messages to each team.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Services that depend on a column get the chance to object before it disappears, avoiding emergency fixes.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.