Postmortem: the day Postgres took down production
How a single missing index, combined with a cron job running at peak, locked up the entire database for 47 minutes. Full timeline and lessons.
Postmortem: the day Postgres took down production
TL;DR: A missing index on a foreign key column, combined with a cron job running during peak hours, caused a 47-minute outage on a billing API.
Timeline (all times UTC)
- 14:00 — Cron job starts:
cleanup_old_invoices.sh - 14:02 — Customer reports slow checkout
- 14:05 — PagerDuty alert: API latency p99 > 5s
- 14:08 — On-call engineer (me) opens laptop
- 14:14 — Identify: Postgres CPU 100%, hundreds of
DELETEqueries waiting - 14:22 — Kill the cron job manually → queries drain in ~30s
- 14:25 — Service recovering
- 14:47 — Full recovery, replication caught up
Total impact: 47 minutes of degraded service, ~120 failed transactions.
Root cause
The cleanup script ran DELETE FROM invoices WHERE created_at < NOW() - INTERVAL '90 days'.
Two things went wrong:
created_athad no index — full table scan for every DELETE batch- Foreign key from
invoice_items.invoice_id— Postgres had to verify no referencing rows on every DELETE, also a full scan
So each DELETE became:
SCAN invoices (~3M rows) — no index
→ for each deleted row:
SCAN invoice_items (~50M rows) — no index on FK
With 10,000 rows to delete, that’s ~3×10⁹ row checks. At ~2 minutes per batch, the cron job would have run for 2+ hours, locking the whole table.
Fix
- Add the missing index:
CREATE INDEX CONCURRENTLY idx_invoices_created_at ON invoices (created_at); CREATE INDEX CONCURRENTLY idx_invoice_items_invoice_id ON invoice_items (invoice_id); - Move the cron job to off-peak (3 AM, not 2 PM)
- Add statement timeout:
SET statement_timeout = '5min'in postgresql.conf - Add a pre-check: if a long-running query already exists, skip the cleanup
Lessons
- Always index foreign keys. Postgres doesn’t do this for you.
- Cron jobs don’t belong at peak hours. Unless they require it.
- Statement timeouts save you. Without it, a bad query will run forever.
- Run a load test. This bug existed for 6 months — only showed up when the table grew past a threshold.
- Document your incident. So the next person doesn’t relearn it.
Action items
- Add missing indexes
- Move cron to off-peak
- Add statement_timeout
- Run EXPLAIN on all DELETE/UPDATE queries >10K rows
- Quarterly: review slow query log