Warming up the neural circuits...
By the end of this chapter you will:
A user transfers ₹10,000 from savings to checking. The money leaves the savings account. The server crashes. The money never arrives in checking. The user just lost ₹10,000. Transactions exist so this never happens.
Transactions are the "all or nothing" guarantee of database systems. They're the reason you can trust a database with money, medical records, and mission-critical data. Without transactions, every operation is a gamble.
Imagine moving gold bars from Vault A to Vault B:
If you trip at step 2, the bars are on the floor — not in A, not in B. A is like having an atomic teleporter: either all bars arrive in Vault B, or they never left Vault A. There is no in-between that anyone can see.
Every relational database promises ACID. Knowing what each letter actually means in practice is the difference between debugging a data corruption bug for 2 hours and for 2 weeks.
| Letter | Meaning | What it guarantees | Without it |
|---|---|---|---|
| Atomicity | All or nothing | If any part of a transaction fails, every part is rolled back. No partial writes. | Money disappears mid-transfer. |
| Consistency | Valid state transitions | A transaction transforms the DB from one valid state to another. Constraints (FKs, CHECK, UNIQUE) are never violated — even momentarily. | Orphaned rows, negative inventory, duplicate unique values. |
| Isolation | Concurrent transactions don't interfere | Each transaction sees the database as if it were the only one running. The level of isolation is configurable. | Dirty reads, lost updates, phantom reads. |
| Durability | Once committed, it's permanent | A committed transaction survives power loss, crashes, and hardware failures. Written to the WAL (Write-Ahead Log) on disk. | Committed data vanishes after a restart. |
// Without transactions — the worst case:
async function transfer(fromId, toId, amount) {
// Step 1: deduct
await db.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId]);
// 💥 Server crashes here! Money deducted but never credited.
PostgreSQL supports four isolation levels defined by the standard. In practice, PostgreSQL's implementation is stricter than the standard.
| Level | Dirty Read | Non-repeatable Read | Phantom Read | Anomaly | PostgreSQL default? |
|---|---|---|---|---|---|
| Read Uncommitted | Possible (in standard) | Possible | Possible | Possible | ❌ (behaves as Read Committed) |
| Read Committed | ❌ | Possible | Possible | Possible | ✅ |
| Repeatable Read | ❌ | ❌ | ❌ (in PG) | Possible | ❌ |
| Serializable | ❌ | ❌ | ❌ | ❌ | ❌ |
Dirty read: Transaction A reads uncommitted data written by Transaction B. If B rolls back, A used data that never existed. PostgreSQL prevents this even at Read Uncommitted.
Non-repeatable read: Transaction A reads a row. Transaction B updates that row and commits. Transaction A reads again and sees different data. "The row changed while I was looking at it."
-- Non-repeatable read (Read Committed):
-- T1: BEGIN; SELECT balance FROM accounts WHERE id = 1; -- returns 1000
-- T2: UPDATE accounts SET balance = 900 WHERE id = 1; COMMIT;
-- T1: SELECT balance FROM accounts WHERE id = 1; -- returns 900! Different!Phantom read: Transaction A runs the same query twice. Between the two queries, Transaction B inserts a new row that matches A's WHERE clause. The second query returns a "phantom" row that wasn't there the first time.
-- Phantom read (Read Committed):
-- T1: SELECT * FROM orders WHERE status = 'pending'; -- returns 5 rows
-- T2: INSERT INTO orders (status) VALUES ('pending'); COMMIT;
-- T1: SELECT * FROM orders WHERE status = 'pending'; -- returns 6 rows! The 6th is a phantom.
-- (PostgreSQL's Repeatable Read prevents this; Read Committed allows it.)Serialization anomaly: The result of running concurrent transactions is impossible to achieve if you ran them one at a time in any order. The most insidious concurrency bug — no individual row conflict, but the final state is logically wrong.
| Level | When to use | Never use for |
|---|---|---|
| Read Committed | Default for 95% of queries. Web apps, , dashboards. | Financial transfers, inventory deductions, anything where you read a value then write based on it |
| Repeatable Read | Reports that must be internally consistent. Backup queries. Any read-then-write operation. | High-concurrency write workloads (more serialization failures) |
| Serializable | Financial ledgers, booking systems (airline seats), auction systems. Anywhere data correctness beats performance. | High-throughput web apps — too many serialization failures and retries |
Sometimes you need to read a row and guarantee nobody else modifies it between your read and your write. SELECT ... FOR UPDATE locks the selected rows until your transaction ends.
BEGIN;
-- Lock this seat so no other transaction can book it
SELECT * FROM seats
WHERE flight_id = 42 AND seat_number = '14A'
FOR UPDATE; -- 🔒 Locked until COMMIT/ROLLBACK
-- Now safe: no other transaction can read+lock this row
-- Check if available, then book it
UPDATE seats SET booked_by =
When Transaction B tries to SELECT ... FOR UPDATE on a row Transaction A has locked, B blocks until A commits or rolls back. If A takes 5 seconds, B waits 5 seconds. If A is a slow transaction (external call inside a DB transaction — never do this), B times out. Set lock_timeout:
SET lock_timeout = '2s';
SELECT * FROM seats WHERE flight_id = 42 AND seat_number = '14A' FOR UPDATE;A deadlock happens when two transactions each hold a lock the other needs. Neither can proceed. PostgreSQL detects deadlocks and kills one transaction automatically.
Transaction A: locks row 1, then tries to lock row 2
Transaction B: locks row 2, then tries to lock row 1
A waits for B to release row 2.
B waits for A to release row 1.
→ DEADLOCK. PostgreSQL kills one transaction after ~1 second.The fix: Always lock resources in the same order. If both transactions lock row 1 first, then row 2, no deadlock. Also: keep transactions short. The longer a transaction holds locks, the higher the chance of deadlock.
-- Safe: consistent locking order
-- Always lock the smaller user_id first
SELECT * FROM accounts WHERE user_id = LEAST($1, $2) FOR UPDATE;
SELECT * FROM accounts WHERE user_id = GREATEST($1, $2) FOR UPDATE;
--// In application code: always retry on deadlock
async function transferWithRetry(fromId, toId, amount, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const client
Stripe's API accepts an Idempotency-Key header on every request. If the same key comes in twice, Stripe returns the original result — even if the first request crashed mid-processing. This is implemented with database transactions:
-- Simplified Stripe idempotency pattern:
BEGIN;
-- Check if we've already processed this key
SELECT response FROM idempotency_keys WHERE key = $1 FOR UPDATE;
-- If found, return cached response
-- If not found, process the charge
INSERT INTO charges (amount, currency, customer_id)
VALUES ($2, $3, $4)
The FOR UPDATE lock ensures that if two requests with the same idempotency key arrive simultaneously, one blocks until the other commits — then the second one finds the cached response and returns it instead of double-charging. No distributed lock, no Redis, just PostgreSQL.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Forgetting to ROLLBACK on error | The connection stays in an open transaction, holding locks indefinitely | Always use try/catch with ROLLBACK, or client.query('ROLLBACK') in a finally block that checks connection state |
| Calling external APIs inside a transaction | The API call might take 2 seconds or timeout entirely. The transaction holds locks the entire time, blocking all other transactions | Call external APIs before or after the transaction, never during |
| Using default Read Committed for read-then-write operations | Between your SELECT and your UPDATE, another transaction can change the data. You update based on stale data. | Use SELECT FOR UPDATE, or Repeatable Read isolation, or an UPDATE with a WHERE that checks the original value |
| Not retrying on serialization failures | In Serializable or Repeatable Read, PostgreSQL deliberately aborts transactions that would cause anomalies. If you don't retry, the operation silently fails. | Always wrap serializable transactions in retry logic (exponential backoff) |
| Long-running transactions with idle periods | The transaction holds locks while the user is reading a , clicking around, or making coffee | Keep transactions as short as possible. Read data outside the transaction, write inside it. |
| UPDATE without WHERE (or wrong WHERE) | UPDATE accounts SET balance = 0; — missing WHERE updates every row. In a transaction, you can ROLLBACK. Without one, every account is now broke. | Always test UPDATE/DELETE with a SELECT first. Use transactions as a safety net. |
idle_in_transaction_session_timeout. Kills transactions that have been idle too long (open but not running queries). Prevents accidental lock accumulation.lock_timeout. If a query waits for a lock longer than this, it fails instead of hanging indefinitely. Much better than a 30-second timeout at the application level.pg_stat_activity for waiting queries. SELECT * FROM pg_stat_activity WHERE wait_event_type = 'Lock'; — shows you what's blocking what.pg_locks to debug locking issues. It shows every lock currently held, what holds it, and what's waiting.SELECT FOR UPDATE blocks readers too — in some isolation levels. In Repeatable Read and Serializable, a SELECT FOR UPDATE blocks other SELECT FOR UPDATEs AND regular SELECTs waiting for the same rows. In Read Committed, regular SELECTs skip locked rows.UPDATE ... WHERE ... LIMIT 1000 inside a loop with brief pauses is safer than UPDATE on 10M rows in one transaction.SELECT FOR UPDATE on popular rows (e.g., a user with millions of followers), they can block legitimate operations. Implement lock_timeout and .BEGIN; UPDATE accounts SET balance = 0 WHERE id = 1; (no COMMIT). In Session B, SELECT * FROM accounts WHERE id = 1;. What happens? Now COMMIT in Session A and query again in Session B. Explain.SELECT FOR UPDATE, check availability, book or return "already taken". Test with concurrent booking attempts for the same seat.What is a database transaction? A unit of work that is atomic (all or nothing), consistent (valid state transitions), isolated (doesn't interfere with other transactions), and durable (survives crashes). ACID.
What happens if a transaction fails partway through? The entire transaction is rolled back — all changes made so far are undone. The database returns to the state it was in before the transaction began.
What is a deadlock? Two or more transactions each hold a lock the other needs, creating a cycle where neither can proceed. The database detects this and aborts one transaction to break the cycle.
Explain the difference between Read Committed and Repeatable Read isolation. Read Committed: each statement sees only committed data, but two SELECTs in the same transaction can see different data if another transaction committed in between. Repeatable Read: the entire transaction sees a snapshot of the database as it was when the transaction began. Any changes committed by other transactions after the snapshot are invisible.
When would you use Serializable isolation? For operations where logical correctness depends on the illusion of sequential execution: financial ledgers, booking/auction systems, inventory systems where overbooking is unacceptable. Serializable isolation is ~2–5x slower but guarantees no concurrency anomalies.
How do you prevent deadlocks in application code? 1) Always acquire locks in a consistent order (sort IDs, always lock table A before table B). 2) Keep transactions short — the less time you hold locks, the lower the chance of collision. 3) Set lock_timeout so transactions fail rather than hang. 4) Always retry transactions that fail due to deadlock (PostgreSQL error code 40P01).
Transactions are the foundation of data integrity. ACID guarantees — Atomicity, Consistency, Isolation, Durability — are what make databases trustworthy. Read Committed is the default and right for 95% of queries. Use Repeatable Read or Serializable for read-then-write operations where correctness matters (money, inventory, bookings). SELECT FOR UPDATE explicitly locks rows for safe read-modify-write cycles. Deadlocks happen — detect, retry with backoff, and always lock resources in a consistent order.
SELECT FOR UPDATE locks rows. Use consistent lock ordering. Set lock_timeout.SELECT FOR UPDATE do? Locks the selected rows until the transaction ends, preventing other transactions from modifying or (depending on isolation level) reading them.