pgmq
"A lightweight message queue. Like AWS SQS and RSMQ but on Postgres." Rust (the extension packaging; the queue itself is PL/pgSQL), PostgreSQL licence, 5,097★ / 141 forks, created 2023-07-24 and pushed 2026-08-20, supporting Postgres 14–18. Named users include Tembo, Supabase, Sprinters and pgflow.
Read from the repository README and its SQL source (pgmq-extension/sql/pgmq.sql) — T1. The
README is a usage document, so the mechanism below comes from the code, the same way pev2 was
read.
Pulled from awesome-postgres' Work Queues section, a seven-entry section that is the clearest example in the whole catalog of the pattern that catalog demonstrates: rather than run a queue, run Postgres.
SKIP LOCKED is the whole idea
A queue is a table that many workers try to take rows from, and the naive version serialises: worker
B waits on the row worker A locked. FOR UPDATE ... SKIP LOCKED inverts that — a locked row is not
a thing to wait for, it is a thing to step over. That one clause is what turns a table into a
concurrent work queue, and it is the reason this section of the catalog exists at all.
pgmq's read() is a single statement. Its stages, from the source:
available_messages— rows wherem.vt <= clock_timestamp(), i.e. not currently invisible.ordered_messages— assign a selection order (with a layered round-robin across message groups, which is how FIFO-per-group is delivered without serialising the whole queue).selected_messages—FOR UPDATE OF m SKIP LOCKED, taking only what no one else holds.updated_messages—UPDATE … SET vt = clock_timestamp() + <interval>, read_ct = read_ct + 1, last_read_at = clock_timestamp(), carrying a second predicate the code annotates itself:AND m.vt <= clock_timestamp() -- final guard to avoid duplicate reads under races.
Reading a message is writing it. There is no dequeue operation and no broker state — the message's
visibility is a timestamp column, and claiming it is an UPDATE of that column inside the same
statement that selected it.
The delivery guarantee, read exactly
"pgmq guarantees exactly once delivery of a message within a visibility timeout. The visibility timeout is the amount of time a message is invisible to other consumers after it has been read by a consumer. If the message is NOT deleted or archived within the visibility timeout, it will become visible again and can be read by another consumer."
The qualifier carries the entire claim. Within the timeout, exactly once; across timeouts, the
message comes back, which is at-least-once with a redelivery clock. The schema admits it — read_ct
counts how many times a message has been handed out, and it would be a pointless column under a real
exactly-once guarantee. The operational instruction follows: "It is recommended to set a vt value
that is greater than the expected time it takes to process a message." Guess low and you get
duplicate processing; guess high and a crashed worker's message sits invisible until the clock runs
out. This is SQS's contract, reproduced faithfully including the part everyone misreads.
archive() is the other half of the contract: it moves a message to a parallel a_<queue> table
rather than deleting it, so the queue keeps a replayable, SQL-queryable history. Every queue is an
ordinary table (pgmq.q_<name>), which means the dead-letter investigation that would need a console
elsewhere is a SELECT here.
Why anyone does this
"No background worker or external dependencies, just Postgres SQL objects" — and it installs either as an extension or by running one SQL file, for environments that cannot load extensions. The consequence the README does not spell out is the one that matters: enqueue can happen in the same transaction as the write that caused it. Insert the order and send the "process payment" message atomically, and the failure mode where the row committed but the message did not simply cannot occur. Against SQS that is not a convenience, it is the removal of a consistency boundary — and it is the same argument citus makes about staying inside Postgres, applied to a different problem.
It also sidesteps a hazard this spoke just paged. pgbouncer's transaction pooling breaks
LISTEN, the mechanism several sibling entries in the Work Queues section rely on for wakeups. A
poller reading a table has no session state to lose.
(My reading, not the project's: the cost is on the other side of the ledger and it is a consequence
of MVCC as postgresql implements it. Every read is an
UPDATE, every UPDATE writes a new heap tuple and leaves a dead one, and a queue's whole point is
churn. A high-throughput pgmq queue is a high-churn table with continuous vacuum pressure — the
classic failure being a table whose physical size grows steadily while its row count stays near zero.
The README makes no claim either way and I have no benchmark; recording the mechanism, not a verdict.)
Not answered here
Any throughput number at all — README and page alike are silent on how many messages per second this sustains, and how it compares to a dedicated broker. Partitioned queues, FIFO group semantics beyond the round-robin visible in the source, and the topic-routing feature are listed and unread.
Related
postgresql · postgresql-extensions · pgbouncer · multiversion-concurrency-control · citus · awesome-postgres · event-sourcing · operational-databases