Every few months, a new database shows up with a landing page that reads like it was designed to be screenshotted. It's serverless, distributed, edge-native, vector-ready, and — this is the important part — allegedly a drop-in replacement for the boring relational database you already have.
We read the docs. We spin up a trial project. And more often than not, we come back to Postgres. Not because we're stubborn, and not because we can't learn something new. But because after six years of shipping production software, we've noticed a pattern in which projects go smoothly and which turn into weekend calls. The boring database keeps showing up on the smooth side of that ledger.
What "default" actually means
A default isn't a religion. It's a starting position you deviate from when there's evidence. The question we ask on every new project is not "is Postgres the best possible database for this workload?" — it's "do we have a concrete reason not to start with Postgres?" Most of the time, the answer is no, and we save ourselves a lot of decisions.
The best time to pick your database is when you can undo it cheaply. The second best time is when you have real data about the workload.
Starting with Postgres buys us both. If we're wrong, we can migrate off it later with plenty of runway — Postgres exports cleanly, and most of the industry knows how to read a Postgres dump. If we're right, we've saved ourselves a distraction.
What Postgres actually gives you
Postgres has quietly grown into a database that covers most of the interesting cases a growing product will hit in its first three years. A quick, incomplete list of things we've used in production without reaching for a second system:
- Full-text search.
tsvectorandtsqueryhandle a surprising amount of what most teams reach for Elasticsearch to do. We only add a search cluster when the ranking requirements or index size genuinely need it. - JSON documents.
jsonbgives us a real document store with a real query language, indexed and transactional. It has replaced Mongo on more than one project. - Queues.
SELECT ... FOR UPDATE SKIP LOCKEDgives you a well-behaved job queue at surprising scale. Sidekiq-shaped needs are often met by a Postgres table and a worker loop. - Time-series data. With TimescaleDB or well-designed partitions, we've handled tens of millions of events without a separate telemetry store.
- Vector search.
pgvectoris now good enough that we've stopped reaching for a dedicated vector database on projects with under a few million embeddings.
None of these are the fastest possible implementation of the thing. They are, however, "one system to back up, one system to monitor, one system to hire for" implementations. That matters enormously for a small team.
An example from a real project
Last year we were rewriting a workflow engine for a compliance product. The team we inherited it from had, over eighteen months, added: a Postgres for user data, a Mongo for workflow definitions, an Elasticsearch for search, a Redis for job state, and a Kafka for event streaming. The system was 40k lines of glue code holding these together.
We spent our first two weeks doing what we always do — measuring the actual traffic. The findings, in short:
Workflow definitions: ~12,000 total, updated a few times a day
Search queries: ~40 rps peak, mostly by exact ID or user
Job throughput: ~150 jobs/sec, mostly under 200ms each
Event streaming: ~90% within a single service, one consumer
None of those numbers required the systems that had been chosen. The rewrite collapsed the storage layer to Postgres plus a Redis cache, dropped 60% of the codebase, and cut infrastructure spend by roughly a third. Latencies improved because most requests were no longer doing a network hop between three services.
Was there anything Postgres couldn't have done? Sure — if the search traffic had been ten times higher, or if the event streaming had crossed service boundaries in a real way, we'd have kept those pieces. But there wasn't, and it didn't.
When we don't reach for Postgres
To be clear: Postgres is not the answer to every question. We've picked other things when the shape of the work called for it.
- Analytics at real scale. If you're doing OLAP over hundreds of millions of rows with ad-hoc queries, ClickHouse or a warehouse is a better tool.
- Global low-latency reads. If your users are truly distributed and you need single-digit-ms reads everywhere, something replication-first like CockroachDB or a DynamoDB deserves a serious look.
- Very high write throughput on unstructured events. A log-oriented store is often the right answer.
What we try to avoid is picking those systems speculatively. If we don't have a workload that demonstrates the need, we're inventing operational complexity we'll have to feed for years.
The unfashionable takeaway
Boring is a feature. A boring default frees your team's attention for the parts of the product that aren't boring — the parts that are actually your competitive edge. Nobody is going to switch to your app because you rewrote the auth service in Rust.
So: default to the thing that works, is well documented, and has a maintainer who will still be around in five years. Deviate when you have evidence. And keep a list of what you deviated on and why — future you will want to know.
Filed under: engineering, defaults, databases · ← Back to journal