Module 11 · Lesson 6 of 15Free
Postgres as a job queue: SKIP LOCKED
- Level
- Intermediate
- Topic
- Queues, background jobs and event streams
- Reading time
- 19 min
When you finish this page you can
- Implement concurrent job claiming with FOR UPDATE SKIP LOCKED.
- Separate short database locks from durable job ownership.
- Recover abandoned jobs without letting stale workers acknowledge newer attempts.
- Explain when a PostgreSQL queue needs operational changes or a separate broker.
Builds on 3 lessons
Start with Module 04, lesson 715 minVideo lesson · coming soon
has 4 receipt workers, but they all select the same waiting job. One customer receives duplicate receipts. Other customers receive nothing yet.
How can PostgreSQL give each worker different work, then recover that work when a worker disappears?
Kartly now runs separate services. This lesson stays inside the orders service and its PostgreSQL database. We will build a small for receipt jobs, not a shared database for every service.
All worker counts, timings and limits below are illustrative choices for this lesson.
Workers need different waiting rows
Start with a table containing unfinished jobs. A worker selects the earliest job, sends its receipt, then marks the job complete.
The first problem appears before any worker crashes. Ordinary reads do not reserve work. Two workers can read the same ready row before either changes it.
Adding FOR UPDATE locks the selected row against competing updates. Adding SKIP LOCKED tells another claimant to skip unavailable rows instead of waiting for them.1
Worker A locks the earliest ready job. Worker B runs the same ordered query with SKIP LOCKED. Must B wait to preserve the ordering?Show answer
Diagram not drawn yet: postgres-queue-skip-locked.svg
A ready list contains jobs J1, J2 and J3. Worker A locks J1. Worker B attempts J1, skips its unavailable row lock, then locks J2. J3 remains ready.
Imagine the jobs contain receipt requests for three different orders. There is no business reason to delay the second receipt behind the first.
This differs from an account balance query. Skipping a locked balance row would hide information the calculation needs. PostgreSQL explicitly describes SKIP LOCKED as unsuitable for general-purpose reads but useful for queue-like tables.1
How should workers divide ready jobs?
Read a ready job, then update it without a lock.
Every worker can read the same row before any update commits. Avoiding duplicates would require another atomic ownership check.
Select with FOR UPDATE and wait.
A claimant can wait behind the earliest locked row even when other jobs are available. This can be acceptable with one worker or an intentional serial workflow.
Select with FOR UPDATE .
Workers skip rows another claimant has locked. Independent receipts can progress together, but strict ordering is lost.
Why this choice
Choose skipping because receipt jobs are independent. A single serialized worker is simpler when every operation must follow the previous one. Partitioning work by account or order becomes preferable when only related jobs require ordering.
This solves competition during claiming. It does not yet explain who owns the job after the claim ends.
Commit ownership before doing work
The simplest locking design keeps a transaction open while sending the receipt. The row stays locked until the worker commits.
That combines two lifetimes: a database operation and a network request. A slow receipt provider leaves the transaction open. Long-lived transactions also create operational problems, including retained locks and table bloat.2
Instead, record a : permission to attempt a job until a stored deadline. Commit that record before contacting the provider.
A protects the short ownership change. The lease records ownership after the lock is gone.
Should the row lock cover the entire receipt delivery?
Hold the transaction open until delivery finishes.
A worker crash rolls back its database changes, but a receipt already accepted by the provider cannot be rolled back. Slow calls also keep transactions open.
Commit a running state and lease, then deliver outside the transaction.
Database transactions stay short. Recovery needs an expiry check, and an expired worker may still be running.
Why this choice
Choose a lease because delivery leaves the database. If all work were a brief SQL change in this same database, one transaction could claim, apply the change and complete the job together. Neither design makes an external provider part of the database transaction.
Our illustrative lease lasts one minute. Each attempt also receives a fresh random ownership token.
The token answers “which attempt owns this row?” The deadline answers “when may recovery take it away?”
Diagram not drawn yet: postgres-queue-lease-path.svg
The orders API writes an order and receipt job into its PostgreSQL database. Receipt workers claim jobs and commit leases before calling a receipt provider. Workers acknowledge completion in PostgreSQL. A recovery worker returns expired attempts to ready or marks them failed.
| Component | Responsibility | Talks to | State it keeps | Failure behavior |
|---|---|---|---|---|
| Orders API | Save order and receipt request together | PostgreSQL | No local job ownership | A failed transaction saves neither change |
| PostgreSQL | Store jobs and arbitrate claims | API, workers, recovery | Job state and lease | Unavailable database stops enqueueing and claiming |
| Receipt workers | Claim, deliver, acknowledge | PostgreSQL, provider | Current attempt token | An abandoned lease becomes recoverable |
| Receipt provider | Accept receipt requests | Workers | Its own delivery records | A leaves acceptance uncertain |
| Recovery worker | Reschedule expired attempts | PostgreSQL | No required local state | Expired jobs wait until recovery resumes |
The provider can outlive a worker’s lease. That fact will determine our delivery guarantee.
Store the queue beside orders
The orders service owns this table. Each job needs only an order reference, not a copy of the whole order.
CREATE TABLE receipt_jobs (
id UUID PRIMARY KEY,
order_id TEXT NOT NULL UNIQUE,
state TEXT NOT NULL DEFAULT 'ready'
CHECK (state IN ('ready', 'running', 'done', 'failed')),
run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
lease_token UUID,
lease_until TIMESTAMPTZ,
last_error TEXT,
CHECK ((state = 'running') = (lease_token IS NOT NULL)),
CHECK ((lease_token IS NULL) = (lease_until IS NULL))
);
CREATE INDEX receipt_jobs_ready
ON receipt_jobs (run_at, id)
WHERE state = 'ready';
CREATE INDEX receipt_jobs_expired
ON receipt_jobs (lease_until, id)
WHERE state = 'running';The ready index serves “find the earliest due jobs”. The expiry index serves “find abandoned attempts”. Both exclude unrelated states; Indexes explains the underlying lookup mechanism.
The unique order_id represents a product rule: one receipt job per order. It prevents duplicate enqueueing, not duplicate delivery. If Kartly later supports deliberate resends, those need a separate operation identity.
Enqueue within the order transaction
After inserting the order, the orders API executes this statement on the same connection, before committing. The application supplies the job .
INSERT INTO receipt_jobs (id, order_id)
VALUES ($1, $2)
ON CONFLICT (order_id) DO NOTHING;The write path is:
- Begin the order transaction.
- Save the order.
- Insert its receipt job.
- Commit both changes.
- Return the order response.
The job becomes durable with the order, using Transactions and ACID guarantees. There is no separate publish step that a process crash can interrupt.
This boundary matters. If jobs live in another database, one local transaction cannot cover both writes. Use a transactional outbox when publishing outside that boundary.
Next, workers need to turn a ready row into a durable claim without exposing a gap between selection and ownership.
Claim and record in one statement
The worker supplies a fresh UUID as $1 for each claim attempt.
WITH candidate AS (
SELECT id
FROM receipt_jobs
WHERE state = 'ready'
AND run_at <= statement_timestamp()
AND attempts < 5
ORDER BY run_at, id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE receipt_jobs AS j
SET state = 'running',
attempts = j.attempts + 1,
lease_token = $1,
lease_until = statement_timestamp() + interval '1 minute'
FROM candidate AS c
WHERE j.id = c.id
RETURNING j.id, j.order_id, j.lease_token, j.attempts;Run this as a short, standalone transaction on the primary database. Under , the statement is that transaction. Otherwise, explicitly commit before starting delivery.
Here is the claim path:
- Find a due ready row.
- Lock it, skipping rows unavailable to this claimant.
- Set its running state, deadline and token.
- Increment the attempt count.
- Return the claimed job and commit.
- Contact the receipt provider outside the transaction.
The locking clause belongs inside candidate. A locking clause outside a referenced WITH query does not automatically lock that query’s rows.1
After commit, another worker can inspect the row, but cannot claim it through this query: its state is now running.
An empty result means “no eligible unlocked row was found”. It does not prove the table contains no waiting jobs. Other transactions may currently hold their locks.
A worker must also distinguish a confirmed commit from a broken connection during commit. With an uncertain result, do not send immediately. Reconnect and check the stored token and unexpired lease, or leave recovery to handle the attempt.
Selection is now safe against simultaneous claimants. Completion still needs protection against a worker that returns late.
A stale worker cannot acknowledge
Suppose A claims a job and then pauses. Its lease expires. Recovery reschedules the job, and B claims it with a new token.
A resumes after B starts. An acknowledgement using only the job ID could mark B’s attempt complete.
The worker therefore acknowledges using both the job ID and its attempt token. It also requires an unexpired lease.
The following implementations belong to the receipt worker. They require Psycopg 3 in Python, or pg and its TypeScript declarations in TypeScript.
import psycopg
def acknowledge(dsn: str, job_id: str, token: str) -> bool:
sql = """
UPDATE receipt_jobs
SET state = 'done', lease_token = NULL, lease_until = NULL
WHERE id = %s
AND state = 'running'
AND lease_token = %s
AND lease_until > statement_timestamp()
RETURNING id
"""
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cursor:
cursor.execute(sql, (job_id, token))
return cursor.fetchone() is not Noneimport type { Pool } from "pg";
export async function acknowledge(
pool: Pool,
jobId: string,
token: string,
): Promise<boolean> {
const result = await pool.query(
`UPDATE receipt_jobs
SET state = 'done', lease_token = NULL, lease_until = NULL
WHERE id = $1
AND state = 'running'
AND lease_token = $2
AND lease_until > statement_timestamp()
RETURNING id`,
[jobId, token],
);
return result.rowCount === 1;
}These examples use one statement per acknowledgement. Psycopg autocommit avoids leaving an implicit transaction open.2 For multi-statement transactions in node-postgres, reserve one client; do not distribute statements through pool.query.3
A false result means this attempt could not complete the row. Log the job ID and token. Do not overwrite ownership or create a replacement job.
This protects queue state, not the provider. A stale worker may already have sent its receipt. Use the same provider-side , such as receipt:<order_id>, across every attempt, if the provider supports that contract.
The attempt token changes on each claim. The delivery key must remain stable. Idempotency: making retries safe covers the receiving side.
If the provider cannot suppress duplicates, Kartly must accept possible duplicate receipts or add provider-specific reconciliation. A row lock cannot remove that uncertainty.
Recover attempts without losing ownership
A recovery worker periodically finds expired running jobs. It uses the same locking pattern, in bounded batches.
WITH expired AS (
SELECT id
FROM receipt_jobs
WHERE state = 'running'
AND lease_until <= statement_timestamp()
ORDER BY lease_until, id
FOR UPDATE SKIP LOCKED
LIMIT 100
)
UPDATE receipt_jobs AS j
SET state = CASE WHEN j.attempts >= 5 THEN 'failed' ELSE 'ready' END,
run_at = statement_timestamp() + interval '30 seconds',
lease_token = NULL,
lease_until = NULL,
last_error = 'lease expired'
FROM expired AS e
WHERE j.id = e.id;Commit each batch promptly. Clearing the token invalidates the old attempt. A later claim assigns a new token.
The fixed retry delay keeps this example small. Production retry timing should spread attempts and respect provider limits.
| Current state | Event | Next state |
|---|---|---|
ready |
Successful claim | running |
running |
Matching, unexpired acknowledgement | done |
running |
Expiry before attempt limit | ready, scheduled later |
running |
Expiry at attempt limit | failed |
A known delivery failure can reschedule immediately through a token-guarded update. Use the same state and token checks as acknowledgement. Choose ready or failed from the attempt count, and clear both lease fields.
For an unknown outcome, such as a provider timeout, keep the stable delivery key. Retrying with a new delivery identity would defeat duplicate protection.
| Failure | What the customer sees | Detection | Recovery |
|---|---|---|---|
| Worker dies before claim commit | Receipt arrives later | No running claim remains | Another worker claims |
| Worker dies after claim commit | Receipt is delayed | Expired lease | Recovery reschedules |
| Provider accepts, worker dies before acknowledgement | Possible repeated delivery attempt | Expiry; provider records if available | Retry with the same delivery key |
| Recovery worker stops | Some receipts remain delayed | Expired-running count rises | Restart recovery |
| Database becomes unavailable | New receipts stop progressing | Query errors and connection failures | Pause claims; reconnect with bounded retries |
This design permits repeated attempts, including overlapping execution after expiry. Its guarantees fit the boundaries discussed in Delivery guarantees, not an unconditional exactly-once promise.
The attempt limit also matters: jobs can reach failed without successful delivery. Kartly needs a visible failed-job review and recovery process.
More workers can burden checkout
For this scenario, suppose the receipt burst reaches 40 jobs per second. A delivery occupies one worker slot for an estimated 200 ms.
Estimate occupied slots
40 jobs/s × 0.2 s/job = 8 occupied slots
Eight slots only match the assumed average workload. They provide no spare capacity.
Choose initial headroom
16 slots ÷ 0.2 s/job = 80 jobs/s
Start with 16 delivery slots, subject to provider limits. This is an idealized ceiling, not a measured result.
Count idle database traffic
16 idle workers ÷ 0.25 s = 64 claim queries/s
Even an empty queue can generate database work. Back off when claims return no rows.
Do not reserve a database connection for each network delivery. Release it after the claim and borrow another for acknowledgement.
Claim only as many jobs as you can start promptly. If a worker reserves a large batch, later jobs may expire before execution begins.
Keep bounded, and spread polling times. Maintain completed-job retention and monitor table cleanup. This table changes state repeatedly; its operational cost is not just stored payload size.
Retain the PostgreSQL queue when local atomic enqueueing and shared operations are valuable. Consider a separate broker when queue work needs an independent failure boundary or harms order .
Moving the queue into another PostgreSQL database also isolates some load, but loses the shared order transaction. That is a trade, not a free upgrade.
Boundaries the small implementation does not hide
- Expiry is not cancellation. The database can reject a late acknowledgement. It cannot stop a paused process from resuming an external call.
- Long jobs need renewal. Extend the deadline only when the token still matches and the lease remains valid. Stop starting new effects after renewal fails.
- Skipping does not eliminate every wait.
SKIP LOCKEDapplies to row locks, not every table-level lock. changes can still block a claimant.1 - Ordering is not fairness. A repeatedly locked row can be skipped repeatedly. Monitor the age of eligible work, not just the total count.
- Database-only effects offer a stronger boundary. Claiming, applying a local effect and completing the job can share one short transaction. External calls cannot join that local guarantee.
Where this shows up
- Design: a notification service: independent deliveries need claims, retry state and duplicate protection.
- Design: an e-commerce checkout: local background work can be recorded with the business transaction.
The next problem is detecting delayed receipts before customers complain; Queue lag turns waiting time into an operational signal.
Common mistakes
- Committing a selection before recording ownership. The lock disappears without a durable claim. Select and update in the same transaction.
- Treating a lease as exclusive execution. Expiry allows recovery even if the original worker is still alive.
- Acknowledging by job ID alone. An old attempt could complete a newer attempt’s row.
- Deleting every trace of completed jobs immediately. Removing the unique order reference also removes this table’s enqueue history.
- Adding workers without checking dependencies. More delivery slots can overload the provider or compete with order queries.
Check your understanding
A claim returns no row, but monitoring shows ready jobs. Is the query necessarily broken?Show answer
A worker receives a claimed job, but its connection fails during commit. Should it send the receipt immediately?Show answer
Worker B has reclaimed a job. Worker A resumes with its old token. What stops A from marking B’s attempt done?Show answer
The provider accepted a receipt, but the acknowledgement was lost. Does SKIP LOCKED prevent a duplicate receipt?Show answer
Queue queries now harm order latency. Should Kartly first increase the number of receipt workers?Show answer
Summary
- PostgreSQL divides work through short locked claims, records ownership as a lease, and recovers abandoned attempts after expiry.
FOR UPDATE SKIP LOCKEDavoids waiting on other claimants; it does not promise strict ordering or exactly-once execution.- Store the job with its order in one local transaction, then commit each claim before external work.
- Guard acknowledgement with an attempt token and deadline. Use a separate, stable delivery key across retries.
- Bound retries, polling and . Monitor expired attempts, failed jobs and the impact on order traffic.