Skip to content
lazy devs
5 min readLazy Devs

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.
  • jsonb columns for flexible metadata and webhook payloads.
  • Full text search for the in-app search box.
  • SELECT ... FOR UPDATE SKIP LOCKED as a job queue for emails and background work.
  • A timestamptz everywhere, 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. pgvector is 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 LOCKED trick 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.

Related service

API & Backend Engineering

Secure, well-documented APIs that scale.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com