Hook: Deadlock investigation is like finding two shoppers stuck in a doorway, each holding the other’s arm — interviewers love it because it tests whether you can debug a real production outage, not just write a query.
Question: How do you investigate a deadlock in SQL?
Answer: A deadlock happens when two transactions each hold a lock the other needs, so neither can move. To investigate it, I first confirm the victim error, then inspect the database’s lock and activity views or deadlock logs to rebuild the wait chain and identify the exact statements and resources involved. After that, I look for the root cause: inconsistent lock order, long transactions, missing indexes, or hidden locks from foreign keys or triggers.
Interview-Ready Answer: I’d start by confirming the deadlock error code and then reconstructing the wait chain from lock and activity metadata. In PostgreSQL, for example, I’d use pg_stat_activity, pg_locks, and pg_blocking_pids() to find the blocked session, the blocker, and the exact statements holding the locks. Once I see the cycle, I’d fix the code so every path takes locks in the same order, keep the transaction shorter, and add retry logic because one transaction will always be rolled back to break the deadlock.
Detailed Explanation: A deadlock is a circular wait. Transaction A holds Lock 1 and needs Lock 2, while Transaction B holds Lock 2 and needs Lock 1. The database cannot let either continue, so the lock manager detects the cycle in the wait-for graph (a graph that shows which session is waiting on which other session) and aborts one transaction, called the victim, to break the cycle.
In PostgreSQL, deadlock checking happens after deadlock_timeout (default about 1 second). In SQL Server, the lock monitor checks for deadlocks and the engine reports error 1205; in PostgreSQL it is usually 40P01. The key interview point is that the database is not randomly failing — it is protecting forward progress by killing one participant.
pg_blocking_pids(pid) tells you who is blocking that session. This is usually the fastest way to start.pg_locks for row locks, table locks, transaction ID locks, and whether a lock is granted or still waiting. Row locks often show as tuple or transaction waits; table-level locks show on the relation itself. The important question is not which query was slow, but which resource was contested.accounts then transfers, while another updates transfers then accounts. That mismatch is the real bug.UPDATE may also read parent rows to verify constraints, which can expand the lock footprint and create an unexpected cycle.| Problem | What happens | Typical clue | What to do |
|---|---|---|---|
| Deadlock | Circular wait | Error code like 40P01 or 1205 | Find cycle, fix lock order |
| Lock wait | One session waits | Slow query, no cycle | Find blocker, shorten tx |
| Lock timeout | Wait exceeds limit | Timeout error, no cycle needed | Tune timeout, reduce contention |
Deadlocks usually show up under load, not in unit tests, because concurrent requests interleave in unlucky ways. The cost of detection is small — roughly proportional to the size of the wait graph, which is usually tiny compared with the cost of rolling back a transaction — but the business cost can be large because the victim loses all work in that transaction and may retry. That is why teams often see a spike in latency or checkout failures before they notice the deadlock log line.
The safest fix is consistent lock ordering: every code path locks rows in the same order, such as ascending primary key. Other helpful fixes are shorter transactions, better indexes so the database touches fewer rows, and batching work so you do not hold locks while calling external services. If you are allowed to change behavior, FOR UPDATE SKIP LOCKED or NOWAIT can reduce waiting, but they change semantics, so use them only when your application can handle skipped rows or immediate failure.
Memory to keep: think same doors, same order. If every transaction grabs locks in the same order, there is no circle, so there is no deadlock.
Real-World Story: Imagine a checkout service for an online store. One transaction reserves inventory, another writes the order record, and a third updates loyalty points. Under peak traffic, two requests hit the same product at the same time: request A locks the orders row first, then wants inventory; request B locks inventory first, then wants orders.
At low traffic, you never notice. At 8 p.m. during a flash sale, the database starts aborting one side with deadlock errors, and the app shows intermittent failed checkouts even though the database is healthy. Logs might show deadlock detected, SQLSTATE 40P01, or 1205, and metrics will show a small spike in retry counts and p95 latency.
The bug is usually not the database is broken. It is that one code path updated tables in a different order than another path, or a trigger or foreign-key check quietly added another lock. The fix is to standardize the lock order, keep the transaction small, and retry the aborted request safely so the customer almost never sees the failure.
-- PostgreSQL demo: a safe, runnable deadlock-investigation setup.
-- It creates sample tables, a blocking-chain view, and a short ordered-locking transaction.
DROP VIEW IF EXISTS deadlock_wait_chain;
DROP TABLE IF EXISTS inventory_items;
DROP TABLE IF EXISTS orders_demo;
CREATE TABLE orders_demo (
order_id integer PRIMARY KEY,
status text NOT NULL
);
CREATE TABLE inventory_items (
sku integer PRIMARY KEY,
quantity integer NOT NULL
);
INSERT INTO orders_demo(order_id, status) VALUES
(1, 'new'),
(2, 'new');
INSERT INTO inventory_items(sku, quantity) VALUES
(100, 10),
(200, 10);
-- This view is the first thing to check during an incident:
-- if it returns rows, you have live blocking and can see both sides immediately.
CREATE OR REPLACE VIEW deadlock_wait_chain AS
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocked.state AS blocked_state,
blocked.wait_event_type,
blocked.wait_event,
left(blocked.query, 120) AS blocked_query,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
blocking.state AS blocking_state,
left(blocking.query, 120) AS blocking_query
FROM pg_stat_activity AS blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS bp(blocking_pid) ON TRUE
JOIN pg_stat_activity AS blocking
ON blocking.pid = bp.blocking_pid
WHERE blocked.wait_event_type = 'Lock';
-- No blockers right now is a good sign; during an incident this becomes your starting point.
SELECT * FROM deadlock_wait_chain ORDER BY blocked_pid, blocking_pid;
-- Ordered locking: lock rows in the same sequence every time.
-- In real code, every code path should use the same order so the wait graph cannot form a cycle.
BEGIN;
SELECT order_id
FROM orders_demo
WHERE order_id IN (1, 2)
ORDER BY order_id
FOR UPDATE;
UPDATE orders_demo
SET status = 'processing'
WHERE order_id = 1;
UPDATE inventory_items
SET quantity = quantity - 1
WHERE sku = 100;
COMMIT;
-- If there is no current wait, this query still runs safely and simply returns the live lock picture.
-- During a deadlock incident, it helps you identify lock type, resource, and whether the lock was granted.
SELECT
a.pid,
a.state,
a.wait_event_type,
a.wait_event,
l.locktype,
l.mode,
l.granted,
l.relation::regclass AS relation_name,
l.page,
l.tuple,
l.transactionid,
left(a.query, 120) AS query_snippet
FROM pg_locks AS l
JOIN pg_stat_activity AS a
ON a.pid = l.pid
WHERE a.datname = current_database()
ORDER BY a.pid, l.granted DESC, l.locktype, l.relation, l.page, l.tuple;
-- Failure-path note:
-- If the wait-chain view is empty during an outage, the deadlock may already have been broken.
-- In that case, the decisive evidence is usually in the server log entry with SQLSTATE 40P01.
Follow-up & Tricky Questions:
pg_stat_activity, pg_locks, and pg_blocking_pids(), then I verify the server log for the deadlock report. In SQL Server, I would look at the deadlock graph from Extended Events and the 1205 error.lock_timeout and deadlock detection? A lock timeout is just a stopwatch: if you wait too long, the statement fails even if there is no cycle. Deadlock detection is cycle-based and exists to break circular waits quickly.FOR UPDATE always prevent deadlocks? No. It can even make deadlocks more visible because it forces earlier locking. It helps only when every code path takes the same locks in the same order.Common Mistakes:
Memory Hook: Two people, two doors, same order. If everyone grabs the same door first, nobody gets stuck in the hallway.
Cheat Sheet:
pg_stat_activity, pg_locks, and pg_blocking_pids() for live evidence.Practice Tasks: