Every new project we take on starts on Postgres with practically the same opening setup. Over the years it's settled on one thing we always switch on first — and three things we used to do religiously and now deliberately don't. This is a short note on both sides of that equation.
We're writing this from web application development, where Postgres is almost always the default database. It isn't a how-to. It's a list of decisions we reversed after the old ones cost us an evening once or twice.
The change we always make: pg_stat_statements from day one
Before we write the first production query, we enable the `pg_stat_statements` extension. It ships with Postgres, costs a fraction of a percent in performance, and from day one tells you exactly which query has consumed the most time — aggregated, not from one random EXPLAIN. Once a week we look at it: the top 10 queries by total time, not by the single slowest run.
It sounds boring. It's also the only reason we almost never guess where the performance is going. Three years ago we'd only turn it on once something was already on fire. The difference between 'switch it on and look' and 'forensic analysis under pressure at three in the morning' is exactly the difference between monitoring that's running and monitoring you're writing during the outage.
Thing #1 we stopped: indexing every foreign key by reflex
We used to add an index to every foreign key automatically. It felt like diligence. In reality, on write-heavy tables it added overhead to every INSERT and UPDATE, and half of those indexes were never used for reads. Now we add an index only when `pg_stat_statements` shows us a slow JOIN or filter that genuinely needs one. On one project we dropped nine of nineteen indexes that way and sped writes up by roughly 15%, without a single slower read.
Thing #2 we stopped: defaulting to random UUID primary keys
For years we reached for `uuid` v4 as the primary key by default — it looked modern and safe against ID guessing. But random UUIDs scatter writes across the whole index, so the B-tree fragments and INSERTs on a large table slow down. On one table with tens of millions of rows, moving from `uuid` v4 to a `bigint` generated identity sped bulk imports up by over 30% and shrank the index. When we genuinely need an unguessable ID in a URL, we use UUID v7 (time-ordered) or a separate public identifier — not random v4 as the primary key.
Thing #3 we stopped: putting everything in JSONB 'for flexibility'
JSONB is excellent — for things that are genuinely irregular. Our mistake was throwing clearly-structured fields into it too, 'so we wouldn't have to change it later'. A year later we were filtering and sorting on those fields, and every such query needed either a GIN index with awkward syntax or a sequential scan of the whole table. The rule we kept: if we'll filter, sort or join on a field, it's a column. JSONB is for the rest — the stuff we only store and read back as a whole.
A lot of 'standard' database decisions are just sediment — we make them because we made them last time. We admitted the same thing about Redis. The difference is that with Postgres, one file teaches us better: pg_stat_statements.
None of these decisions is dogma. There are projects where a random UUID makes sense, and tables where JSONB is exactly right. But the default flipped over the years: instead of 'add it to be safe', it's now 'add it when the data tells you to'. If you're building an app on Postgres and want a second pair of eyes on the schema before it grows to millions of rows, get in touch — half a day on the schema now saves a week of migrations later.