env.dev

Postgres as a Message Queue with SKIP LOCKED

FOR UPDATE SKIP LOCKED turns a Postgres table into a job queue with transactional enqueue. Working SQL, LISTEN/NOTIFY, pgmq, and when you still need Kafka.

By env.dev Updated

Postgres is Enough series

Part 1: Just Use Postgres: One Database for Almost EverythingPart 2: Postgres as a Message Queue with SKIP LOCKED (you are here)

A Postgres table with FOR UPDATE SKIP LOCKED is a production-grade job queue, and it has been since the clause landed in Postgres 9.5 (January 2016). The PostgreSQL docs name the use case themselves: skipping locked rows exists "to avoid lock contention with multiple consumers accessing a queue-like table". One measured implementation put the overhead at about 1.5 ms of extra database time per task — in exchange, you get something Redis, RabbitMQ, and SQS structurally cannot offer: the job is enqueued in the same transaction as the business change that caused it, so a rolled-back order never sends its confirmation email. Rails 8 leaned into exactly this trade-off by shipping Solid Queue, a database-backed backend that "replaces the need for not just Redis, but also a separate job-running framework, like Resque, Delayed Job, or Sidekiq, for most people".

TL;DR

  • FOR UPDATE SKIP LOCKED lets many workers pull from one jobs table without blocking each other — each worker locks a different row and skips the ones already taken.
  • The killer feature is transactional enqueue: job and business write commit or roll back together, which removes the dual-write/outbox problem external brokers force on you.
  • LISTEN/NOTIFY removes polling latency; pg_cron covers scheduled jobs; pgmq packages it all as an SQS-like extension.
  • Mature worker libraries exist for most stacks: River (Go), Oban (Elixir), Graphile Worker and pg-boss (Node.js), Solid Queue (Rails).
  • Reach for Kafka or SQS when you need replayable streams fanned out to many independent consumers, or sustained throughput beyond what one Postgres instance should absorb.

Why use Postgres as a job queue?

Because the hardest bug in queue-based systems is not throughput — it is the dual write. With an external broker, "save the order, then enqueue the email job" is two systems that can disagree: the database commits and the enqueue fails, or the enqueue succeeds and the transaction rolls back. The textbook fix is the transactional-outbox pattern, which means… writing the job to a database table first. A Postgres queue simply skips the middleman. River, the Go job library, is built on this observation: keeping jobs in the application database avoids "whole classes of distributed systems problems" — jobs enqueued inside a transaction become visible to workers only if that transaction commits.

It is also one less system to run, which is the core of the Just Use Postgres argument: every datastore you add brings its own backups, monitoring, upgrade cadence, and failure modes. A queue that lives in the database you already operate costs none of that.

How does FOR UPDATE SKIP LOCKED actually work?

FOR UPDATE locks the selected rows; SKIP LOCKED tells Postgres to silently skip any row another transaction has already locked instead of waiting. Run the same query from fifty workers and each gets a different job, with no coordination code:

A minimal jobs table and worker loop
CREATE TABLE jobs (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  kind        text NOT NULL,
  payload     jsonb NOT NULL DEFAULT '{}',
  status      text NOT NULL DEFAULT 'pending',   -- pending | running | done | failed
  attempts    int  NOT NULL DEFAULT 0,
  run_at      timestamptz NOT NULL DEFAULT now(),
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX jobs_claim_idx ON jobs (run_at) WHERE status = 'pending';

-- each worker runs this in a transaction
UPDATE jobs SET status = 'running', attempts = attempts + 1
WHERE id = (
  SELECT id FROM jobs
  WHERE status = 'pending' AND run_at <= now()
  ORDER BY run_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
RETURNING id, kind, payload;

The partial index keeps the claim query fast no matter how many completed jobs accumulate. Retries are a run_at = now() + interval '1 minute' * attempts update on failure; enqueueing is a plain INSERT, which is why it composes with whatever transaction your application already has open. Delete or archive finished rows on a schedule — the PostgreSQL cheat sheet has the partitioning and bulk-delete patterns.

How do workers pick up jobs instantly without hammering the database?

Poll on a coarse interval as the reliable baseline, and layer LISTEN/NOTIFY on top for latency. Workers LISTEN new_job; enqueuers fire NOTIFY — and because notifications are delivered only when the sending transaction commits, a worker can never be woken for a job that later rolled back:

Wake workers on commit
-- in the enqueueing transaction
INSERT INTO jobs (kind, payload) VALUES ('send_email', '{"user_id": 42}');
SELECT pg_notify('new_job', 'send_email');
COMMIT;  -- notification is delivered here, not before

-- worker connection
LISTEN new_job;  -- then claim with the SKIP LOCKED query on wake-up

Two limits to respect: payloads are capped at 8000 bytes (send a job id, not the job), and the notification queue is a finite 8 GB — a worker that goes to lunch inside an open transaction stops the queue from draining. Treat NOTIFY as a doorbell, not a delivery mechanism; the jobs table remains the source of truth.

Should you use pgmq or a worker library instead of raw SQL?

Usually, yes — the raw pattern is worth understanding, but retries, backoff, visibility timeouts, and metrics are solved problems. pgmq (5,000+ GitHub stars) is a Postgres extension exposing an SQS-shaped API in SQL: pgmq.send(), pgmq.read() with a visibility timeout that makes crashed-worker messages reappear automatically, and pgmq.archive() for replayable history. pg_cron adds scheduled jobs with ordinary cron syntax — SELECT cron.schedule('nightly-vacuum', '0 10 * * *', 'VACUUM') — validate expressions with the cron parser.

LibraryStackNotable
RiverGoTransactional enqueue as the headline feature
ObanElixirThe de facto Elixir job system, Postgres-backed
Graphile WorkerNode.jsLow-latency via LISTEN/NOTIFY out of the box
pg-bossNode.jsRetries, backoff, and cron-style scheduling
Solid QueueRailsBundled with Rails 8; replaces Redis + Sidekiq for most apps

What are the gotchas of a Postgres queue?

  • Don't hold the claim transaction open while the job runs. A ten-minute PDF render inside the locking transaction blocks vacuum and pins the connection. Claim with a status update, commit, do the work, then mark done — and add a reaper that re-queues running jobs whose worker stopped heartbeating.
  • Dead-tuple churn. Every state transition is an update, and at high throughput the jobs table becomes autovacuum's hardest customer. Keep the table small (archive finished jobs), and monitor n_dead_tup. This is the honest scaling ceiling of the pattern.
  • SKIP LOCKED reads are deliberately inconsistent. The docs are explicit that skipping locked rows "provides an inconsistent view of the data" — fine for claiming work, wrong for anything else. Don't reuse the clause in ordinary queries.
  • Strict FIFO is an illusion under concurrency. ORDER BY run_at plus SKIP LOCKED gives approximate ordering; if two workers claim simultaneously, completion order is theirs to decide. If exact ordering matters, process that queue with a single worker.

When is a real message broker the right call?

A jobs table is a work queue, not an event streaming platform. Reach for Kafka (or Redpanda, or a managed equivalent) when many independent services consume the same ordered stream and need to replay it from an offset — that is a different data structure, not a bigger queue. Reach for SQS when producers and consumers live in different trust domains, or when volume is so bursty that you want someone else's infinite buffer. And if your jobs are pure cache invalidations with no transactional tie to your data, a Redis-based queue loses little. The threshold to watch in Postgres is sustained thousands of jobs per second with autovacuum falling behind — below that, the simpler system wins.

References

This is part 2 of the Postgres is Enough series — start with Just Use Postgres for the full consolidation argument, and check your schedule expressions with the cron parser.

Was this helpful?
Series · Part 2 of 2

Postgres is Enough

A series on Postgres as the default for queues, caching, search, vectors, and more.

  1. Part 1

    Just Use Postgres: One Database for Almost Everything

  2. Part 2 · You are here

    Postgres as a Message Queue with SKIP LOCKED

Frequently Asked Questions

Can Postgres replace RabbitMQ or SQS for background jobs?

For application background jobs — emails, exports, webhooks, image processing — yes. FOR UPDATE SKIP LOCKED gives contention-free claiming, and transactional enqueue removes the dual-write problem brokers introduce. RabbitMQ and SQS earn their place for cross-service messaging, very bursty volume, or when producers and consumers are operated by different teams.

How many jobs per second can a Postgres queue handle?

A well-indexed SKIP LOCKED queue on ordinary hardware comfortably processes hundreds of jobs per second, and one measured implementation put queue overhead at roughly 1.5 ms of database time per task. The practical ceiling is dead-tuple churn from status updates: sustained thousands of jobs per second make the jobs table an autovacuum hotspot, which is the point to consider pgmq partitioning or a dedicated broker.

What is the difference between SKIP LOCKED and advisory locks for queues?

SKIP LOCKED uses ordinary row locks: claim by selecting, and the lock releases on commit — no bookkeeping. Advisory locks are application-defined locks keyed by an integer you choose; they can outlive a single statement and work across transactions, but you must release them and map keys to jobs yourself. For a jobs table, SKIP LOCKED is simpler and the standard choice; advisory locks shine for singleton processes like "only one instance of this scheduler may run".

Do I need pgmq, or is plain SQL enough?

Plain SQL is enough for a single application with modest needs — the whole pattern is one table and one query. pgmq is worth adopting when you want SQS semantics without building them: visibility timeouts that auto-recover crashed workers, message archiving for replay, and partitioned queues for scale. It is an extension, so it is available on Supabase and other managed providers.

How do I run scheduled (cron) jobs in Postgres?

pg_cron runs inside the database and schedules any SQL with standard cron syntax: SELECT cron.schedule('cleanup', '0 3 * * *', $$DELETE FROM jobs WHERE status = 'done' AND created_at < now() - interval '7 days'$$). It is supported on most managed Postgres services, including RDS, Cloud SQL, and Supabase. Pair it with the jobs table to enqueue recurring work.

Stay up to date

Get notified about new guides, tools, and cheatsheets.