Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Yes: PostgreSQL can serve as a durable job queue when jobs are stored in a table and workers claim them with one atomic statement. For priority scheduling that favors urgent work, then older eligible work, use ORDER BY priority DESC, available_at ASC, id ASC. The final unique tie-breaker makes the preference deterministic; FOR UPDATE SKIP LOCKED lets workers claim different rows without waiting on one another.

What the three ordering terms mean

Put the policy directly in the claim query’s ORDER BY. Each term answers a different scheduling question:

Term Effect
priority DESC Try higher-priority jobs before lower-priority jobs.
available_at ASC Within a priority level, prefer the job that has been eligible longest. This also accommodates jobs scheduled to become available later.
id ASC Resolve ties in a stable, deterministic way, assuming id is unique.

A commonly used alternative is ORDER BY priority DESC, created_at ASC, which favors older-created jobs within each priority. Use available_at when eligibility time matters—for example, when a job may be deferred—and created_at when the policy is FIFO by enqueue time. Bassam Ismail’s queue example discusses priority ordering with FIFO within each priority: PostgreSQL as a job queue.

This is fair preference, not a guarantee that every job executes in exact timestamp order. A locked row can be skipped so another worker can make progress, and a steady supply of higher-priority work can leave lower-priority jobs waiting. If that is unacceptable, the policy needs an explicit way to bound how long lower-priority work can wait.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Claim and update each job atomically

Do not select a job in one statement and mark it running in a later statement. Two workers could select the same queued row before either update commits. Instead, select and lock an eligible row, skip rows already locked by other workers, and update ownership as part of the same statement:

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'queued'
    AND available_at <= now()
  ORDER BY priority DESC, available_at ASC, id ASC
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE jobs j
SET status = 'running',
    locked_by = $1,
    locked_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;

Here, $1 is the worker’s identifier. The inner query chooses and locks one due job; the outer update records that it is running and who claimed it. The returned row gives the worker the job data. If no eligible, unlocked row is available, the statement returns no rows. To claim a batch, use a batch limit and ensure the worker can process every returned row under the same recovery policy.

PostgreSQL documents that SKIP LOCKED skips rows that cannot be locked immediately and produces an intentionally inconsistent view. That makes it appropriate for queue consumers, not general-purpose reporting. See the PostgreSQL 16 documentation. The claim pattern is also shown in Prisma’s implementation walkthrough.

What fairness means with multiple workers

The ordering expresses which eligible job should be preferred; locking determines which rows remain available to a worker at that moment. If one worker holds a lock on the top-ranked row, another worker using SKIP LOCKED can claim the next eligible, unlocked row rather than wait. Under contention, workers therefore do not collectively execute jobs in a strict global FIFO sequence.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

That trade-off is what keeps claimers from blocking on the same busy row. The three terms still make the preference legible and repeatable among rows a worker can claim, but they cannot promise exact execution order or equal completion times: job duration and worker availability also affect when work finishes.

Keep the claim transaction short

Commit the status and ownership update before running the application handler. Do not hold the row lock while doing long-running work. A long transaction retains locks and can make other database activity wait, reducing the benefit of allowing claimers to skip locked rows.

Atomic claiming prevents two workers from claiming the same row at the same moment; it does not guarantee exactly-once execution. A worker can fail after the claim commits, leaving the job marked running even though its work did not finish. Use a lease or heartbeat and a recovery process that can return abandoned jobs to the queue. Treat execution as at least once: make handlers idempotent where possible and limit retries with an attempt budget. Ismail describes this failure caveat in his queue implementation.

Index the eligible working set

A partial index can keep completed history out of the index used for queued claims while reflecting the claim’s ordering:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
CREATE INDEX jobs_queued_claim_idx
ON jobs (priority DESC, available_at ASC, id ASC)
WHERE status = 'queued';

This is a starting point, not a guarantee that every claim will use the index optimally. Check the actual plan with EXPLAIN for the claim query and monitor claim latency as the table and workload grow. Indexes consume storage and add work to writes, so measure the benefit against their maintenance cost. Bassam Ismail’s discussion explains the value of an index aligned with the claim order and the cost of maintaining broader indexes: PostgreSQL queue implementation.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

When a Postgres table is a good fit

A table is especially attractive when PostgreSQL is already the application’s source of truth and creating a job must commit together with business data. The enqueue operation and the related business change can then share a transaction, avoiding a gap in which one commits but the other does not. A single table can also hold the queue’s status transitions, such as queued, running, and completed.

Compare this approach with Redis, RabbitMQ, or a hosted queue based on the needs that matter to the application: transaction coupling with database writes, delivery and retry semantics, dead-letter handling, throughput and latency, operational burden, and cross-service fan-out. The table pattern is not automatically the best fit if work must be distributed across services or the application needs queue capabilities beyond the state and recovery rules it is prepared to operate.

One published measurement is not a capacity promise: Percona Community reports a 1.68 ms median claim time at 16 workers in its specific 2026 workflow-engine benchmark. Results depend on the schema, indexes, transaction duration, hardware, workload, and PostgreSQL version; there is no universal jobs-per-second limit for this design. See Percona Community’s benchmark and workflow example.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.