Why a bank transfer needs more than two UPDATE statements
Move $50 from one account to another and the naive version is two statements: subtract from account A, add to account B. Run them separately, and if the process crashes or the connection drops between the two, the money is gone from A and never arrives in B. The database did exactly what it was told, twice. The business still lost $50.
A transaction wraps both statements so the database treats them as one unit: either both happen or neither does. That guarantee, plus three others, is what “ACID” names. It’s the reason you can trust a database with money or stock levels, where “half-done” is worse than “not done.”
What the four ACID properties guarantee
- Atomicity. All the statements in a transaction commit together, or none of them do. If the process crashes after the debit but before the credit, the database rolls the debit back too. There is no partial state to clean up by hand.
- Consistency. A transaction can only move the database from one valid state to another. If a
CHECKconstraint says a balance can’t go negative, no transaction is allowed to commit a row that violates it, no matter what the application code tried to do. - Isolation. Transactions running at the same time don’t see each other’s half-finished work. How strictly that is enforced is a dial, not a fixed rule: that dial is the isolation level, covered below.
- Durability. Once the database says a transaction committed, that data survives a crash one second later. This is what the write-ahead log is for: the change is on disk (or replicated) before the commit returns.
Atomicity and durability are the database’s job, and mainstream engines do them correctly. Isolation is the one you make decisions about. Stricter isolation costs concurrency, and the cost shows up in production latency graphs.
How to run a transaction with BEGIN, COMMIT and ROLLBACK
In PostgreSQL (and most SQL databases, with minor syntax differences), a transaction is the statements between BEGIN and COMMIT:
| |
If something goes wrong before COMMIT, say a constraint violation or a business check in your application code, ROLLBACK undoes everything since BEGIN:
| |
After the rollback, account 1’s balance is exactly what it was before BEGIN ran. For a long transaction where you only want to undo part of the work, a savepoint gives you a partial rollback:
| |
Outside an explicit BEGIN, most drivers run every statement in its own auto-committed transaction. Each statement is atomic on its own, but nothing ties two statements together. Skip BEGIN in the bank transfer and you are back to the lost $50.
What transaction isolation levels prevent
Two concurrent transactions can interfere with each other in three specific ways, and isolation levels are defined by which of these they allow:
| Anomaly | What happens |
|---|---|
| Dirty read | Transaction A reads a row that transaction B wrote but hasn’t committed yet. If B rolls back, A read data that never existed. |
| Non-repeatable read | A reads the same row twice in one transaction and gets different values, because B committed a change in between. |
| Phantom read | A re-runs the same WHERE query twice and gets a different set of rows, because B inserted or deleted a matching row in between. |
The SQL standard defines four levels around these three anomalies:
| Isolation level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible |
| Read Committed | Prevented | Possible | Possible |
| Repeatable Read | Prevented | Prevented | Possible (allowed by the standard) |
| Serializable | Prevented | Prevented | Prevented |
You will rarely want READ UNCOMMITTED. PostgreSQL doesn’t even implement it and quietly runs Read Committed instead; MySQL does honor it and will show you dirty reads. In practice the two levels that matter are Read Committed and Repeatable Read, and the two major open-source databases ship different defaults:
- PostgreSQL defaults to Read Committed. Each statement inside a transaction sees a fresh snapshot taken when that statement starts, so two
SELECTs in the same transaction can return different data if another transaction committed in between. - MySQL (InnoDB) defaults to Repeatable Read. The snapshot is taken once, at the first read in the transaction, and every later plain
SELECTin that transaction sees that same snapshot, even if another transaction has committed an update since.
In PostgreSQL, set the level right after BEGIN, before any query runs:
| |
In MySQL, the SESSION form changes the level for every following transaction on that connection:
| |
How Repeatable Read blocks a phantom read in PostgreSQL
Open two psql sessions against the same table and run this. Seeing it once sticks better than memorizing the table.
Session A:
| |
Session B, while A’s transaction is still open:
| |
Session A, same open transaction, re-running the identical query:
| |
The row B inserted and committed doesn’t appear. PostgreSQL’s Repeatable Read takes its snapshot at the first query of A’s transaction and keeps it until COMMIT, so new rows from other transactions never show up. That is stricter than the standard asks for: the table above allows phantoms at this level, but PostgreSQL’s implementation doesn’t produce them. Run the same sequence with A on Read Committed and the second SELECT returns the new row, because Read Committed takes a fresh snapshot per statement.
Serializable goes further. It detects when two concurrent transactions produced a result that couldn’t have happened in any one-at-a-time order, and aborts one of them with a serialization failure (PostgreSQL returns SQLSTATE 40001) instead of letting the anomaly commit. This is the level that closes the classic race condition where two transactions each read a value, decide based on it, and both write. Code using SERIALIZABLE has to be ready to retry:
| |
What stricter isolation levels cost
The price of stricter isolation is concurrency:
- Writes stay locked until commit, at every level. An
UPDATEtakes a row lock the moment it runs and holds it untilCOMMITorROLLBACK, regardless of isolation level. WhatRepeatable ReadandSerializableadd on top is holding the same snapshot (or, forSerializable, predicate locks on the rows your queries examined) for the whole transaction. Make a slow external API call betweenBEGINandCOMMITand every row you’ve written stays locked, and every row you’ve read underSerializablestays tracked, for as long as that call takes. - Retries aren’t optional under Serializable. It will reject valid-looking transactions under concurrent load by design. If your application doesn’t catch serialization failures and retry, those transactions fail intermittently, under load, which is the worst time to find out.
Repeatable Readcan raise the same40001error on a plain concurrent update, so the retry code isn’t only forSerializable— any transaction above Read Committed needs it. - Long transactions block schema changes and bloat storage. PostgreSQL’s MVCC keeps old row versions around as long as any open transaction might still need to see them. A transaction left open for hours (a common bug: opening one in a web request and never committing or rolling back) prevents
VACUUMfrom reclaiming that space, and anALTER TABLEon a table it touched waits for it to finish. - Not every write needs an explicit transaction. A single
UPDATEstatement is already atomic. Wrapping one statement inBEGIN/COMMITadds a round trip for no extra guarantee. Reach for an explicit transaction when correctness depends on two or more statements succeeding or failing together: the bank transfer, a signup that writes both ausersrow and anaccountsrow, an order that decrements inventory and inserts the order record.
ORMs (Entity Framework, SQLAlchemy, ActiveRecord) usually open a transaction per unit of work and commit it for you. Don’t fight that. The bug to watch for is the opposite: code that calls .save() or .commit() separately for two related writes, throwing away the atomicity the ORM would have given you.
Which isolation level to use
| If your workload is… | Use |
|---|---|
| Typical web app CRUD, reads and writes on a per-request basis | Read Committed. It’s the PostgreSQL default for a reason: lowest contention, and dirty reads are already off. |
| Reports or batch jobs that need every query in the transaction to agree with each other | Repeatable Read. Every query reads the same snapshot, so totals and detail rows can’t drift apart mid-report. |
| Financial transfers, inventory counts, anything where a race between two transactions is a correctness bug, not a performance nuisance | Serializable, with retry logic on 40001. Keep these transactions short and the concurrency cost stays small. |
| A single statement with no related writes | No explicit transaction needed. The statement is already atomic. |
Start at Read Committed and move up only for the specific transactions where you can name the race you’re closing. It’s the same discipline as adding an index only after you’ve found the slow query. If you can’t write that race down in one sentence, you don’t need a stricter level yet.