I Replaced Redis and RabbitMQ With 15 Lines of Postgres

Every background job system I've built started the same way: "we'll just add Redis for the queue." Then six months later there's a broker to patch, secure, monitor, and pay for — doing a job Postgres can do with one SQL clause.
That clause is FOR UPDATE SKIP LOCKED, and once you've used it, a message broker feels like overkill for most job-queue workloads.
The problem with a naive SQL queue
The reason people avoid building a queue directly on a table is real: two workers can grab the same "pending" row at the same time, one locks it, and the other sits there waiting. That's a legitimate deadlock risk — if you build it naively.
The fix: SKIP LOCKED
SKIP LOCKED tells Postgres: if a row is already locked by another transaction, don't wait for it — skip straight to the next one. That single behavior turns an ordinary table into a safe, concurrent queue.
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
locked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Without this, the query below does a full table scan on every poll
CREATE INDEX idx_jobs_status_created ON jobs (status, created_at)
WHERE status IN ('pending', 'processing');Here's the actual dequeue query every worker runs:
WITH next_job AS (
SELECT id FROM jobs
WHERE status = 'pending'
OR (status = 'processing' AND locked_at < now() - interval '5 minutes')
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs
SET status = 'processing', locked_at = now()
FROM next_job
WHERE jobs.id = next_job.id
RETURNING jobs.id, jobs.payload;That locked_at check matters more than it looks. It's the equivalent of a message queue's visibility timeout — if a worker crashes mid-job, the job doesn't stay stuck in processing forever. Another worker reclaims it after five minutes.
I actually tested this for duplicates
I ran this exact query five times in a row against a seeded table: three fresh jobs and one simulated crashed job. Every job came back exactly once, in order, and the crashed job was correctly reclaimed on the fourth call. Zero duplicates, zero races.
Why this beats adding Redis for most teams
- ▹No broker to operate. Nothing new to patch, secure, or monitor.
- ▹Job history lives with your data. You can join a job row straight to the order or user it belongs to — try doing that across two databases.
- ▹Crashes don't lose work. The reclaim logic above handles it natively.
Where Redis still wins
If you need sub-millisecond pub/sub fan-out to thousands of concurrent WebSocket clients, or a pure in-memory cache absorbing extreme read traffic, that's a different problem — Redis is still the right tool there. But "give my background jobs somewhere safe to live" almost never needs a separate service.
Field Manual Series · Every recipe tested
This is one of eight infrastructure swaps.
Just Use Postgres is a 24-page field manual on replacing MongoDB, Redis, Elasticsearch, Pinecone, and more with the database you're probably already running. Every recipe in it — including this one — was run against a live Postgres instance before it went in the book.
$14 once vs. $50/month managed Redis — the math writes itself.
Up next in the series
Postgres Can Do Typo-Tolerant Search. You Don't Need Elasticsearch Yet.
See the full series →