title: "Postgres as the entire backend"
date: 2024-05-12
tags: [engineering]
reading_time: 10 min
slug: postgres-as-the-entire-backend
---
Postgres as the entire backend
The trend this year is to add more services. Auth service, queue service, analytics service, feature-flag service. Each of these is a reasonable idea in isolation, but together they add up to an ops bill and a mental model that barely fits in one head.
Postgres can do 80% of this. It's worth saying that out loud before reaching for the next service.
The features that replace services
Most teams know Postgres as "the database." It is also:
- An auth layer: row-level security (
RLS) policies are first-class. Define them once, enforce them in every query, stop writingWHERE user_id = ?in application code. - A message bus:
LISTEN/NOTIFYgets you pub/sub without Redis. Not replayable, not durable, but good enough for "tell the cron worker something happened." - A queue:
FOR UPDATE SKIP LOCKEDturns a regular table into a work queue. I've had one running at hundreds of jobs per second for two years, untouched. - A read cache: materialised views refresh on a schedule. The refresh has a cost but so does every cache invalidation strategy you were about to invent.
- An analytics store:
generate_series, window functions, lateral joins. Enough to power most dashboards before you need Clickhouse.
None of these are exotic. All of them ship in stock Postgres.
The one bad fit
The one thing Postgres is genuinely bad at is event sourcing at scale. If you need millions of events per second, write-amplify for analytics, and time-travel reads — go somewhere else. But that's less than 5% of apps I've seen.
A concrete example
Here's a queue in Postgres. 15 lines:
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
kind text NOT NULL,
payload jsonb NOT NULL,
run_after timestamptz DEFAULT now(),
attempts int DEFAULT 0
);
-- worker loop:
WITH next_job AS (
SELECT id FROM jobs
WHERE run_after <= now()
ORDER BY run_after
LIMIT 1
FOR UPDATE SKIP LOCKED
)
DELETE FROM jobs WHERE id IN (SELECT id FROM next_job) RETURNING *;
That's a work queue. Many workers, no double-processing, no extra infra. Add a retry policy with UPDATE instead of DELETE and you're done.
The trade-off
The price is:
- Operational concentration. If Postgres dies, your whole world dies. With separate services, each outage is smaller.
- Single vendor lock-in. Moving off Postgres one feature at a time is harder than moving off a dedicated service.
For most small-to-medium apps — including every side project I've shipped — the upside outweighs the cost by a large margin. Fewer moving parts is a real architectural virtue; we just stopped valuing it.