Last 30 Days
No notifications
A transaction is a unit of work the database treats as all-or-nothing. ACID is the guarantee. Isolation levels are how you trade safety for speed. Locks and deadlocks are how it goes wrong. Get this right and concurrent users never corrupt each other's data.
BEGIN; -- start a transaction
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- atomically apply both
-- or:
ROLLBACK; -- discard everything since BEGINEither both updates happen, or neither. No "money disappeared mid-transfer" possible.
(MySQL/Postgres alias: START TRANSACTION;. SQL Server: BEGIN TRANSACTION;.)
| Letter | Means |
| Atomicity | All statements commit, or none do β partial failure is impossible. |
| Consistency | The DB moves from one valid state to another β constraints, FKs, triggers all hold. |
| Isolation | Concurrent transactions don't see each other's half-finished work. |
| Durability | Once COMMIT returns, the change survives crashes / power loss. |
A transaction can be rolled back automatically by the engine on:
SERIALIZABLE)BEGIN;
INSERT INTO logs(...);
SAVEPOINT before_risky;
UPDATE big_table SET ...;
-- error or "nope, undo just this part"
ROLLBACK TO SAVEPOINT before_risky;
-- the INSERT into logs is still alive
COMMIT;Useful when one branch may fail and you don't want to abort the whole transaction.
Concurrent transactions can step on each other in four well-known ways:
| Anomaly | What happens | ||||||
| Dirty read | T1 reads data T2 has written but not yet committed; T2 then rolls back. | ||||||
| Non-repeatable read | T1 reads row X, T2 commits an UPDATE to X, T1 reads X again and sees a different value. | ||||||
| Phantom read | T1 runs SELECT β¦ WHERE β¦, T2 inserts a new row matching that filter and commits, T1 re-runs and sees an extra "phantom" row. | ||||||
| Lost update | T1 reads, T2 reads, both write β one writer's change is silently overwritten. | Isolation levels are the dials you turn to forbid more of these β at the cost of more locking / more retries. The four standard isolation levels | Level | Dirty read | Non-repeatable | Phantom | Lost update |
| READ UNCOMMITTED | possible | possible | possible | possible | |||
| READ COMMITTED *(default in PG, Oracle, SQL Server)* | no | possible | possible | possible | |||
| REPEATABLE READ *(default in MySQL InnoDB)* | no | no | possible (SQL spec) β prevented in MySQL InnoDB & PG | possible | |||
| SERIALIZABLE | no | no | no | no |
-- Set for the next transaction
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
...
COMMIT;Postgres detail: REPEATABLE READ actually gives snapshot isolation β your whole transaction sees a consistent snapshot taken at BEGIN. SERIALIZABLE adds detection of read-write conflicts and may abort one transaction with a serialization failure (which the app retries).
Pessimistic: lock the row immediately. Other writers wait.
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- exclusive row lock
-- compute new balance, then:
UPDATE accounts SET balance = ... WHERE id = 1;
COMMIT;Optimistic: don't lock; assume no one else will touch it; check at write time using a version column or the snapshot:
-- read with version
SELECT id, balance, version FROM accounts WHERE id = 1;
-- application computes new_balance and tries to commit:
UPDATE accounts
SET balance = :new_balance, version = version + 1
WHERE id = 1 AND version = :version_read;
-- 0 rows updated β another transaction won β retryPessimistic is simpler under high contention; optimistic scales better when conflicts are rare.
| Lock | Granted to | Conflicts with |
| Shared (S) | readers | exclusive |
| Exclusive (X) | writer of a row / table | shared & exclusive |
| Row-level | individual rows (most modern engines) | only that row |
| Table-level | whole table (DDL, some bulk ops) | everything else |
You almost never write LOCK directly. SELECT ... FOR UPDATE (X), SELECT ... FOR SHARE (S) and the implicit locks taken by UPDATE/DELETE cover 99% of needs.
Two transactions wait on each other forever:
T1: lock row A, then wants row B
T2: lock row B, then wants row AThe engine detects this, picks a victim, and aborts it with a deadlock error. The app must catch and retry. Avoid by:
1. Always locking rows in the same order across all code paths (e.g. lower id first). 2. Keeping transactions short β open BEGIN, do the minimum, COMMIT. 3. Doing slow / external work (HTTP calls, file I/O) outside the transaction.
READ UNCOMMITTED in production "for speed" β almost never the actual bottleneck and reintroduces dirty reads.UPDATE that depends on a previous SELECT is safe β it isn't, unless wrapped in a transaction with FOR UPDATE or done atomically (UPDATE β¦ WHERE balance >= 100).BEGIN;
-- Lock both rows in deterministic order (lower id first)
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE; UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Optional: fail loudly if it would overdraw
-- (better: a CHECK or a WHERE balance >= 100 + retry)
COMMIT;
That's it: ACID, isolation level appropriate to your engine's defaults, deterministic lock order, short transaction, no external I/O between BEGIN and COMMIT.