# Postgres as the Default Everything-Store, and Where It Ends

*Published 2025-06-17 · Dmitry Shirokov · shirokoff.ca/blog/postgres-as-default-datastore*

Nine boxes on the diagram: Postgres, Redis, RabbitMQ, Elasticsearch, a vector database, S3, a warehouse, an ETL tool, a metrics store. Four engineers. Nobody was on call for nine systems, which meant nobody was on call for six. Every choice was individually defensible; together they were why the team shipped slowly. **Start on the database you already operate, and add a specialist system when a specific, nameable constraint forces it** — because each extra datastore costs a backup story, a failure mode, a consistency boundary, an on-call rotation, and a sync pipeline nobody estimates.

## Five things Postgres does well enough

| Need | Postgres answer | Good until |
|---|---|---|
| **Job queue** | `SELECT … FOR UPDATE SKIP LOCKED`, or pgmq | Thousands/sec; fan-out and replay semantics |
| **Full-text search** | `tsvector` + GIN, or a BM25 extension | Relevance tuning and faceting become the product |
| **Vector search** | pgvector HNSW; DiskANN-style on managed platforms | ~10M vectors, or filtering/hybrid as the product |
| **Document store** | `JSONB` + GIN, partial/expression indexes | Schemaless multi-tenant data at very high write rates |
| **Cache** | Unlogged table, or the buffer cache doing its job | Sub-ms p99 at high concurrency; pub/sub |
| **Light analytics** | Materialized views, partitioning, columnar extensions | Tens–hundreds of GB scanned; concurrent BI |

**Queues**: `SKIP LOCKED` lets workers claim rows without blocking, and because the claim is a transaction, **enqueueing in the same transaction as the business change** is trivial — the classic "row committed, job never queued" bug can't happen, and the transactional outbox is a pattern you skip. Ends at very high sustained throughput, fan-out to independent consumer groups, and replay over a retained log — that's Kafka's job.

**FTS**: stemming, ranking, phrase queries, GIN staying fast over millions of docs — and hybrid retrieval (text rank + vector distance in one statement) with no second system. Ends when search *is* the product: custom analyzers, faceting, typo tolerance, relevance experimentation. The tell is a dedicated person tuning relevance.

**Vectors**: real RAG workloads to ~10M, with embeddings updated in the same transaction as the row, so no index-sync pipeline. Ends at single-node scaling and when filtered/hybrid performance is the product.

**Documents and cache**: JSONB keeps joins and transactions alongside relational data — usually why the document store was a bad fit. And many caches exist because someone assumed a DB read was expensive; a warm indexed Postgres query often beats the network hop to Redis. Cache when measured, not on principle.

## Where it genuinely ends

Single-node writes · analytical scans (row storage, per-query processes) · connection scaling (a process per connection → pooler) · **long transactions blocking vacuum** (bloat and stalled autovacuum: the classic Postgres incident) · managed-service extension availability · **mixed workload interference**.

## Graduation triggers, as metrics

Queue → broker: queue-table bloat you can't vacuum, claim contention, a second consumer wanting independent replay. FTS → search engine: someone's full-time job is relevance. pgvector → vector DB: p95 climbing with vector count, rebuilds outgrowing the maintenance window, filtered recall failing your eval set. Postgres → warehouse: analytics interfering with transactional latency even on a replica. Single node → sharding: writes at the primary's ceiling with no instance size left.

## The failure mode of this advice taken too far

One instance running transactions, a queue, a vector index and analytics has **a single failure domain and four workloads that starve each other**. An index rebuild slows checkout; a runaway query exhausts connections. And the subtle one: **a long transaction anywhere blocks vacuum everywhere**, so an analytical query held open for an hour bloats your queue table and presents as a slow queue with no obvious cause. The version I'd defend: **one database technology, not necessarily one instance** — three Postgres instances for three workloads still means one skill set, one backup story, one operational model, with a failure domain each.

## Leaving without a rewrite

**Narrow interfaces per capability** (`Queue.enqueue/claim`, `Search.query`, `Retriever.search(vector, filter, k)`) so graduation is a new implementation, not a call-site hunt. And **turn on logical replication early**: stand the specialist system up as a consumer, backfill, run both, compare, cut over. The same mechanism behind [CDC pipelines](https://shirokoff.ca/blog/debezium-cdc) is what makes every graduation incremental.

*Related: [pgvector on Postgres](https://shirokoff.ca/blog/pgvector-postgres-vector-database) · [Postgres internals](https://shirokoff.ca/blog/postgres-internals) · [MVCC](https://shirokoff.ca/blog/mvcc-multi-version-concurrency-control) · [Designing a data API](https://shirokoff.ca/blog/designing-a-data-api) · [System design for data engineers](https://shirokoff.ca/blog/system-design-for-data-engineers)*
