~/sumit/portfolio — postgres-as-the-entire-backend.md
writing.md/Postgres as the entire backend
---
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

10 min read engineering by sumit

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 writing WHERE user_id = ? in application code.
  • A message bus: LISTEN / NOTIFY gets you pub/sub without Redis. Not replayable, not durable, but good enough for "tell the cron worker something happened."
  • A queue: FOR UPDATE SKIP LOCKED turns 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.