Why we reach for Postgres first
Most projects do not need a fancy database, just one boring, reliable one that does everything well. Here is the honest case for Postgres.

Every new project comes with the same fork in the road: which database do we use? The temptation is to match a trendy tool to each shape of data, one store for documents, another for search, another for the queue. We have learned the lazy answer is usually the right one: start with Postgres, and only add a second system when Postgres genuinely cannot do the job. It almost never gets to that point.
The default that earns its keep
Postgres is the kind of boring that comes from thirty years of people abusing it in production and filing bugs. It is fully ACID, it has a real query planner, and it does not lose your data when a node hiccups. That alone rules out a surprising number of "modern" alternatives for anything that touches money, accounts, or state you cannot reconstruct.
But the reason we reach for it first is not just reliability. It is range. A single Postgres instance can comfortably act as your relational store, your document store, your full text search, your job queue, and your geospatial index. Each of those is a separate vendor in the typical architecture diagram. Collapsing them into one system means one backup strategy, one connection pool, one set of credentials, and one thing to monitor at 3am.
JSONB: the document store you already have
People reach for a document database when their data is genuinely unstructured, or when they think it is. Postgres has had a binary JSON type, jsonb, for over a decade, and it is indexable. You get schema flexibility where you want it and relational integrity where you need it, in the same table.
create table events (
id bigint generated always as identity primary key,
user_id bigint not null references users (id),
type text not null,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
-- GIN index makes containment queries fast
create index events_payload_idx on events using gin (payload);
-- "find every checkout event over 100 dollars"
select id, payload->>'currency' as currency
from events
where type = 'checkout'
and (payload->'amount')::numeric > 100
and payload @> '{"status": "completed"}';The @> containment operator hits the GIN index, so that query stays fast as the table grows. You keep user_id as a real foreign key, so a deleted user cannot leave orphaned events. That blend, strict where it matters and loose where it does not, is hard to get from a pure document database.
Search without a search cluster
The reflex for search is to stand up Elasticsearch or OpenSearch. For most apps that is a heavy second system to feed, sync, and keep alive. Postgres full text search covers the common cases: weighted ranking, stemming, and prefix matching, all transactional and always consistent with your source data.
alter table articles
add column search tsvector
generated always as (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) stored;
create index articles_search_idx on articles using gin (search);
select id, title, ts_rank(search, websearch_to_tsquery('english', $1)) as rank
from articles
where search @@ websearch_to_tsquery('english', $1)
order by rank desc
limit 20;The search column is a generated column, so it updates itself on every write. No background job, no sync lag, no moment where search returns a row that was already deleted. When you outgrow this (and you will know, because you will be fighting relevance tuning), reach for a real search engine then. Not on day one.
The lazy part is what you do not run
The real cost of a database is rarely the license or the hosting bill. It is the operational surface. Every extra system is another upgrade path, another security advisory to read, another failure mode, another thing a new engineer has to learn before they can ship. We treat each new piece of infrastructure as a standing tax on the whole team.
Picking Postgres first is a way of refusing to pay that tax until something forces our hand. A typical app we build might use:
- Tables and foreign keys for the core domain.
jsonbcolumns for flexible metadata and webhook payloads.- Full text search for the in-app search box.
SELECT ... FOR UPDATE SKIP LOCKEDas a job queue for emails and background work.- A
timestamptzeverywhere, because storing local time is a bug waiting to happen.
That is one piece of infrastructure doing five jobs. The alternative is Postgres plus a document database plus a search cluster plus a message broker, four systems that all have to be online for your app to work, and four sets of "how do we restore this from backup" runbooks.
A queue is just a table with locking
This pattern surprises people, so it is worth showing. You do not need a separate broker to process jobs reliably. SKIP LOCKED lets many workers pull from the same table without stepping on each other.
-- one worker grabbing the next available job
with next as (
select id
from jobs
where status = 'pending'
and run_at <= now()
order by run_at
for update skip locked
limit 1
)
update jobs j
set status = 'running', attempts = attempts + 1
from next
where j.id = next.id
returning j.*;Because the grab and the status update happen in one transaction, two workers can never claim the same job. If a worker crashes mid-job, the row stays running and a sweeper can reset stale jobs after a timeout. For the throughput most products actually see (thousands of jobs an hour, not millions a second), this is plenty, and it lives in the same backup as everything else.
When we do not reach for Postgres
Reaching for Postgres first is a default, not a religion. There are real cases where it is the wrong tool, and pretending otherwise is how you end up with a sad database.
We add a second system when:
- We need a true cache or ephemeral counters at high write rates. Redis exists for a reason, and hammering Postgres for rate limiting is rude to it.
- We are doing vector search over millions of embeddings with tight latency targets.
pgvectoris excellent and we use it happily into the hundreds of thousands of vectors, but at large scale a dedicated vector store can pull ahead. - We have genuine analytical workloads scanning billions of rows. That is a column store job (think ClickHouse or a warehouse), not an OLTP database job.
- We need millions of small messages per second with strict ordering guarantees. That is Kafka territory, and the
SKIP LOCKEDtrick will not stretch that far.
Notice the pattern: every one of those is a specific, measured limit, not a vague feeling that Postgres "won't scale." The decision to add infrastructure should come with a number attached. "Our p99 queue latency crossed 500ms under load" is a reason. "Mongo feels more modern" is not.
The takeaway
Start with one Postgres instance. Use jsonb for the loose data, full text search for the search box, and a locked table for the queue. Add a second system only when you have a concrete limit you can name, measure, and point to in a graph. You will ship faster, sleep better, and spend your energy on the product instead of on keeping four databases in sync.
If you want a second pair of eyes on a schema or a stack you are about to commit to, that is the sort of thing our API and backend engineering work is built around.

