Skip to content
experience

Blameless review: the 'quick' migration that blocked every write to our orders table

Bram Verhoeven@bram_on_a_bike

October 18, 2026 · 2 min read

Short incident review, because the lesson is more useful than the embarrassment.

What happened

A normal Tuesday deploy at 08:12 ran two migrations generated by our migration tool:

ALTER TABLE orders ADD COLUMN priority int NOT NULL DEFAULT 0;
CREATE INDEX orders_priority_idx ON orders (priority);

Both look harmless. Since Postgres 11, adding a column with a constant default does not rewrite the table, so the ALTER is instant. And it was, once it got its lock.

Timeline (UTC+2)

  • 08:12 The ALTER TABLE needs an ACCESS EXCLUSIVE lock. An analytics query had been reading orders for 11 minutes. The ALTER waits behind it.
  • 08:12 Every new query on orders, even plain SELECTs, queues behind the waiting ALTER. API requests pile up. 504s.
  • 08:14 The analytics query finishes. The ALTER runs in milliseconds. Queue drains.
  • 08:14 CREATE INDEX without CONCURRENTLY takes a lock that blocks writes while it builds the index over 90 million rows.
  • 08:18 Index done. Writes resume. Total: about 6 minutes of failed or very slow order creation.

What we changed

-- every migration now starts with this
SET lock_timeout = '3s';
SET statement_timeout = '15min';

ALTER TABLE orders ADD COLUMN priority int NOT NULL DEFAULT 0;

If the ALTER cannot get its lock in 3 seconds, it fails instead of building a queue behind it, and our deploy retries it with backoff a few times. A failed migration is annoying. A queue in front of the busiest table is an outage.

-- separate migration, run outside a transaction
CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_priority_idx ON orders (priority);

CONCURRENTLY builds the index without blocking writes. It is slower and it cannot run inside a transaction block. If it fails halfway, it leaves an invalid index behind, so we check after every deploy:

SELECT indexrelid::regclass AS index_name
FROM pg_index
WHERE NOT indisvalid;

We also added squawk, a linter for Postgres migrations, to CI. It flags CREATE INDEX without CONCURRENTLY, missing lock timeouts, and a few other classics.

Blameless part

The engineer did what the tool generated and what our review checklist allowed. The tool defaults and the checklist were wrong. Both are fixed. Nobody needs to feel bad, everybody needs to know about lock queues.

I wrote this on the train because it rained too hard even for me. Do not tell anyone.

Get the best of #select-star-anonymous weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

9

Sign in to join the conversation.

Sign in to comment
Sanne Bakker@sanne_sre1 rep in SELECT * Anonymous
lock_timeout on every migration. Should be the default in every tool. Good review, short enough that people will actually read it.
0 net votes
Bram Verhoeven@bram_on_a_bike#1 in SELECT * Anonymous
You told me to keep it short. I listened. Once.
2 net votes
Nurul Aisyah@nurul_sql#8 in SELECT * Anonymous
the pg_index NOT indisvalid query, confirm adding to our runbook. CONCURRENTLY failing halfway and leaving an invalid index that nobody notices for months lah
1 net votes
Alasdair Reid@onprem_or_die2 rep in SELECT * Anonymous
the cloud didnae save ye then. aye, postgres locks dont care what invoice you're on
6 net votes
Bram Verhoeven@bram_on_a_bike#1 in SELECT * Anonymous
This was on our own hardware. Your cupboard server would have done exactly the same. Postgres is fair to everyone.
2 net votes
Danny Cho@danny_cho_la1 rep in SELECT * Anonymous
The lock queue is the part nobody expects. The ALTER itself is instant, but waiting for it blocks everyone behind it. My dad's version: one customer asks to close the counter for a second, and the whole line out the door waits.
2 net votes
Lucía Ferrer@lucia_ferrer_dev1 rep in SELECT * Anonymous
I don't use Postgres at work yet but I read it to learn how a postmortem looks. 'The engineer did what the tool generated' is very kind, my old team always search the person to blame 😅
6 net votes
Bram Verhoeven@bram_on_a_bike#1 in SELECT * Anonymous
Then that team had the bigger incident. Keep reading these, you are learning the right thing.
2 net votes
Agnieszka Zielińska@agnieszka_z1 rep in SELECT * Anonymous
We use Flyway, it wraps migrations in a transaction, so CONCURRENTLY fails there. Had to put it in a separate migration with executeInTransaction=false in the script config.
4 net votes