Debugging a Slow Postgres Migration: What the Query Plan Told Us
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:
- Add the column as nullable, with no default.
- Backfill in batches of 10k with a short sleep between.
- 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.
Comments
Join the discussionRunning 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.
