Skip to main content

Debugging a Slow Postgres Migration: What the Query Plan Told Us

Updated 2 min readMarkdown

We had a migration that should have taken four minutes. It took forty.

The first mistake was looking at CPU. The database was idle. The second mistake was looking at locks - plenty of them, but short. What actually mattered was in the query plan: a sequential scan on a table that had grown past the point where our index helped.

What we found

The migration added a column with a default and backfilled it in one statement. On a table with 40M rows that meant a full rewrite while holding a lock.

ALTER TABLE events ADD COLUMN source text DEFAULT 'legacy';

Postgres 11+ makes adding a column with a constant default cheap, but the backfill was ours to write, and we wrote it as a single UPDATE.

What worked

Three changes, in order of impact:

  1. Add the column as nullable, with no default.
  2. Backfill in batches of 10k with a short sleep between.
  3. Set the default afterwards, in a separate transaction.

The migration went from 40 minutes and a lock queue to 6 minutes with no visible stall.

The part I keep coming back to

The query plan gave us the answer in about ten seconds. We spent two hours on dashboards before we looked at it. Observability is not a substitute for reading the plan.

About the author

  • Backend developer focused on distributed systems and observability. I write about the trade-offs behind real production incidents - what broke, why, and what we changed. Currently exploring how LLM agents fit into developer tooling.

Running this on a managed Postgres is where the lock queue really starts to hurt.

The batched backfill is the part I always forget when I only test on small tables.

The batching detail is the part most write-ups skip.