The startup's Postgres survival guide

abelanger1 pts0 comments

Hatchet · The startup's Postgres survival guide

2026-07-22<br>The startup's Postgres survival guide

A guide to preventing Postgres from toppling over.

Alexander BelangerCo-FounderHatchet

Over the past half year or so, I’ve been writing an internal doc for our engineers trying to distill two years of Postgres battles into a somewhat cohesive document. While I love the Postgres manual, I find it’s hard to turn to when shit hits the fan because it’s just so darn comprehensive. I thought this might be useful for others and would appreciate feedback (or other tidbits that you’ve learned running Postgres in production).

Before starting Hatchet, while I was familiar with SQL, the extent of my knowledge was basically: if a query is slow, you need an index. That’s the starting point for this doc; I’m going to assume you’re familiar with SQL basics, rows, tables, and know roughly what an index is.

And if Claude is writing all of your queries, this might be a waste of time! I recommend supabase/agent-skills

A quick note on ORMs

This guide should still be useful, but you might need to translate some of these tips into your ORM of choice. Lots of optimizations as you scale just aren’t possible with ORMs unless you can break past the abstraction layer and write SQL. You can do this gracefully or non-gracefully; Prisma TypedSQL or equivalents look interesting for this. We use sqlc at Hatchet which gets us very similar behavior; highly recommend if you’re a Go stack.

Table of contents

The simple stuff: good reads, writes and schemas

Writing a good schema

Writing good read queries

Writing performant joins

Compound indexes and aligning ORDER BY to your indexes

Writing good write queries

Migrations

Connection management

Intermediate: the query planner, bulk updates, and autovacuum

Introducing the leakiest of abstractions, the query planner

Sometimes it just makes sense to seq scan

Writing lots of data

Default autovacuum settings can kill your database

Other types of bloat

Some advanced stuff

FOR UPDATE SKIP LOCKED

Partitioning

Tricks for large table migrations

The simple stuff: good reads, writes and schemas

Let’s start with the basics: queries and schemas at low volume.

Writing a good schema

After you’re deployed, schemas are by far the hardest to change moving forward, so it’s worth spending some time on them. I’d recommend building your schema iteratively: start with a rough approximation for your tables and primary keys, then write some queries on those tables based on your application needs. You can approximate this with some questions: Is this a high-read and/or high-write table? What are the most common filters on reads? Which columns am I updating the most?

If you want to be more formal about it, you can look into database normalization into 1NF/2NF/3NF, but I’ve found normal forms to sometimes be at odds with query efficiency and ease of use, which is critical when you’re moving fast—sometimes it’s just easier to dump data into a jsonb column.

My rules of thumb for schemas are:

Use identity columns (auto-incrementing integers, slightly more performant than bigserial) or built-in UUIDs for primary keys

Always use timestamptz

Always use primary keys

Use foreign keys with cascading deletes for low-volume tables, particularly where database consistency and correctness are important. Careful at higher volume.

Writing good read queries

Let’s start with SELECT queries. A useful—albeit slightly inaccurate—mental model for fast selects is: under the hood, Postgres is either going to find a single row in a table very quickly, or it’s going to read every single row in your table using something called a sequential scan 😞.

It’s going to find a single row very quickly when you filter by:

An explicit index

A unique constraint (just a special case of index)

A primary key (these are automatically indexed in Postgres)

Indexes by default use a btree implementation. It’s most helpful to think of indexes as just another table in Postgres, with data stored in a specific format which is optimized for lookups (more on this later). These trees are great because finding a single row happens in approximately log(n) time, where n is the number of rows in the table—in other words, really fast.

When Postgres can’t use an index, it’ll use something called a sequential scan, or seq scan. Seq scans are much slower than index lookups, but modern databases are so fast at loading rows into memory that you probably won’t even notice at first: seq scans on tables with less than 20k rows are pretty much instant.

Writing performant joins

For inner joins, there’s rarely an argument for not using primary keys as the inner join; it usually speaks to a schema design or normalization problem. Treat ON clauses with the same respect as a WHERE clause—the same principles apply. Use an index.

Compound indexes and aligning ORDER BY to your indexes

Often the first slow query in your application will be a list query...

postgres writing index queries table good

Related Articles