CoderBlog
Hosting

Postgres as a Queue: Why Your Database Is Your Best Message Broker

A working engineer's defense of using Postgres for async work in 2026 — the operational story, the at-least-once delivery pattern, and the surprising places where a dedicated broker is the wrong answer.

The standard advice for "I need to do something asynchronously" in 2026 is "spin up RabbitMQ, or Kafka, or SQS, or NATS, or one of the eight other message brokers that have been written since 2010." The advice is good. The advice is also often wrong. For most teams, the right answer is the database they already have, doing a job it was already designed to do, with about ten lines of code. The pattern is called "Postgres as a queue" and in 2026 it is finally mature enough to recommend without irony.

This is what that actually looks like in production — the patterns that work, the patterns that look like they work but do not, the operational story, and the surprising places where a dedicated message broker is genuinely the wrong answer.

Cover image: Postgres as a queue

Fig. 01 — The shape of work in flight. One table, one consumer, no broker, no schema registry, no Kafka cluster to babysit.

The Standard Advice and Where It Comes From

The "you need a message broker" advice comes from a real place. Decoupling services with a queue gives you a place to put work that does not need to be done synchronously. It gives you a buffer that absorbs traffic spikes. It gives you a place to retry failed work. It gives you a place to look at the work in flight. None of these are problems Postgres was designed to solve.

The problems show up when the queue is doing the work. The "queue is a database" pattern — which is what every message broker is, underneath — has a history. The first generation of "your database is your queue" advice was bad because the database did not have the features you need. There was no SELECT ... FOR UPDATE SKIP LOCKED. There was no LISTEN/NOTIFY. There was no way to atomically claim a row without locking the whole table. So the advice was "use a real queue." That advice was correct in 2010.

The database has caught up. PostgreSQL 9.5 added SKIP LOCKED in 2016. PostgreSQL 9.0 added LISTEN/NOTIFY in 2010. PostgreSQL 10 added native partitioning in 2017. PostgreSQL 13 added incremental backups in 2020. By 2026, Postgres has every feature a message broker needs, and it has the operational story your team already knows. The "you need a real queue" advice is now 10 years out of date. Most of the engineers giving it learned it 10 years ago and have not updated.

The Pattern That Works

The pattern that works in 2026 is a single table with a status column, indexed for the work you do most often, consumed by SELECT ... FOR UPDATE SKIP LOCKED in a loop. That is the entire pattern. The implementation in 30 lines.

-- the entire schema
CREATE TABLE work_queue (
    id           BIGSERIAL PRIMARY KEY,
    payload      JSONB NOT NULL,
    status       TEXT NOT NULL DEFAULT 'pending',
                  CHECK (status IN ('pending', 'running', 'done', 'failed')),
    attempts     INT NOT NULL DEFAULT 0,
    scheduled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX work_queue_pending_scheduled_idx
    ON work_queue (scheduled_at)
    WHERE status = 'pending';
# the entire consumer (one process per shard)
import psycopg
import time

while True:
    with conn.transaction():
        row = conn.execute("""
            SELECT id, payload
            FROM work_queue
            WHERE status = 'pending'
              AND scheduled_at <= now()
            ORDER BY scheduled_at
            FOR UPDATE SKIP LOCKED
            LIMIT 1
        """).fetchone()

        if row is None:
            time.sleep(1)
            continue

        try:
            handle(row.id, row.payload)
            conn.execute(
                "UPDATE work_queue SET status = 'done' WHERE id = %s",
                (row.id,))
        except Exception:
            conn.execute("""
                UPDATE work_queue
                SET status = CASE
                    WHEN attempts + 1 >= 5 THEN 'failed'
                    ELSE 'pending'
                END,
                attempts = attempts + 1,
                scheduled_at = now() + (interval '1 minute' * power(2, attempts))
                WHERE id = %s
            """, (row.id,))

That is the entire system. The producer is INSERT INTO work_queue .... The consumer is the loop above. The retry policy is exponential backoff with a hard cap. The dead-letter handling is the failed status. The horizontal scaling is to run more consumers. The visibility is SELECT * FROM work_queue WHERE status = 'pending'.

The throughput, on a single Postgres node, is roughly 1,000 to 5,000 work items per second, depending on the payload size and the work itself. That is enough for 95% of the asynchronous workloads in real production systems. When it is not enough, the answer is not "switch to Kafka." The answer is "shard by some key" or "use pg_partman to partition the table by created_at" or "use logical replication to a read replica dedicated to the queue."

The Operational Story, Honestly

The operational story for "Postgres as a queue" is "you already operate it." Your team knows how to back it up, how to monitor it, how to scale it, how to recover from a failed node, how to debug a slow query, how to add an index, how to add a column, how to read the WAL, how to run pg_dump. The same is not true for RabbitMQ, Kafka, NATS, or any other message broker. Each of those has its own operational story, its own failure modes, its own backup story, its own monitoring story, its own upgrade story.

"Postgres as a queue" inherits all of your existing operational investment. The backup is the same backup. The monitoring is the same monitoring. The incident playbook is the same playbook. When the queue is slow, the on-call engineer knows what to do. When the queue is full, the on-call engineer knows what to do. When the queue needs more capacity, the on-call engineer knows what to do. The same is not true of a dedicated broker. Every dedicated broker adds operational surface area, and every additional surface area is a place for incidents to hide.

The "Postgres as a queue" pattern is not for every team. If your team does not have a strong Postgres operational story, the pattern is not free. If your team has a strong RabbitMQ operational story, the pattern is probably not worth the disruption. The point is not that the pattern is universally right. The point is that the pattern is right for teams that already have a strong Postgres operational story, and that is most teams in 2026.

The Patterns That Look Right and Are Not

The patterns I have seen fail in production. The ones you should not copy from a blog post without understanding the tradeoffs.

The polling consumer. The naive implementation of "consumer reads from the queue" polls the table on a timer. SELECT * FROM work_queue WHERE status = 'pending' LIMIT 100; every 5 seconds. The problem is the cost. On a busy table, the query is expensive. On a quiet table, the latency is high. The fix is LISTEN/NOTIFY, which wakes the consumer when a new row is inserted. The poll is the fallback for the case where the NOTIFY is lost. Use NOTIFY first. Poll as a backstop.

The shared table for work and metadata. The naive implementation of "store the work in a JSONB column on the existing table" — ALTER TABLE users ADD COLUMN pending_emails JSONB; — is wrong. The queue is a different access pattern. The index is different. The retention is different. The vacuum strategy is different. Use a separate table. Always. The operational complexity of two tables is less than the operational complexity of one table with two access patterns.

The unbounded queue. The naive implementation of "work_queue" without a retention policy is wrong. The queue grows. The disk fills. The backups slow. The vacuum bogs. The fix is a retention policy — drop or archive rows older than 7 days, or after status = 'done' for 24 hours. Put the retention in a cron job, or in pg_partman, or in a background worker. Make the retention explicit. Make the retention monitored. Make the retention someone else's on-call rotation.

The "just use Kafka" architecture. The naive implementation of "we need to do work asynchronously" is "add Kafka to the stack." The Kafka cluster needs to be operated, monitored, secured, scaled, upgraded, recovered. The Kafka producers and consumers need to be instrumented. The Kafka schema registry needs to be set up. The Kafka topic naming convention needs to be agreed. The Kafka retention needs to be set. None of this is hard, individually. All of it together is a meaningful fraction of an engineer's time for the first 6 months. The "just use Kafka" advice is bad advice for teams that do not already operate Kafka. The "just use Postgres" advice is good advice for teams that do already operate Postgres.

When a Dedicated Broker Is the Right Answer

The cases where a dedicated message broker earns its operational cost. Be honest about whether any of these apply to you before you default to "we need Kafka."

You need to push more than 50,000 work items per second. Postgres can do this with sharding, but the sharding story is a real engineering project. Kafka can do this out of the box, with mature tooling for horizontal scaling. If your throughput requirement is in this range, the right answer is Kafka. The same is not true if your throughput requirement is in the "thousands per second" range, which is what 95% of real workloads are.

You need to replay history. Kafka's log-based architecture means you can rewind a consumer and replay the messages from any point in the last N days. Postgres-as-a-queue does not have this. The history is gone the moment a row moves to status = 'done'. If you need to replay, you need a log. If you need a log, you need Kafka, or Pulsar, or Redpanda, or one of the log-based systems. There is no good way to fake this with Postgres.

You need exactly-once delivery across heterogeneous services. Postgres gives you at-least-once. If you need exactly-once, you need a system that supports it natively — Kafka with transactional producers, or a custom solution that involves idempotency keys and deduplication on the consumer. The "exactly-once across services" guarantee is one of the few cases where a dedicated broker is genuinely necessary.

You need fanout to more than 100 services. A Postgres queue with 100 consumers is fine. A Postgres queue with 100,000 consumers is not. The lock contention on the queue table will become the bottleneck. A dedicated broker with native fanout (Kafka's consumer groups, RabbitMQ's exchanges, NATS's subjects) handles this without modification.

You have a team that already operates a message broker. This is the most important case. If your team already runs Kafka in production, the operational cost of adding another queue is zero. The right answer for you is to use Kafka. The fact that Postgres could do the job is irrelevant.

For everyone else — and the "everyone else" is most teams in 2026 — the answer is Postgres. The pattern is mature. The operational story is yours. The cost is zero. The performance is enough. The work is to write 30 lines of code and to convince your team that the standard advice is 10 years out of date.

The Shape of What Comes Next

The "Postgres as a queue" pattern is not new. The pattern has been around since at least 2014. What is new is that the standard advice has caught up. The engineers giving the "you need Kafka" advice in 2014 were right. The engineers giving the same advice in 2026 are not. The pattern works. The operational story is yours. The performance is enough. The cost is zero. The question is not whether to use it but when to reach for a dedicated broker instead.

A small toolkit to get started, if you have not already: in your existing Postgres database, run the CREATE TABLE above. In your application, add the producer (INSERT INTO work_queue ...) and the consumer (the Python loop above). Deploy the consumer as a separate process — Kubernetes pod, systemd service, ECS task, whatever you have. Watch the queue for a week. See what fails. See what surprises you. The pattern is the work. The rest is operational hygiene.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.