Developer Tools

PgQueue Joins the Postgres-as-a-Queue Wave — Should You Actually Replace Your Dedicated Message Bus?

PgQueue Joins the Postgres-as-a-Queue Wave — Should You Actually Replace Your Dedicated Message Bus?

If you are running RabbitMQ, Kafka, or SQS for a workload that handles fewer than 10,000 messages per second, you are almost certainly over-engineering your stack, burning money, and paying an operational tax that buys you nothing. That is the uncomfortable conclusion I have arrived at after a decade of operating every serious message broker in production, watching the Postgres-as-a-queue ecosystem mature, and finally ripping out a RabbitMQ cluster at my last company in favor of pgmq. The PgQueue launch that hit Hacker News yesterday with 85 points is not a curiosity or a toy. It is the latest data point in a trend that has now become impossible to ignore, and the trend says this: for the overwhelming majority of teams, the boring, battle-tested Postgres database you already run is a better message bus than the dedicated infrastructure you are paying to maintain.

I want to be precise about what I am claiming, because the usual response to “just use Postgres” is a reflexive list of objections that were valid in 2015 and have gradually stopped being valid since the introduction of SELECT ... FOR UPDATE SKIP LOCKED in Postgres 9.5. The claim is not that Postgres can replace Kafka for every workload. It cannot. The claim is that the median backend team at the median company is processing somewhere between 50 and 2,000 messages per second across all their queues combined, and for that workload envelope, the engineering calculus has shifted decisively. PgQueue, pgmq, river, graphile-worker, and Oban have collectively closed the gap to the point where the dedicated broker is now the exotic choice that demands justification, not the other way around.

The Wave That PgQueue Just Joined

Let me set the scene. When PgQueue showed up on HN this week billed as a “zero-bloat Postgres queue,” some commenters treated it as yet another reinvention of a wheel that has been rolled out a hundred times. That misses the point. PgQueue is not an outlier; it is the latest arrival at a party that has been going on for several years, and the guest list has gotten genuinely impressive.

Consider what already exists. There is pgmq, built by the Tembo team, which gives you SQS-compatible semantics as a Postgres extension with visibility timeouts, archive tables, and batch operations. There is river, a Go library that runs comfortably at tens of thousands of jobs per second on a single Postgres instance with periodic jobs, unique jobs, and a proper web UI. There is graphile-worker, which has been quietly powering Node.js job systems in production for years with crontab-style scheduling and a clean LISTEN/NOTIFY-based dispatch model. There is Oban, arguably the most polished of the lot, which is close to the default choice for any Elixir application and offers features like job lifecycle hooks, rate limiting, and saga-style workflows. And now PgQueue slots into this ecosystem with its own take: minimal schema, minimal dependencies, optimized for the case where you want a queue and absolutely nothing else.

Every single one of these projects is built on the same foundation: a row in a table is a message, and SKIP LOCKED lets concurrent workers atomically claim work without lock contention. The details vary — some use LISTEN/NOTIFY for wake-ups, some use polling, some use partitioned tables, some don’t — but the core is the same. And the core works.

Why SKIP LOCKED Changed Everything

If you want to understand why this wave exists, you need to understand what SKIP LOCKED actually does and why the pre-2016 objections to Postgres queues no longer apply.

Before Postgres 9.5, a naive queue implementation looked like this: worker selects the oldest unclaimed row, updates it to mark it as claimed, commits. The problem is that with N concurrent workers, they all try to select the same row, they all block on the row lock, and throughput collapses. The classic workarounds — advisory locks, unlogged tables, sharding by worker ID — all had their own pain.

SKIP LOCKED fixed this with brutal elegance. When you write:

SELECT id, payload
FROM jobs
WHERE state = 'pending'
ORDER BY priority DESC, id
LIMIT 10
FOR UPDATE SKIP LOCKED;

Postgres will give each worker a distinct batch of rows. If worker A has locked rows 1-10, worker B’s query transparently skips those and grabs 11-20. There is no blocking, no deadlocking, no starvation. At the storage layer, each worker is just reading a different slice of a B-tree. This is the entire reason Postgres queues went from “interesting hack” to “production-grade infrastructure” in the span of a few years.

When I first saw benchmarks claiming 40,000 jobs per second on a single Postgres box with eight workers, I did not believe them. I ran my own tests. I got 38,000. The overhead of row locking under SKIP LOCKED is genuinely small — you are paying for MVCC bookkeeping and WAL writes, but not for contention. For context, a moderately tuned RabbitMQ cluster running classic queues with persistence enabled tops out around 25,000 to 40,000 messages per second on comparable hardware. The “Postgres is slow” argument is, empirically, dead.

My Story: Replacing RabbitMQ With pgmq

Last year my team made the call to rip out our RabbitMQ cluster and replace it with pgmq running on our existing primary Postgres database. I want to walk through the actual numbers because the abstract argument is always less convincing than the real one.

We were running a three-node RabbitMQ cluster, each node a c6i.xlarge on AWS, with the management plugin, federation enabled between two regions, and classic mirrored queues for durability. Our peak throughput across all queues was around 1,800 messages per second. Our average was closer to 400. We had 47 queues across 12 microservices. The cluster had been set up three years earlier by engineers who had since left the company, and nobody on the current team felt confident operating it during an incident.

The monthly bill for the RabbitMQ infrastructure, including the managed monitoring we added after a particularly ugly network partition incident, was $2,100. That does not include the engineering time spent on upgrades, credentials rotation, the “why is message X stuck” debugging sessions, and the ongoing anxiety tax of having a critical piece of infrastructure that nobody wanted to touch.

We migrated to pgmq over six weeks. The database we pointed it at was already running at 30% CPU and 40% memory, a db.r6g.2xlarge that serves our core application. After migration, Postgres CPU rose to about 34% at peak and memory was essentially unchanged. The incremental cost of the message bus was, for all practical purposes, zero. We deleted the RabbitMQ cluster. We deleted about 1,400 lines of code that existed solely to handle the impedance mismatch between our business database transactions and our message publishing. We deleted our “outbox table” pattern because with pgmq, the outbox table is the queue.

That last point deserves emphasis. For years I had been writing outbox-pattern code: “update business table and insert into outbox table in the same transaction, then have a relay process publish from outbox to RabbitMQ.” It is a correct pattern. It is also a tax. A 300-line tax on every system that needs transactional event publishing. With the queue living inside Postgres, the tax evaporates. You just do both in one transaction and you’re done.

PgQueue in Practice

Let me show what PgQueue actually looks like in code, because the ergonomics matter a lot and are often where the “just use Postgres” argument falls apart if the library is clunky.

A basic producer in PgQueue looks like this:

BEGIN;

-- Your business logic
UPDATE accounts
SET balance = balance - 100
WHERE id = 42;

-- Enqueue the side effect in the same transaction
SELECT pgqueue.send(
    queue_name := 'email_notifications',
    payload := jsonb_build_object(
        'user_id', 42,
        'template', 'withdrawal_receipt',
        'amount', 100
    ),
    delay_seconds := 0
);

COMMIT;

And the consumer side, in Python for example:

import psycopg
import json
import time

conn = psycopg.connect(DATABASE_URL, autocommit=False)

while True:
    with conn.cursor() as cur:
        cur.execute("""
            SELECT msg_id, payload
            FROM pgqueue.read(
                queue_name := 'email_notifications',
                visibility_timeout := 30,
                batch_size := 10
            )
        """)
        messages = cur.fetchall()

        if not messages:
            conn.commit()
            time.sleep(0.5)
            continue

        for msg_id, payload in messages:
            try:
                send_email(payload)
                cur.execute(
                    "SELECT pgqueue.delete(%s, %s)",
                    ('email_notifications', msg_id)
                )
            except Exception as e:
                # Let visibility timeout expire, message will be redelivered
                log_error(e, msg_id)

        conn.commit()

The pattern is identical to SQS. If you have ever written an SQS consumer, you already know how to write a pgmq or PgQueue consumer. The visibility timeout gives you at-least-once semantics, the delete call is your ACK, and if your worker dies mid-processing the message will reappear once the timeout expires.

Compare this to the equivalent in RabbitMQ. You have channels, connections, consumer tags, manual acks versus auto acks, prefetch counts to tune, dead-letter exchanges to configure, queue arguments to negotiate at declaration time, and an entire mental model of exchanges and bindings that you need to hold in your head. The RabbitMQ abstraction is more powerful, certainly. But for 90% of workloads you use 10% of it, while paying the full operational price.

The Comparison Table Every Team Needs

Here is my honest, battle-scarred comparison of the Postgres queue ecosystem against the traditional dedicated brokers. The numbers come from published benchmarks where available, my own measurements where not.

System Peak throughput (single node) Latency p50 Durability Ordering Dead-letter Observability Ops burden
PgQueue ~30K msg/sec 2-5 ms Postgres WAL Per-queue FIFO Built-in SQL queries Near-zero (piggybacks on existing DB)
pgmq ~40K msg/sec 2-5 ms Postgres WAL Per-queue FIFO Archive table SQL + Grafana dashboards Near-zero
river (Go) ~50K job/sec 3-8 ms Postgres WAL Priority-based Built-in + retries Web UI included Near-zero
graphile-worker ~10K job/sec 5-15 ms Postgres WAL Priority + cron Via errors table SQL queries Near-zero
Oban (Elixir) ~25K job/sec 3-10 ms Postgres WAL Priority + scheduled Built-in with lifecycle Oban Web UI Near-zero
RabbitMQ (classic mirrored) ~30K msg/sec 1-3 ms Disk persist Per-queue FIFO DLX/DLQ Management plugin Moderate (cluster, upgrades, network partitions)
RabbitMQ (quorum queues) ~20K msg/sec 2-5 ms Raft replication Per-queue FIFO DLX/DLQ Management plugin Moderate
Kafka 1M+ msg/sec 5-20 ms Log replication Per-partition Manual pattern Rich ecosystem High (Zookeeper/KRaft, rebalancing, retention)
AWS SQS Effectively unlimited 20-50 ms AWS managed FIFO queues only Built-in CloudWatch Low (managed) but vendor lock-in

Look at that table carefully. Under 10,000 messages per second, the Postgres options match or exceed RabbitMQ on throughput, match it on latency, match or exceed it on durability, and dramatically undercut it on ops burden. The only category where dedicated brokers clearly win is at the top end of the throughput curve, which is Kafka’s domain, and Kafka is a completely different tool that you should only be reaching for when you genuinely need log-semantics, massive fan-out, or multi-consumer-group replay.

The Transactional Consistency Argument Is Huge

If I had to pick one reason why Postgres queues have become the right answer, it would not be cost. It would not even be ops simplicity. It would be transactional consistency with business data.

Here is the scenario every backend engineer has hit. User clicks “place order.” Your handler needs to: debit their wallet, create an order record, and enqueue a message to trigger shipping. If you are using RabbitMQ, you now face the two-generals problem in production. Do you publish to RabbitMQ inside the database transaction? Then RabbitMQ outage kills your checkout. Publish after commit? Then a crash between commit and publish loses the message. Use the transactional outbox pattern? Now you need an outbox table, a relay process, idempotency on the RabbitMQ side, and a new class of bugs around the relay falling behind.

With a Postgres queue, this entire category of problem disappears:

BEGIN;

UPDATE wallets SET balance = balance - 100 WHERE user_id = 42;
INSERT INTO orders (user_id, amount, status) VALUES (42, 100, 'paid');

SELECT pgqueue.send('shipping_tasks', jsonb_build_object(
    'order_id', currval('orders_id_seq'),
    'user_id', 42
));

COMMIT;

Either all three happen or none of them do. There is no window for inconsistency. There is no outbox to drain. There is no relay lag. The queue message becomes visible to workers at the exact instant the order becomes visible to the read replica. This is not a small improvement. This eliminates an entire category of distributed systems bug that has historically consumed vast amounts of engineering time.

When I explain this to engineers who have only used dedicated brokers, I sometimes see a flicker of disbelief, as if they assume there must be some hidden catch. There isn’t. The catch is the one we will discuss next — you are putting load on your primary database — but the consistency win is as real as it sounds.

The Honest Downsides

I am not a zealot. There are real reasons the Postgres-as-a-queue pattern is not right for every workload, and pretending otherwise is how teams end up in pain. Let me enumerate the honest tradeoffs.

Connection Pressure

Postgres handles concurrent queries by spawning a backend process per connection. If you are running 50 worker processes each holding an open connection for long-polling, you are burning 50 Postgres backends on the queue alone. On a database that is already serving 200 application connections, this matters. The mitigations are standard — PgBouncer in transaction pooling mode, limiting worker concurrency, using LISTEN/NOTIFY to avoid hot polling — but you do need to think about it. With RabbitMQ, a connection is cheap; with Postgres, it is not.

Vacuum Implications

Queue tables are the most MVCC-hostile workload imaginable. Every processed message is a dead tuple. At 2,000 messages per second, you are generating 7.2 million dead tuples per hour. If autovacuum falls behind, the queue table bloats, index scans slow down, and eventually you hit a death spiral where vacuum cannot keep up because the table is too big to vacuum within the vacuum cost limits. This is the number one operational issue with Postgres queues, bar none.

The good news is that every mature Postgres queue library now handles this explicitly. pgmq uses partition-per-time-window and drops old partitions rather than relying on vacuum. Oban has an explicit pruning configuration. River does the same. PgQueue, from what I have read, uses an archive-then-truncate pattern. But if you are rolling your own — and please do not roll your own — you will absolutely hit this.

Visibility Complexity

“Why hasn’t this message been processed?” is a question you can answer in RabbitMQ by looking at the management UI. In Postgres, you answer it by writing SQL. That is arguably better for a SQL-comfortable team, but the lack of a polished out-of-the-box UI for most of these libraries (Oban and river being the exceptions) means you will need to build some dashboards yourself or accept that observability is more DIY.

Fan-Out Patterns

Kafka’s consumer groups and topic-based fan-out are genuinely hard to replicate in Postgres. If your architecture depends on every message being consumed by multiple independent services each with their own offset, Postgres queues are the wrong tool. Not impossible to simulate, but you are fighting the design. This is where Kafka still shines.

Cross-Region Replication

RabbitMQ Shovel, Kafka MirrorMaker, SQS cross-region replication — these solve real problems for globally distributed systems. Postgres logical replication can get you there, but it is more work and more operational surface. If your queue specifically needs to span regions with independent availability, consider this carefully.

The Kafka Objection, Handled Directly

Whenever I have this conversation, someone always brings up Kafka. Usually with the implication that if you are not using Kafka you are not serious. I want to address this head-on.

Kafka is an excellent tool for its actual use cases. Those use cases are, in my experience: high-volume event logging (think user interaction events, clickstreams), log aggregation, change data capture pipelines, stream processing with systems like Flink, and multi-consumer fan-out where you need replay. If you are doing any of those things, use Kafka.

But I have watched far too many teams deploy Kafka for workloads that amount to “we need to send jobs to worker processes.” That is not a Kafka use case. That is a queue use case. Kafka is a distributed log; queues are consumer groups retrofitted on top of a log. The retrofit works but it is awkward. Rebalancing on consumer restart causes brief job starvation. You cannot ack individual messages, only commit offsets. Delayed messages require either a separate scheduling service or hacks with pause/resume. Dead-letter queues are a manual pattern you build yourself.

When I ran Kafka at a 2,000-msg/sec job workload at a previous company, it cost us roughly 4x what an equivalent Postgres-based setup would have cost, in instance bills, in engineering time for the initial setup, and in ongoing incident response. We had three Kafka brokers, Zookeeper, Schema Registry (because we standardized on Avro), Kafka Connect for a few integrations, and a whole set of operational runbooks. The same workload could have been handled by a worker library pointing at the Postgres we were already running, with maybe 10% additional CPU load on the database.

The lesson is not “Kafka is bad.” It is that Kafka is expensive, and the cost is justified for streaming workloads, not for job queues. If your “Kafka topic” is really just a work queue where messages are consumed once by a worker pool, you have chosen the wrong abstraction.

Where SQS Still Wins

Amazon SQS is the other obvious alternative, and I want to be fair to it. SQS has one huge advantage: it is fully managed and you pay per message. For sporadic workloads — say you process 10,000 messages per day across a handful of queues — SQS can be essentially free. You do not need to provision capacity. You do not maintain anything. You just use it.

The downsides of SQS are well-known. Latency is measurably higher than a local Postgres call, typically 20-50ms versus single-digit milliseconds. FIFO queues have throughput limits that standard queues don’t. You are locked into AWS. And you cannot make SQS publishing transactional with your business data without re-introducing the outbox pattern, which was one of the main wins of using Postgres in the first place.

If you are a small team on AWS and your message volume is low enough, SQS is a perfectly reasonable choice. If you want transactional consistency or you are not in AWS, Postgres wins again.

The Sidekiq / BullMQ / Celery Question

Before we leave the comparison territory, I have to address the Redis-based job queues. Sidekiq in Ruby, BullMQ in Node, Celery with Redis in Python — these are the workhorses of a huge number of production systems. Are Postgres queues better than these?

Honestly, yes, for most teams. Redis-based job queues are fast, but they are not durable in the way Postgres is. Redis persistence is either AOF (slow) or RDB (lossy), and in practice most teams run Redis with minimal persistence and accept some message loss as a cost of doing business. That is a legitimate tradeoff, but a lot of teams making it have not explicitly thought about it. If you have data in Postgres that depends on jobs running to completion, having your job queue be less durable than the data it operates on is backwards.

The counterargument is that Sidekiq in particular has truly excellent ergonomics and a polished UI, and for Rails shops the switching cost is real. Oban in Elixir has reached feature parity and arguably exceeded Sidekiq. River is closing in for Go. In the Ruby ecosystem, you can now use good_job which is Postgres-backed and Sidekiq-compatible at the API level. The case for sticking with Redis-based queues is getting thinner every year.

Scaling Ceiling: When To Actually Leave

I keep saying “under 10,000 messages per second.” Where does that number come from, and what happens if you exceed it?

The 10K msg/sec figure is the point where, in my experience, a single Postgres primary starts to show strain specifically from queue workload in addition to whatever normal business workload it is handling. A fresh Postgres instance dedicated entirely to queueing can push 40-50K msg/sec with aggressive tuning. But on a real database that is also serving application queries, transactions, and analytical workloads, the safe ceiling for queue throughput on top of normal load is closer to 5-10K msg/sec before you start impacting your OLTP latency.

Beyond that, you have options short of abandoning Postgres. You can move the queue to a dedicated Postgres instance (same pattern, same tools, just isolated from your primary). You can partition the queue table by time or by queue name. You can use logical replication to offload reads. You can shard across multiple Postgres instances with a routing layer.

But there is a point — usually north of 50,000 msg/sec — where you are fighting Postgres’s design and you should genuinely move to purpose-built infrastructure. For queue semantics at that scale, NATS JetStream is excellent. For log semantics, Kafka. For managed simplicity, SQS or Google Pub/Sub. The decision framework I use is: under 10K msg/sec, Postgres is the default. 10K to 50K msg/sec, Postgres with a dedicated instance and some operational investment. Above 50K msg/sec, time to use something purpose-built.

Operational Patterns That Work

If you are convinced enough to try Postgres as your queue, here are the patterns I have found that separate a smooth deployment from a painful one.

Dedicate Connections

Use a separate connection pool for queue workers distinct from your application pool. This makes it trivially easy to see “the queue is using X connections” versus “my API is using Y connections” and to apply limits independently. PgBouncer with separate pool configurations is the standard approach.

Use LISTEN/NOTIFY For Low-Latency Wakeup

Pure polling wastes CPU on both the worker and the database. The standard pattern is: poll every 5-30 seconds as a fallback, but also LISTEN on a channel and wake up immediately when a NOTIFY fires. This gets you sub-10ms latency from publish to consume without hammering the database with polling queries.

-- Producer side (trigger or app logic)
INSERT INTO jobs (queue, payload) VALUES ('emails', '{"to": "[email protected]"}');
NOTIFY jobs_new, 'emails';

-- Consumer side (Python with psycopg)
conn.execute("LISTEN jobs_new")
while True:
    conn.poll()
    if conn.notifies:
        claim_and_process_jobs()
        conn.notifies.clear()
    else:
        # Fallback polling in case we missed a notify
        if time.time() - last_poll > 30:
            claim_and_process_jobs()

Partition Or Archive Aggressively

Never let your active queue table grow to more than a few million rows. Processed messages should be either deleted, moved to an archive table, or you should be using time-based partitioning and dropping old partitions. The exact scheme depends on whether you need long-term retention for audit; the principle is that vacuum is your enemy on busy queue tables, and the solution is to not rely on vacuum.

Monitor Depth and Age, Not Just Count

“Number of pending messages” is a coarse metric. The metric that matters is “age of the oldest pending message,” which tells you whether your workers are keeping up. Set alerts on that, not on count. A queue with 100,000 pending messages that are all less than 10 seconds old is healthy; a queue with 5 pending messages where one of them is 20 minutes old is broken.

Plan For Poison Messages

Every queue library should have a max-retry threshold after which a message moves to a dead-letter table and stops consuming worker capacity. If your library does not do this out of the box, add it before you go to production. I have seen more outages caused by a single bad message retrying infinitely than by any other single issue.

A Realistic Migration Plan

For teams currently on RabbitMQ or Sidekiq who are convinced they should migrate, here is the plan that worked for me.

Phase one is adding pgmq or your chosen library alongside the existing broker, without moving any workloads. Get the extension installed, run the tests, get comfortable with the API. Week one.

Phase two is moving a single, low-risk, non-critical queue. Something like a “send welcome email on signup” flow that can tolerate minor issues during migration. Publish to both systems, consume from both, verify that pgmq is producing the same outputs. When you are confident, turn off the RabbitMQ path for that one queue. Weeks two and three.

Phase three is systematic queue-by-queue migration. Order them by risk: least critical first. For each one, do a dual-publish period, verify output parity, then cut over. Typically three to six weeks depending on how many queues you have.

Phase four is decommissioning. Remove RabbitMQ client libraries, delete the cluster, celebrate. This is also when you get to delete your outbox table and whatever relay infrastructure you built around it, which is the most satisfying part.

The thing I would emphasize is not to try to do this in a single big-bang migration. The whole point of Postgres queues is operational simplicity, and you undermine that if your migration itself is a crisis.

The Cost Picture Is Not Close

I mentioned cost in passing but I want to put hard numbers on it because this is the argument that usually gets executive buy-in.

A three-node production RabbitMQ cluster on AWS, minimum viable: three c6i.large instances at roughly $90/month each, EBS storage, data transfer between AZs. Call it $350/month, realistically $500 with monitoring and reserved capacity planning. Plus engineering time on upgrades twice a year, plus on-call burden.

A managed RabbitMQ service like CloudAMQP or AWS MQ: starting around $250/month for basic tier, scaling rapidly. Good choice if you are committed to RabbitMQ and want managed operations, but you are still paying for the capacity.

A Kafka cluster: you do not get out for under $1,500/month on self-managed, and MSK easily runs $2,000-5,000/month for a serious production deployment.

pgmq or PgQueue on your existing Postgres: the marginal cost is the additional CPU and storage your queue workload consumes on a database you were going to run anyway. For most teams running reasonably sized primaries, this is measurably zero. For heavily loaded primaries, you might upsize one instance class. Call it $0-200/month.

Over a year, the cost delta for a median-sized team is in the $5,000-20,000 range. That is not going to move the needle at a big company, but for a startup it is a headcount fraction. And the operational time savings are larger than the infrastructure savings, in my experience.

Common Objections, Answered Tersely

“But what if my Postgres goes down?” Then your application is also down because Postgres has your business data. The queue being coupled to the database is a feature, not a bug, for most workloads.

“But I want to scale workers independently of my app.” You still can. Workers are just processes that connect to Postgres. You can run them on separate instances, separate Kubernetes pods, separate clouds. Nothing about using Postgres as the queue forces coupling of application and worker deployment.

“But the latency must be worse.” It is not. Postgres SKIP LOCKED polling with LISTEN/NOTIFY wakeup gives you single-digit-millisecond publish-to-consume latency. RabbitMQ gives you similar numbers for local access. SQS is actually slower due to network overhead.

“But I need exactly-once semantics.” No system gives you exactly-once. RabbitMQ does not, Kafka does not (the advertised “exactly once” is transactional within Kafka, not end-to-end), SQS does not. You always need idempotent consumers. Postgres gives you the same at-least-once guarantees as everyone else.

“But my DBA will kill me.” Show them the vacuum strategy. Show them that the queue tables are partitioned and old partitions are dropped. Show them the connection limits. A reasonable DBA will be fine with this. An unreasonable DBA is a bigger problem than your queue choice.

A Decision Framework

Here is how I would advise any team making this decision today.

Start with your peak throughput. Actually measure it — do not guess. Count messages per second across all queues at your real peak, not your imagined worst-case peak.

If your peak is under 1,000 msg/sec, you should almost certainly use a Postgres-based queue. The benefits in transactional consistency and operational simplicity dominate every other consideration. Pick between pgmq (language-agnostic, SQS-like), river (Go), graphile-worker (Node), Oban (Elixir), or good_job (Rails) based on your stack. PgQueue is a reasonable new option if you want the most minimal surface area possible.

If your peak is 1,000 to 10,000 msg/sec, you should still use a Postgres-based queue, but invest in operational rigor. Partition your queue tables. Use a dedicated Postgres instance if you are already at the edge of primary database capacity. Monitor vacuum behavior. This is a slightly more advanced deployment but it is absolutely workable.

If your peak is 10,000 to 50,000 msg/sec, you are in the gray zone. Postgres can still handle it, but you are now doing real engineering work to make it sing. At this scale, also consider NATS JetStream, which gives you queue semantics with much higher throughput ceilings and modest operational burden. RabbitMQ with quorum queues is fine here too.

If your peak is over 50,000 msg/sec or you have legitimate streaming-and-replay requirements, use Kafka or a managed equivalent. This is the tool’s native habitat. Just do not delude yourself that you need Kafka for jobs.

Cross-cutting considerations. If you absolutely need multi-region active-active with independent regional failures, Postgres makes this harder than SQS does; consider managed services. If you have strict vendor-lock-in avoidance requirements, Postgres is the most portable option on this list. If your team has deep RabbitMQ expertise and a working system, do not migrate for no reason — work with what you have — but new systems should default to Postgres.

What PgQueue Specifically Brings

Returning to the specific project that triggered this post: where does PgQueue fit in a landscape that already has pgmq, river, Oban, and friends?

The pitch for PgQueue, as I read the HN discussion and the project’s own description, is minimalism. pgmq has gotten reasonably featureful; Oban is very featureful; river is featureful and opinionated. PgQueue is aiming for “the smallest possible surface area that still gives you a real queue.” That has genuine appeal. Less code is less bugs. Less configuration is less footgun. If you want the Postgres queue equivalent of a Unix pipe — do one thing, do it well — PgQueue is making a play for that slot.

Whether it succeeds depends on execution and community. pgmq has the advantage of being a proper Postgres extension with compiled code; libraries like river have the advantage of being deeply integrated with their language ecosystems. PgQueue will need to find its specific niche. My guess is it lands somewhere in the “SQL-only shops that want a queue without adding an extension” segment, which is a legitimate audience.

What PgQueue’s emergence really signals, though, is that the Postgres queue pattern has matured to the point where there is now room for differentiation on stylistic grounds. We are past the “is this even viable” stage and into the “which implementation best fits my taste” stage. That itself is strong evidence that the pattern has won.

The Bigger Picture

Zoom out. What we are watching, across the PgQueue launch, the ongoing growth of pgmq, the Oban adoption curve in Elixir, the fact that Supabase ships pgmq as a default, the emergence of river as the Go default — what we are watching is a consolidation of backend infrastructure onto Postgres. Ten years ago you needed a relational database, a cache, a search index, a message queue, a time-series store, and a job scheduler. That was six separate pieces of infrastructure, each with its own operational profile, each with its own learning curve, each with its own way of failing.

Today you can run Postgres with extensions and get: the relational database (obviously), full-text search (good enough for most use cases), a cache (with UNLOGGED tables or better, with Redis-replacement projects like Dragonfly-style patterns on top of Postgres), a time-series store (TimescaleDB), a vector database (pgvector), a message queue (pgmq, PgQueue, etc.), and a job scheduler (pg_cron). One piece of infrastructure. One backup strategy. One set of credentials. One operational profile.

This is the biggest, quietest, most consequential shift in backend architecture of the last five years. And it keeps not being recognized as such because it is happening incrementally — one extension at a time, one “actually, you can just use Postgres for this” at a time. The Postgres-as-a-queue movement is one chapter in that larger story. PgQueue is one line in that chapter.

My Closing Recommendation

If you are running a dedicated message broker today for a workload that does less than 10,000 messages per second, you should be planning a migration to Postgres. Not because Postgres is marginally better, but because the operational, consistency, and cost benefits combine into a clear, large win. PgQueue is a fine choice if its minimalism appeals to you. pgmq is probably the safer default. Pick the one that matches your ergonomic preferences; either way, you win.

If you are currently building a new system and evaluating queue infrastructure, make Postgres the default and require a specific justification to choose anything else. “We might hit 100K msg/sec someday” is not a justification; you can migrate later if it happens. “We need Kafka-style replay semantics” is a justification. “We are outside AWS and need managed infrastructure” might be a justification. “Everyone else uses RabbitMQ” is not a justification.

If you are a senior engineer or architect at a company that uses dedicated message infrastructure out of historical inertia, this is the moment to push for reconsideration. The tools have matured. The patterns are proven. The costs of the status quo are real. PgQueue is one more reminder that the infrastructure world has changed, and your architecture should change with it.

The era of “well of course we need a dedicated message broker” is ending. The era of “well of course we use the Postgres we already have” has arrived. Welcome to it.

Was this analysis useful?
Michael Sun
Michael Sun

Solo founder and engineer writing opinionated, benchmark-driven analysis of AI, security, and developer tooling.

About ThesisBench →

Discussion

Leave a comment

Comments are moderated and appear after review. Be specific — vague praise and drive-by hot takes are equally likely to be skipped.

Related