In defence of boring databases
The most expensive architecture decisions we get asked to unwind are rarely reckless. They are aspirational. A team picked infrastructure for the scale they hoped to reach, and paid for it every day in the scale they actually had.
Our default is Postgres. Not because it is fashionable, and not because it is simple — it is a deep, occasionally surprising piece of software — but because it absorbs an enormous amount of product change before it makes you move.
It is already several databases
Most of the systems a growing product reaches for are things Postgres does adequately, in the same process, inside the same transaction and the same backup.
- Document store: JSONB with indexes on the paths that matter, for the genuinely schemaless parts.
- Search: full-text with ranking that is good enough for most in-product search.
- Vector store: pgvector, next to the rows the embeddings describe — the approach behind RAG without the hallucinations.
- Queue: SKIP LOCKED handles far more throughput than most teams will ever queue.
- Cache: an unlogged table is unglamorous and frequently sufficient.
One system you understand deeply beats four you each understand approximately.
The value is not that each is best-in-class. It is that they share one connection, one consistency model, one restore procedure, and one place to look at three in the morning.
Consistency is a feature you stop appreciating
The strongest argument for a boring relational database is the class of bug it makes impossible. Write across two stores and you inherit partial failure forever: the row saved, the document did not, and now something must reconcile them. That reconciler is code nobody wants to own, and it is where the ugliest production incidents live.
One transaction, or an explicit compensating action you designed on purpose. Those are the honest options. Distributed state acquired by accident is the third one, and it is the one that hurts.
Boring does not mean unoptimised
Almost every "we have outgrown Postgres" conversation we join is really a missing index, an N+1 in a loop, or a query pattern that never got read back. Measure before migrating — the fix is usually hours of work rather than a quarter of it.
-- Which queries actually cost you? Ask, don't guess. SELECT calls, round(total_exec_time) AS ms, round(mean_exec_time, 2) AS avg_ms, query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
Connection pooling, a read replica for reporting, and partitioning the one table that genuinely got huge will carry a product a very long way — considerably further than most roadmaps require.
When we do reach for something else
There are real cases, and pretending otherwise would be its own kind of dogma. Genuinely global write locality. Append-only telemetry at volumes where a column-oriented store is a different category of tool, not a tuning exercise. Millisecond-sensitive caching that wants to live in memory. Analytical queries over billions of rows that have no business competing with your transactional workload.
What those have in common is a measured constraint, not an anticipated one. That is the whole test we apply: can you point at the number that made this necessary? If yes, specialise deliberately. If no, the boring answer is still winning, and the capacity you were about to spend on infrastructure is better spent on the product.
Start here
Let's build the thing they can't stop using.
Send the brief. A senior engineer reads every one and comes back with an architecture, a plan and a quote written against your actual scope — usually within a day.