Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
- 12Relational Databases and Data Modeling for System Design
- 13Database Indexes and Query Performance: How to Make Reads Fast
- 14Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions
- 15Database Replication: Primary-Replica, Read Replicas, Lag and Failover
- 16Database Sharding and Partitioning: How It Works and When You Need It
- 17SQL vs NoSQL: Data Models and How to Choose the Right Database
6 · Distributed Systems
- 18Why Distributed Systems Are Hard: Partial Failure, Timeouts and Clocks
- 19CAP Theorem and Consistency Models Explained Without the Myths
- 20Idempotency, Retries and Message Delivery Guarantees
- 21Distributed Transactions: Two-Phase Commit, the Outbox Pattern and Sagas
- 22Distributed Locks, Leases, Leader Election and Consensus
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
- 33Design a Notification System: Email, SMS and Push at Scale
- 34Design a Chat System Like a Messaging App
- 35Design a News Feed: Fan-Out, Ranking and the Celebrity Problem
- 36Design Search Autocomplete (Typeahead)
- 37Design a Distributed Job Scheduler
- 38Design a Ticket Booking System Without Overselling
- 39Design a Payment System: Idempotency, Ledgers and Reconciliation
11 · Large-Scale Case Studies
- 40Design a Video Streaming Platform: Upload, Transcoding and Adaptive Streaming
- 41Design a Ride-Hailing System: Location, Matching and Real-Time Updates
- 42Design a Food Delivery Platform: Orders, Restaurants and Delivery Partners
- 43Design an E-Commerce System: Catalog, Cart, Checkout, Inventory and Flash Sales
- 44Design a Web Search Engine: Crawling, Inverted Indexes and Ranking
12 · Interview Readiness
Lesson 14 · Databases
Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions
What goes wrong when many requests touch the same rows at once: lost updates, double booking, write skew, dirty and non-repeatable reads. Learn isolation levels, MVCC, atomic conditional updates, row locks, optimistic concurrency with versions, constraints, deadlocks and hot rows, with a runnable simulation and worked examples.
For
Interview candidates moving from SDE-1 to SDE-2 depth, and backend developers who need to stop overselling, double booking and lost updates under concurrent load.
Level
Before you start
Read Relational Databases and Data Modeling (article 12) for ACID and constraints, and Database Indexes (article 13). Article 04's section on If-Match and 412 is useful background.
On this page
Key takeaways
- Most data races are read-modify-write or check-then-act sequences split across separate statements; make the check and the change one atomic operation.
- Isolation levels trade anomalies for concurrency: know the default of your database, and which anomalies it still allows.
- Use a conditional update or a unique constraint first, optimistic version checks when conflicts are rare, and row locks when conflicts are common and the critical section is short.
- Deadlocks are normal under locking: acquire locks in a consistent order, keep transactions short, and retry.
- These tools work inside one database; contention on a single hot row and coordination across services need different designs.
A concert has one seat left. Two fans press "book" within the same millisecond. Your code does what looks perfectly reasonable: read the seat, see it is free, mark it booked. Both requests read "free" before either writes "booked". Both fans get a confirmation email. One of them arrives at the venue to find someone in their seat.
Nothing crashed. No error was logged. Each request, on its own, was correct. The bug exists only in the interleaving of two correct requests. That is a race condition, and it is the most common way systems that look fine in testing go wrong in production. This article shows the common races, the isolation levels databases offer, and the techniques that prevent races inside one database.
What you will learn
#- The common races: lost updates, check-then-act, write skew, and the read anomalies.
- What isolation levels mean, and how MVCC lets readers and writers coexist.
- Five techniques: atomic conditional updates, constraints, optimistic concurrency, pessimistic row locks, and serializable isolation with retries.
- Deadlocks: why they happen and how to avoid and handle them.
- Hot rows, long transactions, and the limits of single-database techniques.
- Worked examples: seat booking, likes, a wallet and a bank transfer.
The common races
#Lost update (read-modify-write)
#Time Request A Request B 1 read balance = 100 2 read balance = 100 3 write balance = 100 + 50 = 150 4 write balance = 100 - 30 = 70Result: 70. A's deposit of 50 is lost.Each request read a value, computed a new one in application code, and wrote it back. The second write overwrote the first.
Check-then-act
# 1 A: is seat 14 free? yes 2 B: is seat 14 free? yes 3 A: book seat 14 4 B: book seat 14 -> double bookingThe check and the action are separate steps, and the world changed in between. Username uniqueness checks, "is there stock?" checks and "has this payment been made?" checks all have this shape.
Write skew
#Two requests read the same data, each checks a rule, and each updates different rows, together breaking the rule. The classic example: a hospital requires at least one doctor on call. Two doctors are on call; each, at the same moment, checks "is someone else on call?" (yes), and takes themselves off. Neither update conflicts with the other row-wise, but now nobody is on call.
Read anomalies
#These are about what a transaction sees:
- Dirty read: seeing another transaction's uncommitted change, which might be rolled back.
- Non-repeatable read: reading the same row twice in one transaction and getting different values because another transaction committed in between.
- Phantom read: running the same query twice and getting a different set of rows because another transaction inserted or deleted matching rows.
Isolation levels
#Isolation (the I in ACID) controls how much concurrent transactions can see of each other. The SQL standard defines four levels; stronger levels prevent more anomalies but allow less concurrency or cause more retries.
Level | Dirty read | Non-repeatable read | Phantom read | Notes |
|---|---|---|---|---|
Read uncommitted | Possible in the standard | Possible | Possible | PostgreSQL treats it as Read Committed |
Read committed | Prevented | Possible | Possible | PostgreSQL's default |
Repeatable read | Prevented | Prevented | Allowed by the standard | PostgreSQL's implementation also prevents phantoms |
Serializable | Prevented | Prevented | Prevented | Results as if transactions ran one at a time |
PostgreSQL's documentation describes its behaviour precisely: Read Committed is the default; Read Uncommitted behaves like Read Committed; Repeatable Read in PostgreSQL also prevents phantom reads; Serializable additionally prevents serialization anomalies such as write skew, and may abort a transaction with a serialization failure that the application must retry (PostgreSQL: Transaction Isolation). Other databases differ in defaults and details (MySQL's InnoDB engine, for example, defaults to Repeatable Read), so always check the documentation of the database you are using.
The most important practical fact: the common default, Read Committed, does not prevent lost updates or check-then-act races when the read and the write are separate statements. You must use one of the techniques below.
MVCC: how readers and writers coexist
#Many databases, including PostgreSQL, use multiversion concurrency control (MVCC): a write creates a new version of a row rather than overwriting it in place, and each transaction reads a consistent snapshot of versions. As PostgreSQL's documentation puts it, reading never blocks writing and writing never blocks reading (PostgreSQL: MVCC introduction). That is why plain reads are fast under heavy write load. It is also why a snapshot read can be slightly behind reality, and why two writers still need a way to avoid stepping on each other.
Five ways to prevent races
#1. Atomic, conditional updates
#Move the check into the write, so the database performs both as one operation:
-- Decrement stock only if some is left. Check how many rows changed.UPDATE products SET stock = stock - 1 WHERE product_id = 9 AND stock > 0;-- 1 row updated: you got one. 0 rows: sold out.-- Book a seat only if it is still free.UPDATE seats SET status = 'booked', booking_id = 501WHERE event_id = 7 AND seat_no = 'C14' AND status = 'free';The database locks the row while it updates it, so two such statements on the same row run one after the other, and the second sees the first's result. No read-modify-write gap exists. This is the first tool to reach for: simple, fast, and correct at any isolation level for single-row changes.
Counters are the same idea: UPDATE photos SET like_count = like_count + 1 WHERE photo_id = ? never loses increments, whereas reading the count into the application and writing back the incremented value does.
2. Constraints
#Let the database reject the second, conflicting write:
CREATE TABLE bookings ( booking_id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, seat_no VARCHAR(5) NOT NULL, user_id BIGINT NOT NULL, UNIQUE (event_id, seat_no) -- a seat can be booked once);Two concurrent inserts for the same seat: one succeeds, the other fails with a unique-violation error, which the application turns into "sorry, that seat was just taken". Constraints are race-proof by construction, and they protect against bugs in every code path, not just the one you remembered. Article 20 uses the same idea for idempotency keys.
3. Optimistic concurrency control
#Assume conflicts are rare. Read the row with a version number, do your work, and write back only if the version has not changed:
SELECT stock, version FROM products WHERE product_id = 9; -- stock 1, version 7-- ... application logic ...UPDATE products SET stock = 0, version = 8WHERE product_id = 9 AND version = 7; -- 0 rows if someone else changed itIf zero rows changed, someone else won; re-read and retry (or give up). No locks are held while the application thinks, so it suits workflows where a user edits a record for minutes. HTTP has a matching mechanism: the server sends an ETag, the client sends If-Match with it on update, and the server responds 412 Precondition Failed if the resource changed (RFC 9110), as article 04 described.
4. Pessimistic locking
#Assume conflicts are likely. Lock the rows you are about to change, so others wait:
BEGIN;SELECT balance FROM accounts WHERE account_id = 1 FOR UPDATE; -- others trying to lock row 1 now wait-- ... check rules in application code ...UPDATE accounts SET balance = balance - 500 WHERE account_id = 1;COMMIT; -- lock releasedSELECT ... FOR UPDATE takes a row-level lock that blocks other transactions from updating or locking the same row until commit (PostgreSQL: Explicit Locking). It is the right tool when the rule involves logic that is awkward to express in one statement, conflicts are frequent, and the critical section is short.
Never hold a database lock while calling an external service (a payment provider, an email API). If the call takes 20 seconds, everyone else waits 20 seconds.
5. Serializable isolation with retries
#Run the transaction at Serializable isolation, and let the database detect conflicting interleavings. It aborts one of them with a serialization error; the application retries the whole transaction. This catches subtle cases like write skew without you designing a lock for each rule. The cost is more aborted transactions under contention, and every transaction must be safe to retry.
Choosing a technique
#Technique | Best when | Watch out for |
|---|---|---|
Atomic conditional update | The rule fits in one | Check the affected-row count |
Unique or other constraint | The rule is "at most one of these" | Turn the constraint error into a clear response |
Optimistic (version check) | Conflicts are rare; long think time; user edits | Retries under contention; starvation if one row is very hot |
Pessimistic ( | Conflicts are common; short critical section; multi-step logic | Deadlocks, lock waits, never hold during slow calls |
Serializable plus retry | Complex rules across several rows (write skew) | More aborts; retries must be safe |
Seeing the difference: a scripted race
#Real races depend on timing, which makes them hard to demonstrate. This Java program scripts the exact interleaving instead: two clients both read before either writes. It compares an unprotected read-modify-write, a conditional update and an optimistic version check. The methods mimic what the SQL statements above do inside the database.
// File: RaceDemo.javapublic class RaceDemo { static int stock; static int version; // Mimics: UPDATE items SET stock = stock - 1 WHERE id = ? AND stock > 0 static boolean conditionalDecrement() { if (stock > 0) { stock--; return true; } return false; } // Mimics: UPDATE items SET stock = ?, version = version + 1 WHERE id = ? AND version = ? static boolean optimisticWrite(int newStock, int expectedVersion) { if (version != expectedVersion) { return false; } stock = newStock; version++; return true; } public static void main(String[] args) { // 1. Unprotected read-modify-write: both read before either writes. stock = 1; int sold = 0; int seenByA = stock; int seenByB = stock; if (seenByA > 0) { stock = seenByA - 1; sold++; } if (seenByB > 0) { stock = seenByB - 1; sold++; } System.out.println("Unprotected: sold " + sold + " of 1, stock shows " + stock); // 2. Conditional update: the check and the change are one atomic statement. stock = 1; sold = 0; for (String client : new String[] {"A", "B"}) { boolean ok = conditionalDecrement(); if (ok) { sold++; } System.out.println("Conditional: client " + client + (ok ? " bought" : " updated 0 rows, sold out")); } System.out.println("Conditional: sold " + sold + " of 1, stock shows " + stock); // 3. Optimistic: both read version 7; only the first write with version 7 succeeds. stock = 1; version = 7; int aStock = stock, aVersion = version; int bStock = stock, bVersion = version; System.out.println("Optimistic: A write " + (optimisticWrite(aStock - 1, aVersion) ? "succeeded" : "failed")); System.out.println("Optimistic: B write " + (optimisticWrite(bStock - 1, bVersion) ? "succeeded" : "failed, version changed")); System.out.println("Optimistic: B re-reads stock " + stock + " and reports sold out; version is now " + version); }}Unprotected: sold 2 of 1, stock shows 0Conditional: client A boughtConditional: client B updated 0 rows, sold outConditional: sold 1 of 1, stock shows 0Optimistic: A write succeededOptimistic: B write failed, version changedOptimistic: B re-reads stock 0 and reports sold out; version is now 8Look at the first line. Both clients read a stock of 1, both passed the check, and two units were sold from a stock of one. Yet the stock column shows 0, so the inventory record looks perfectly healthy. Lost updates are dangerous precisely because the data looks valid afterwards. The conditional update sells exactly one; the optimistic version detects the conflict and B backs off.
Deadlocks
#With locks, two transactions can wait for each other forever:
Transaction A: locks account 1 ... then wants account 2Transaction B: locks account 2 ... then wants account 1-> each waits for the other: deadlockDatabases detect deadlocks and abort one transaction (PostgreSQL's documentation describes this behaviour (PostgreSQL: Explicit Locking)). Your job:
- Lock in a consistent order. For a transfer, always lock the lower account id first. Then two transfers between the same accounts lock in the same order and cannot deadlock.
- Keep transactions short and touch as few rows as possible.
- Retry the aborted transaction (it must be safe to retry).
Hot rows and long transactions
#Hot rows
#All of these techniques serialise access to one row. If one row is very hot (the stock count of a flash-sale product, a global counter, a celebrity's like count), every request queues on it. Throughput is limited by how fast that single row can be locked, changed and committed, often a few thousand per second at best on good hardware, and latency spikes as requests wait.
Designs for hot rows:
- Split the counter into N rows (
stock_bucket0 to 9) and pick one at random to decrement; sum them to read. Contention drops by about N times. - Pre-allocate: hand out blocks of inventory to servers or regions in advance.
- Queue the requests for the hot item and process them in order with a single consumer (article 43's flash sale).
- Count asynchronously for things that need not be exact in real time (likes, views).
Long transactions
#A transaction that stays open for seconds or minutes holds locks, blocks others, and in MVCC databases keeps old row versions alive (which hurts cleanup). Keep external calls, user think time and batch processing outside database transactions. For long user edits, use optimistic concurrency, not open transactions.
Worked example 1: booking a seat
#Requirements: no seat is ever booked twice, many users may try the same popular seats at once.
CREATE TABLE seats ( event_id BIGINT NOT NULL, seat_no VARCHAR(5) NOT NULL, status VARCHAR(10) NOT NULL DEFAULT 'free', -- free, held, booked hold_until TIMESTAMPTZ, holder_id BIGINT, PRIMARY KEY (event_id, seat_no));-- Hold a seat for 10 minutes, only if it is free or its hold has expired.UPDATE seatsSET status = 'held', holder_id = :user, hold_until = now() + interval '10 minutes'WHERE event_id = :event AND seat_no = :seat AND (status = 'free' OR (status = 'held' AND hold_until < now()));One conditional update per seat: exactly one user gets each hold. Confirming the booking after payment is another conditional update (WHERE status = 'held' AND holder_id = :user AND hold_until > now()). Article 38 builds the full system, including what happens when payment succeeds after the hold expired.
Worked example 2: a wallet and a transfer
#Wallet debit (one row): UPDATE wallets SET balance = balance - :amt WHERE user_id = :u AND balance >= :amt, then check one row changed. No lock needed beyond the update itself.
Transfer between two wallets (two rows, both must change):
BEGIN;-- Lock both rows in a consistent order (lower id first) to avoid deadlocks.SELECT user_id, balance FROM wallets WHERE user_id IN (:a, :b) ORDER BY user_id FOR UPDATE;UPDATE wallets SET balance = balance - :amt WHERE user_id = :from AND balance >= :amt; -- check 1 rowUPDATE wallets SET balance = balance + :amt WHERE user_id = :to;INSERT INTO transfers (transfer_id, from_id, to_id, amount) VALUES (:id, :from, :to, :amt);COMMIT;The transfers row has a primary key supplied by the client's idempotency key, so a retried request fails the insert instead of moving money twice (article 20). Real payment systems record money movements as an append-only ledger instead of mutating balances directly; article 39 explains why.
Beyond one database
#Every technique here relies on one database coordinating access to its own rows. When the data involved lives in different databases or services (stock in the inventory service, money in the payment service), there is no single place to lock or to run one transaction. Those cases need idempotency (article 20), sagas or the outbox pattern (article 21), and sometimes distributed locks with fencing (article 22). A useful design instinct is to keep data that must change together in one database whenever you can.
Failure modes
#Failure | Effect | Mitigation |
|---|---|---|
Unprotected read-modify-write | Lost updates, overselling; data looks valid | Conditional updates, versions, locks |
Check-then-act across statements | Duplicates, double booking | Constraints, conditional updates |
Deadlock | One transaction aborted | Consistent lock order; retry |
Lock held during a slow call | Requests pile up; timeouts | Never call external services inside a transaction |
Hot row | Throughput cap, latency spikes | Split counters, queue, pre-allocate |
Retried transaction not idempotent | Double effects after an abort or timeout | Idempotency keys and constraints |
Trade-offs
#Decision | You gain | You pay |
|---|---|---|
Stronger isolation | Fewer anomalies to reason about | More blocking or aborts; lower throughput |
Pessimistic locking | Predictable behaviour under contention | Waiting, deadlocks |
Optimistic concurrency | No locks held; good for rare conflicts | Retries and wasted work under contention |
Constraints in the database | Correct under any concurrency | Must handle constraint errors gracefully |
Split counters | Removes a hot row | Reads must sum; exactness checks are harder |
Common mistakes
#- Reading a value into application code, changing it and writing it back.
- Checking existence and then inserting, without a unique constraint.
- Assuming the default isolation level prevents lost updates.
- Holding transactions open across network calls or user think time.
- Locking rows in different orders in different code paths.
- Retrying aborted transactions whose side effects are not idempotent.
In an interview
#- When two users can act on the same thing (a seat, a coupon, stock, a username, a balance), say "this is a race" and name your protection: conditional update, unique constraint, version check or row lock.
- State the database's default isolation level and that you are not relying on it to prevent lost updates.
- For very hot items, say how you avoid a single-row bottleneck.
- If the data spans services, say that local transactions are not enough and point to sagas or idempotency.
Exercises
#Exercise 1
Name the anomaly
#Name the race or anomaly, and the simplest fix:
- Two support agents open the same ticket, one changes the priority and the other the assignee, and the second save silently reverts the priority.
- Two signups for the username "asha" both succeed.
- A report reads the total of all orders, then the list of orders, and the list does not add up to the total because orders arrived in between.
- Two managers each approve a leave request after checking "at least 2 people remain in the team this week", and the team ends up with 1 person.
Exercise 2: Fix the wallet
#This code runs on 20 servers:
balance = SELECT balance FROM wallets WHERE user_id = ?if balance >= amount: UPDATE wallets SET balance = (balance - amount) WHERE user_id = ? record purchaseelse: rejectExplain how it fails and rewrite it.
Exercise 3: The deadlock
#Transfers between accounts sometimes fail with "deadlock detected". The code locks the sender's row first, then the receiver's. Explain what happens when Asha sends money to Ravi at the same moment Ravi sends money to Asha, and fix it.
Exercise 4: Optimistic or pessimistic?
#Choose and justify:
- Editing a wiki page, where two people rarely edit the same page and each edit takes minutes.
- Decrementing stock for a product during a sale where hundreds of buyers hit it per second.
- Updating a user's profile settings.
- Moving a delivery order through states (assigned, picked up, delivered), where the rider app and the restaurant app may both update it.
Show solutionHide solution
- Lost update at the record level: each agent saved the whole ticket as they had read it. Fix with optimistic concurrency (a version column, or
ETagandIf-Match): the second save fails with a conflict and the agent reloads. Alternatively, update only the changed fields. - Check-then-act. Fix with a
UNIQUEconstraint onusername(case-normalised), and turn the violation into "name taken". - Non-repeatable read or phantom within the report. Fix by running both queries in one transaction at Repeatable Read (or higher), so they see the same snapshot, or compute the total from the list itself.
- Write skew. Each approval changed a different row, so row locks on those rows do not help. Fix with Serializable isolation and retry, or by locking a shared row (for example the team-week row) with
FOR UPDATEbefore checking, or by modelling the capacity as a counter updated with a conditional update.
Exercise 2
Show solutionHide solution
Two purchases can both read a balance of 100, both pass balance >= amount for an amount of 80, and both run the update. Depending on how the update is written, the balance ends at 20 (lost update: 160 spent from 100) or -60. Either way, the user spent money they did not have.
Fix with a single conditional statement:
UPDATE wallets SET balance = balance - :amountWHERE user_id = :user AND balance >= :amount;-- if 1 row updated: record the purchase; if 0 rows: reject (insufficient balance)Record the purchase in the same transaction, keyed by an idempotency key, so a retried request cannot debit twice. The update is atomic per row, so it is correct on any number of servers.
Exercise 3
Show solutionHide solution
Asha's transfer locks Asha's row, then waits for Ravi's. Ravi's transfer has locked Ravi's row and waits for Asha's. Each waits for the other forever; the database detects the cycle and aborts one transaction with a deadlock error.
Fix: lock both rows in a consistent order regardless of direction, for example by ascending account id (SELECT ... WHERE id IN (:a, :b) ORDER BY id FOR UPDATE). Both transfers then try to lock the lower id first; one gets it and proceeds, the other waits briefly, and no cycle can form. Keep the transaction short, and retry on the rare deadlock that may still occur from other code paths.
Exercise 4
Show solutionHide solution
- Optimistic. Conflicts are rare and edits take minutes; holding locks that long would block others. On conflict, show both versions or ask the user to merge.
- Neither as stated; use an atomic conditional update (
stock = stock - 1 WHERE stock > 0), and if one row is still too hot, split stock into buckets or queue purchase attempts (article 43). Optimistic retries would thrash under hundreds of conflicts per second. - Optimistic, or simply last-write-wins on independent fields. One user edits their own settings; conflicts are rare.
- Conditional state transitions:
UPDATE orders SET status = 'picked_up' WHERE order_id = ? AND status = 'assigned'. This is optimistic in spirit, using the current state as the "version", and it encodes the allowed transitions, so an out-of-order or duplicate update simply changes 0 rows.
Where to go next
#- Next: Database Replication: Primary-Replica, Read Replicas, Lag and Failover, where copies of these rows live on other machines and can lag behind.
- Later: idempotency (article 20), distributed transactions (article 21), ticket booking (article 38) and payments (article 39) build directly on these techniques.
Sources & further reading
- Technology
- Software Engineering
- #consistency
- #databases
- #distributed systems
- #roadmap
- #scalability
- #system design
- #system design interview
- #transactions
Connected lessons
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
SQL and PostgreSQL Fundamentals for Java Developers
Guide · Technology
Start Here: Your Roadmap from Zero to Job-Ready DSA
Guide · Career
A LinkedIn Profile That Gets Recruiter Messages

