Advertisement

Home/Coding & Tech Skills

Deadlocks in Databases Explained: 3 Patterns Every Developer Must Know

coding-tech-skills · Coding & Tech Skills

Advertisement

It was 2:47 AM, and my phone buzzed with a PagerDuty alert. The production database had been grinding to a near halt for the past four minutes. I logged into the MySQL console, ran SHOW ENGINE INNODB STATUS, and there it was—a deadlock. Two transactions, each holding a lock the other needed, locked in a silent standoff. My team's payment processing pipeline was stuck, and customers were seeing "try again later" errors. That night, I learned something that every developer should know early: deadlocks in databases aren't a bug—they're a design signal. Understanding them is like learning to read a car's warning lights; ignore them, and you'll end up on the side of the road. In this article, I'll break down the three deadlock patterns I've seen most often in production systems, so you can spot them before they cost you sleep.

Advertisement

What Is a Database Deadlock? The Wait-For Cycle You Can't Escape

A database deadlock is a situation where two or more transactions are each waiting for a resource locked by the other, creating a cycle that prevents any of them from proceeding. Think of it like two cars meeting nose-to-nose on a narrow road: neither can move forward until the other backs up, but neither is willing to. The database resolves this by killing one transaction—typically the one that has done the least work—so the other can finish. This is not just "slow queries"; it's a full stop. The transactions involved are stuck until the database steps in, and if your application doesn't handle the resulting rollback gracefully, you'll see failed requests and frustrated users.

To visualize this, imagine a wait-for graph: Transaction A holds lock on Row 1 and waits for lock on Row 2. Transaction B holds lock on Row 2 and waits for lock on Row 1. The graph shows a cycle: A → B → A. That's a deadlock. Simple, right? But in practice, deadlocks can be subtle. They can involve more than two transactions, range locks that seem unrelated, or even read operations that acquire shared locks. The key is to recognize the patterns that create these cycles.

Pattern 1: The Classic Cross-Lock (Two Resources, Two Transactions)

The most common deadlock pattern is the classic cross-lock, where two transactions each lock one resource and then try to lock the other's locked resource. Here's a realistic example from a typical e-commerce order system:

-- Transaction A (running in session 1)
START TRANSACTION;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 123;
-- Now holds lock on inventory row for product 123
UPDATE orders SET status = 'processing' WHERE order_id = 456;
-- Waits for lock on orders row 456 held by Transaction B
COMMIT;

-- Transaction B (running in session 2)
START TRANSACTION;
UPDATE orders SET status = 'processing' WHERE order_id = 456;
-- Now holds lock on orders row 456
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 123;
-- Waits for lock on inventory row 123 held by Transaction A
COMMIT;

In my own experience, this pattern often emerges when different parts of the codebase update tables in different orders. For example, a checkout service might always update inventory first, then orders, while a refund service updates orders first, then inventory. When both run concurrently on the same rows, you get a deadlock. The fix is simple but often overlooked: enforce a consistent lock order across all transactions. I usually recommend alphabetically by table name, or by primary key value. It's a discipline that pays off immediately.

Pattern 2: The Phantom Lock (Range Locks and Insert Conflicts)

This pattern is trickier because it doesn't involve two transactions fighting over the same two rows. Instead, it involves range locks—specifically gap locks or next-key locks used by InnoDB to prevent phantom reads under the REPEATABLE READ isolation level. Consider a booking system where you check for available time slots:

-- Transaction A
START TRANSACTION;
SELECT * FROM slots WHERE slot_time BETWEEN '2026-03-01 09:00' AND '2026-03-01 10:00' FOR UPDATE;
-- Acquires gap lock on the range, preventing any insert in that range
-- Now tries to insert a new slot for 09:30
INSERT INTO slots (slot_time, status) VALUES ('2026-03-01 09:30', 'available');
-- Waits because Transaction B holds a conflicting lock

-- Transaction B
START TRANSACTION;
SELECT * FROM slots WHERE slot_time BETWEEN '2026-03-01 09:30' AND '2026-03-01 10:30' FOR UPDATE;
-- Acquires gap lock on this overlapping range
-- Now tries to insert a slot for 09:45
INSERT INTO slots (slot_time, status) VALUES ('2026-03-01 09:45', 'available');
-- Waits because Transaction A holds a conflicting lock
-- Deadlock!

This happened to a colleague of mine at a healthcare booking platform. Two staff members tried to add overlapping appointment slots simultaneously, and the system froze. The deadlock arose because the range locks from the SELECT FOR UPDATE statements overlapped, creating a wait-for cycle that didn't involve the same rows. The solution? Use a lower isolation level like READ COMMITTED if phantom reads are acceptable, or reduce the transaction's scope so that the SELECT and INSERT are not in the same transaction. Another approach is to use explicit row-level locks rather than range locks—for example, by pre-inserting placeholder rows. This pattern taught me that deadlocks can hide in queries that seem purely read-only.

Pattern 3: The Cascading Update (Hot Row Contention)

The third pattern I've seen is the cascading update, where multiple transactions try to update overlapping sets of rows in a table, creating a dependency chain that loops back on itself. Imagine a social media platform where user posts are stored in a table, and each time someone comments, the comment count is incremented:

-- Transaction A: Comment on post 1 and post 2
UPDATE posts SET comment_count = comment_count + 1 WHERE id = 1;
UPDATE posts SET comment_count = comment_count + 1 WHERE id = 2;

-- Transaction B: Comment on post 2 and post 3
UPDATE posts SET comment_count = comment_count + 1 WHERE id = 2;
UPDATE posts SET comment_count = comment_count + 1 WHERE id = 3;

-- Transaction C: Comment on post 3 and post 1
UPDATE posts SET comment_count = comment_count + 1 WHERE id = 3;
UPDATE posts SET comment_count = comment_count + 1 WHERE id = 1;

If all three transactions run concurrently, you can get a deadlock: A locks 1, waits for 2 (locked by B); B locks 2, waits for 3 (locked by C); C locks 3, waits for 1 (locked by A). This is a three-way deadlock, and it's surprisingly common in batch update scenarios—like a banking system updating account balances in a loop, or a logging system that updates a summary table. The root cause is hot row contention: multiple transactions competing for the same rows without a consistent order.

In my own work on a payment reconciliation system, I encountered this exact pattern. The fix was to reorder the UPDATE statements by primary key value (ascending) across all transactions. That way, Transaction A would always update post 1 before post 2, and Transaction C would update post 1 before post 3, breaking the cycle. It's a small change but eliminated 90% of our deadlocks overnight.

How to Prevent and Handle Deadlocks (Without Giving Up on Transactions)

Deadlocks are a normal part of concurrent systems—you can't eliminate them entirely without sacrificing consistency. But you can reduce their frequency and handle them gracefully. Here are the strategies I've found most effective in production:

  • Consistent lock ordering: Always access tables and rows in the same order across all transactions. This breaks the circular wait condition. I often enforce this with a code review checklist.
  • Reduce transaction scope: Keep transactions as short as possible. The longer a transaction runs, the more locks it holds, increasing the chance of conflict. Move non-essential work outside the transaction.
  • Use lower isolation levels when safe: If your application can tolerate dirty reads or non-repeatable reads, consider READ COMMITTED instead of REPEATABLE READ or SERIALIZABLE. Fewer locks mean fewer deadlocks.
  • Implement retry logic: Your application should catch deadlock errors (MySQL error 1213, PostgreSQL error 40P01) and retry the transaction. Use exponential backoff to avoid hammering the database. This is not a fix for frequent deadlocks, but a safety net.
  • Monitor deadlocks: In MySQL, run SHOW ENGINE INNODB STATUS regularly and parse the "LATEST DETECTED DEADLOCK" section. In PostgreSQL, check the server logs or query pg_locks to see waiting locks. Set up alerts for deadlock frequency.

One counter-intuitive insight I've learned: trying to prevent all deadlocks can lead to over-engineering. A rare deadlock that your application retries in milliseconds is often better than a complex locking scheme that slows down every transaction. Focus on patterns that cause frequent or long-lasting deadlocks, and don't sweat the occasional one.

Conclusion: Your Next Steps to Tame Database Deadlocks

Deadlocks in databases are like potholes on a road—you can't remove them all, but you can learn to spot them and steer around the big ones. The three patterns we've covered—classic cross-lock, phantom lock, and cascading update—cover the vast majority of deadlocks I've seen in production. Your next step is to audit your own code: look for transactions that access multiple tables or rows, check if lock ordering is consistent, and scan your slow query logs for deadlock messages. If you find one, don't panic. Use the pattern diagnosis above to understand the cycle, then apply the prevention strategies. Share this article with your team—it's worth bookmarking before your next deployment. After all, the best time to fix a deadlock is before it wakes you up at 2:47 AM.