diff --git a/lessons/09-concurrency/03-deadlocks/lesson.mdx b/lessons/09-concurrency/03-deadlocks/lesson.mdx
new file mode 100644
index 0000000..df7d047
--- /dev/null
+++ b/lessons/09-concurrency/03-deadlocks/lesson.mdx
@@ -0,0 +1,125 @@
+Locking lets one transaction wait for another to finish. A *deadlock* is what happens when two transactions wait for *each other* — a circular standoff neither can escape. This lesson shows how that arises, how Postgres breaks it, and the simple ordering discipline that stops it from happening at all.
+
+The seed is the Module 9 ledger: four `accounts` with a starting balance of 100 each.
+
+
+SELECT id, owner, balance FROM accounts ORDER BY id;
+
+
+## What a deadlock actually is
+
+Picture two money transfers running at the same moment. One moves money from Ada (id 1) to Sofia (id 4); the other moves money from Sofia to Ada. Each `UPDATE` takes a row lock, and each transaction grabs its two rows in a different order:
+
+```sql
+-- Session 1: transfer ada -> sofia
+BEGIN;
+UPDATE accounts SET balance = balance - 10 WHERE id = 1; -- locks row 1
+-- ... now wants row 4, but Session 2 holds it
+UPDATE accounts SET balance = balance + 10 WHERE id = 4; -- BLOCKS
+```
+
+```sql
+-- Session 2: transfer sofia -> ada
+BEGIN;
+UPDATE accounts SET balance = balance - 10 WHERE id = 4; -- locks row 4
+-- ... now wants row 1, but Session 1 holds it
+UPDATE accounts SET balance = balance + 10 WHERE id = 1; -- BLOCKS
+```
+
+Session 1 holds row 1 and waits for row 4. Session 2 holds row 4 and waits for row 1. Neither will ever release, because each is blocked on the other. That cycle is a deadlock. Note it has nothing to do with *how much* work each does — it is purely the *order* in which they reach for the two locks.
+
+## Postgres detects it and picks a victim
+
+Left alone, those two sessions would hang forever. Postgres doesn't allow that. Whenever a transaction waits on a lock for longer than `deadlock_timeout` (default 1 second), the engine pauses to check the wait graph for a cycle. If it finds one, it aborts one of the transactions — the *victim* — so the other can proceed:
+
+```
+ERROR: deadlock detected
+DETAIL: Process 12345 waits for ShareLock on transaction 678;
+ blocked by process 12346.
+ Process 12346 waits for ShareLock on transaction 679;
+ blocked by process 12345.
+HINT: See server log for details.
+```
+
+The victim's transaction is rolled back with `SQLSTATE 40P01` (`deadlock detected`). The survivor commits normally. So a deadlock is never *silent* data corruption — it is a loud, catchable error. Your job as the application is to **catch `40P01` and retry** the losing transaction, which will almost always succeed on the second attempt once the other side is done.
+
+The 1-second pause is deliberate: deadlock detection is comparatively expensive, so Postgres assumes most lock waits are brief and only goes hunting for a cycle once a wait looks genuinely stuck.
+
+## The fix: acquire locks in a consistent order
+
+A deadlock cycle can only form when two transactions take the *same* set of locks in *different* orders. Remove the disagreement and the cycle is impossible. The rule: **always lock rows in a consistent order** — for our ledger, lowest `id` first.
+
+Rewrite both transfers to touch the lower id before the higher id, regardless of which direction the money flows. Now the ada→sofia transfer and the sofia→ada transfer both grab row 1, then row 4. One of them gets row 1 and the other simply *waits* for it — a normal, brief wait, not a cycle. Whoever wins finishes and releases; the other then proceeds. No deadlock is even possible.
+
+Here is a full ada→sofia transfer that respects that order, done as one atomic transaction. It updates id 1 first, then id 4:
+
+
+BEGIN;
+UPDATE accounts SET balance = balance - 10 WHERE id = 1;
+UPDATE accounts SET balance = balance + 10 WHERE id = 4;
+COMMIT;
+
+
+Check the result — 10 has moved from Ada to Sofia, and the total is still 400:
+
+
+SELECT id, owner, balance FROM accounts ORDER BY id;
+
+
+If you want the ordering to be automatic no matter how the caller phrases the transfer, lock the two rows up front with `SELECT ... FOR UPDATE` and an explicit `ORDER BY id`, then apply the deltas. The `ORDER BY` guarantees the locks are taken low-id-first even when the "from" account has the higher id:
+
+
+BEGIN;
+SELECT id, balance FROM accounts WHERE id IN (1, 4) ORDER BY id FOR UPDATE;
+UPDATE accounts SET balance = balance + 10 WHERE id = 1;
+UPDATE accounts SET balance = balance - 10 WHERE id = 4;
+COMMIT;
+
+
+That transaction moves the 10 back. Keeping transactions short and touching rows in a predictable order is the whole discipline: fewer locks held for less time, always in the same sequence.
+
+## Advisory locks: an app-level mutex
+
+Sometimes the thing you need to serialize isn't a single row — it's a *critical section* keyed by some value (a user id, an account number, a batch name). Postgres offers **advisory locks**: locks on an arbitrary integer key that *you* decide the meaning of. They don't protect any row automatically; they're a cooperative mutex your code agrees to honor.
+
+The transaction-scoped variant, `pg_advisory_xact_lock(key)`, blocks until it holds the lock and releases automatically at commit or rollback — so you can't leak it. Take one keyed on an account before doing work, and any other transaction using the same key must wait its turn. Here we guard a read of Ada's balance behind the lock keyed on her id (the block leaves balances untouched):
+
+
+BEGIN;
+SELECT pg_advisory_xact_lock(1);
+SELECT owner, balance FROM accounts WHERE id = 1;
+COMMIT;
+
+
+Everything between taking the lock and the commit is a critical section: because everyone locks on the same key in the same way, only one transaction runs it at a time — a clean way to serialize work that spans several rows or tables, even work that no single-row lock would cover. (There is also session-scoped `pg_advisory_lock`, which you must release yourself with `pg_advisory_unlock`; prefer the `xact` form so a forgotten unlock can't strand a lock.) Even with advisory locks, order your keys consistently if you take more than one — the deadlock rule never stops applying.
+
+## Your turn
+
+Move 30 from Ada (id 1) to Sofia (id 4) as one atomic transaction, locking the two rows in id order so this transfer could never deadlock with a concurrent one. Update the lower id first. Try it before peeking — here's one way:
+
+
+BEGIN;
+UPDATE accounts SET balance = balance - 30 WHERE id = 1;
+UPDATE accounts SET balance = balance + 30 WHERE id = 4;
+COMMIT;
+
+
+Confirm the balances — Ada should be at 70 and Sofia at 130, with the ledger total unchanged at 400:
+
+
+SELECT owner, balance FROM accounts ORDER BY id;
+
+
+
+Run the transfer above so it locks id 1 before id 4. We'll confirm the final balances are ada=70, grace=100, linus=100, sofia=130 — the total is still 400, and the ordering makes the transfer deadlock-safe.
+
+
+## What you learned
+
+- A deadlock is a cycle of waits: transaction A holds lock 1 and wants lock 2 while transaction B holds lock 2 and wants lock 1 — neither can proceed.
+- Postgres detects the cycle after `deadlock_timeout` (default 1s) and aborts one transaction with `deadlock detected` (`SQLSTATE 40P01`); the survivor commits. The victim should catch the error and retry.
+- The reliable cure is a consistent lock order — always touch rows in the same sequence (e.g. lowest id first), so two transactions can never disagree and form a cycle.
+- `SELECT ... FOR UPDATE ... ORDER BY id` locks rows in a guaranteed order up front, making a transfer safe regardless of direction. Keep transactions short.
+- `pg_advisory_xact_lock(key)` is an app-level mutex on an arbitrary key that releases at commit — handy for serializing a critical section that spans more than one row.
+
+Up next: Module 10 — Expert & operations, starting with roles and privileges.
diff --git a/lessons/09-concurrency/03-deadlocks/lesson.yaml b/lessons/09-concurrency/03-deadlocks/lesson.yaml
new file mode 100644
index 0000000..692afb9
--- /dev/null
+++ b/lessons/09-concurrency/03-deadlocks/lesson.yaml
@@ -0,0 +1,23 @@
+title: Deadlocks
+summary: Why two transactions can freeze each other, how Postgres detects and breaks the tie, and the ordering discipline that prevents it.
+estimatedMinutes: 14
+tags:
+ - deadlocks
+ - locking
+ - advisory-locks
+ - concurrency
+authors:
+ - exekias
+seed: seed.sql
+checks:
+ - id: consistent-order-transfer
+ type: query-returns
+ description: Run a transfer that locks both rows in id order, leaving ada=70 and sofia=130 (the total is unchanged).
+ sql: SELECT owner, balance FROM accounts ORDER BY id
+ expect:
+ rowCount: 4
+ rows:
+ - [ada, 70]
+ - [grace, 100]
+ - [linus, 100]
+ - [sofia, 130]
diff --git a/lessons/09-concurrency/03-deadlocks/seed.sql b/lessons/09-concurrency/03-deadlocks/seed.sql
new file mode 100644
index 0000000..447fc6b
--- /dev/null
+++ b/lessons/09-concurrency/03-deadlocks/seed.sql
@@ -0,0 +1,16 @@
+-- Seed for "03-deadlocks": the same tiny bank ledger from the rest of Module 9.
+-- accounts holds four owners with starting balances, so we can reason about two
+-- concurrent transfers grabbing row locks in opposite orders (a deadlock) and
+-- then fix it by always locking rows in a consistent order (lowest id first).
+
+CREATE TABLE accounts (
+ id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ owner text NOT NULL UNIQUE,
+ balance int NOT NULL CHECK (balance >= 0)
+);
+
+INSERT INTO accounts (owner, balance) VALUES
+ ('ada', 100),
+ ('grace', 100),
+ ('linus', 100),
+ ('sofia', 100);
diff --git a/lessons/09-concurrency/module.yaml b/lessons/09-concurrency/module.yaml
new file mode 100644
index 0000000..114ec39
--- /dev/null
+++ b/lessons/09-concurrency/module.yaml
@@ -0,0 +1,3 @@
+title: Concurrency
+difficulty: advanced
+summary: How Postgres keeps concurrent transactions correct — MVCC, isolation levels, locking, and deadlocks.