Database Deadlock Post-Mortem: How One Missing Index Froze $10M in Transactions
It was 11:43 PM on a Friday. The on-call phone rang. The payment pipeline was down — not slow, not degraded — down. Orders queuing, retries piling up, connection pool sitting at 100%, the database sweating. Somewhere in the stack, two transactions had grabbed each other by the throat and refused to let go. Classic deadlock. Except nothing about it felt classic when you’re the one staring at the logs.
[ incident_report ] — The Friday Night Crash
The system had been running fine for months. Then the Black Friday pre-sale hit — traffic spiked 4x — and what was a minor inefficiency in normal conditions became a cascading system failure in 40 minutes flat. The production incident response playbook said “restart the app servers.” We tried. Didn’t help. The problem wasn’t the app — it was deeper. Two concurrent transaction flows, both touching the orders and inventory tables, were acquiring locks in opposite order. That’s the oldest deadlock recipe in the book, and we’d been cooking it for six months without knowing.
-- pg_stat_activity snapshot at 23:47 — the crime scene
SELECT pid, state, wait_event_type, wait_event, query_start, query
FROM pg_stat_activity
WHERE state != 'idle'
AND wait_event_type = 'Lock'
ORDER BY query_start;
-- Result: 47 rows. All waiting. All blocked by each other.
Why 47 connections waiting on locks is a death sentence
When you see a wall of Lock wait events in pg_stat_activity, you’re not looking at a problem — you’re looking at the aftermath. The actual deadlock happened milliseconds earlier. PostgreSQL detected it, killed one of the transactions, but by then the connection pool was already choking. Each blocked query held a connection open. The pool hit its ceiling. New requests couldn’t get a connection. The app started timing out at the HTTP layer. Users saw 500s. That’s how a single lock contention event becomes a cascading system failure visible to every customer.
Unbounded Queue: Memory Death The system is green. All health checks pass. CPU is idling at 30%. Your on-call engineer is halfway through a coffee. Then the OOM killer wakes up, picks your most critical...
The Anatomy of Transaction Isolation
Here’s the part most developers skip in school and pay for in production: transaction isolation levels explained in theory sound clean. In practice, they’re a negotiation between correctness and concurrency, and the database is doing the negotiating without asking you. PostgreSQL defaults to READ COMMITTED — which means every statement in a transaction sees the freshest committed data at that moment, not at transaction start. That sounds safe. It also means two transactions can read the same row, both decide to update it, and race to acquire an exclusive lock — only one wins, the other waits. Multiply that by a spike in traffic and you’ve got your Friday.
-- Reproducing the lock conflict in a controlled environment
-- Session A:
BEGIN;
SELECT * FROM orders WHERE id = 9001 FOR UPDATE; -- acquires exclusive lock
-- Session B (concurrent):
BEGIN;
SELECT * FROM inventory WHERE product_id = 42 FOR UPDATE; -- acquires exclusive lock
UPDATE orders SET status = 'reserved' WHERE id = 9001; -- now waits for Session A
-- Meanwhile Session A does:
UPDATE inventory SET qty = qty - 1 WHERE product_id = 42; -- waits for Session B
-- PostgreSQL: "Deadlock detected." Kills one. The other limps forward.
Shared lock vs exclusive lock — the part that actually matters
A shared lock says “I’m reading this, don’t let anyone change it.” An exclusive lock says “I’m writing this, nobody touches it until I’m done.” The conflict happens not between two readers — they coexist fine — but the moment a write gets involved. The real trap here is SELECT FOR UPDATE: it looks like a read but grabs an exclusive lock immediately. Junior devs use it thinking it’s just “a safe select.” It’s not. It’s a declaration of intent to write, and the database treats it exactly that way. Understanding shared lock vs exclusive lock behavior isn’t academic — it’s the difference between a system that scales and one that implodes at 4x traffic.
Debugging the Connection Pool Exhaustion
While the DB was deadlocking, the application layer was quietly dying for a completely different reason — or so it looked. Connection pool exhaustion is the shadow problem that follows every slow-query disaster. The pool has, say, 20 connections. The queries are slow because of locks. Requests pile up waiting for a free connection. The pool queue fills. The queue times out. The app throws errors that look like network issues, config issues, anything but database lock contention. Slow query log analysis was the thing that finally connected the dots — 200ms queries that had ballooned to 14 seconds.
-- Checking connection pool saturation + average query time
SELECT count(*) AS total_connections,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle') AS idle,
count(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting_on_lock,
round(avg(extract(epoch FROM (now() - query_start)))::numeric, 2) AS avg_query_sec
FROM pg_stat_activity
WHERE backend_type = 'client backend';
Why the app layer lies about the real problem
When the pool is exhausted, the error your monitoring catches is usually a connection timeout — not a lock error. That’s the deception. The app says “can’t connect to DB” but the DB is technically accepting connections just fine; it’s your pool config that’s refusing new ones. So you look at network, at firewall, at DNS — anywhere but the actual culprit. The slow query log is almost always the honest witness here: if your p99 latency suddenly spikes while your p50 looks fine, something specific is being serialized. Probably a lock. Probably something you didn’t know was acquiring one.
High Concurrency Issues That Kill Systems Before You Notice Your monitoring dashboards look calm. Green metrics everywhere. P50 latency is a steady 200ms. Nothing suggests danger. The system feels safe—until it isn’t. A sudden traffic...
The Query Planner Betrayal: Why Indexes Matter
This is the part that really stings when you find it. The deadlock was bad. But why it lasted long enough to exhaust the pool — that’s on the query planner. PostgreSQL’s optimizer had decided, at some point in the past, that a sequential scan on the orders table was cheaper than using the index. Not because it was smarter than us. Because the table statistics were stale and it was working with outdated information. The index existed. The planner just chose not to use it. Index scan vs sequential scan — the choice that turns a 2ms lookup into a 14-second full table walk, holding locks the entire time.
-- The query that started it all, explained
EXPLAIN ANALYZE
SELECT o.id, o.status, i.qty
FROM orders o
JOIN inventory i ON i.product_id = o.product_id
WHERE o.status = 'pending'
AND o.created_at > now() - interval '1 hour';
-- Output (the painful line):
-- Seq Scan on orders (cost=0.00..48732.91 rows=189234 width=32)
-- (actual time=0.043..13847.221 rows=189234 loops=1)
SQL execution plan debugging — reading what the planner actually tells you
EXPLAIN ANALYZE is the closest thing to a lie detector the database gives you. The cost estimate is what the planner thought would happen; the actual time is what actually happened. When those two are wildly different — like “estimated 200ms, actual 14 seconds” — the planner was working from bad data. In our case, ANALYZE hadn’t run on the orders table since it had grown from 50K rows to 190K rows. The planner still thought it was a small table. So it skipped the B-Tree index and walked every row. Every row, locked for the duration, while 46 other connections politely waited their turn in a line that never moved.
Root Cause Analysis (RCA) and the 1-Line Fix
After four hours of forensics, the root cause analysis landed somewhere embarrassing: a composite index that should have been added six months ago, when the orders table crossed 100K rows, wasn’t. The ticket existed. It was in the backlog, priority “medium,” sitting under fourteen other things. Technical debt consequences are rarely dramatic on day one. They accumulate quietly and then explode on the worst possible night. The “fix” itself was one line. The investigation was four hours. That ratio is pretty standard for relational database performance bottlenecks that have been cooking for a while.
-- The fix. One line. Four hours to earn it.
CREATE INDEX CONCURRENTLY idx_orders_status_created
ON orders (status, created_at)
WHERE status = 'pending';
-- After index + ANALYZE:
-- Index Scan using idx_orders_status_created on orders
-- (actual time=0.081..1.243 rows=1891 loops=1)
-- Execution time: 1.8ms. Not 14 seconds. 1.8ms.
Why CONCURRENTLY matters and why the WHERE clause isn’t optional here
Creating an index without CONCURRENTLY locks the entire table for writes during build — in production, that’s trading one outage for another. The WHERE status = 'pending' turns it into a partial index: it only indexes the rows you actually query, which means it’s smaller, faster to build, and cheaper to maintain. The query planner picks it up immediately on the next planning cycle. Post-deploy, query time dropped from 14s to under 2ms. Connection pool freed up. Deadlock pressure disappeared because transactions were no longer holding locks long enough to conflict. The cascading failure unwound in the opposite order it had built up.
Code Audit for Software That Actually Works — Not Just Looks Good on Paper Most teams discover their codebase is a liability right when they need it to be an asset — during a funding...
Prevention Protocol: Monitoring and Guardrails
The technical fix is the easy part. The harder question is: why did this survive six months undetected? The answer is that the monitoring was watching the wrong things. CPU was fine. Memory was fine. The app response time looked acceptable at p50. Nobody had set up PostgreSQL lock monitoring tools that would have flagged the growing lock wait times weeks before the crash. pg_stat_activity and pg_locks were available the whole time. We just weren’t querying them on a schedule. That’s the real lesson — WAL write-ahead logging issues and lock contention don’t announce themselves until it’s too late unless you’re actively watching.
-- Monitoring query: lock waits over threshold (run every 60s)
SELECT relation::regclass AS table_name,
mode,
count(*) AS waiting_count,
max(extract(epoch FROM (now() - query_start))) AS max_wait_sec
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE NOT l.granted
GROUP BY relation, mode
HAVING max(extract(epoch FROM (now() - query_start))) > 5
ORDER BY max_wait_sec DESC;
What this query tells you before Friday night calls you first
Run this every minute, alert on anything over 5 seconds of lock wait, and you’ll catch the early signal — usually a single query that’s taking longer than expected and quietly blocking others behind it. The race condition detection happens here, upstream of the crisis, before the pool fills. Pair it with auto_explain logging slow queries with their actual plans, and you’ll rarely be surprised by a sequential scan in production again. The goal isn’t zero incidents — that’s fantasy. The goal is catching the smell of smoke before the building is on fire.
The system now runs with automated ANALYZE on high-write tables every 15 minutes, lock wait alerts at 5s threshold, and a standing rule: no table over 50K rows ships without a reviewed index strategy. Not because the rules feel good — because the alternative rang at 11:43 PM on a Friday and didn’t let go until 3 AM. That tends to change how you think about “medium priority” backlog tickets.
Written by: