Most teams pick a transaction isolation level by accident. They inherit the database's default — Read Committed in PostgreSQL, Repeatable Read in MySQL, Serializable in SQLite — and never think about it again until a production incident reveals what their default actually allows. The four standard isolation levels defined by ANSI SQL are not interchangeable, and the differences between them are exactly the categories of subtle bugs that take hours to reproduce and longer to explain to a customer.
This piece walks through what each isolation level actually prevents, where the trade-offs live, and how to make the choice deliberately rather than by accident.
The four phenomena
The standard defines isolation levels in terms of which read anomalies they prevent. There are three classical phenomena:
Dirty reads: a transaction reads data written by another transaction that has not yet committed. If the writing transaction rolls back, the reader has based its logic on data that never existed. Almost no production database default permits this; we mention it only for completeness.
Non-repeatable reads: a transaction reads a row, then re-reads it later in the same transaction, and gets a different value because another transaction modified and committed in between. The reader sees inconsistent state within a single logical operation.
Phantom reads: a transaction runs the same query (say, SELECT * FROM orders WHERE customer_id = 5) twice and gets a different set of rows because another transaction inserted or deleted matching rows. Like non-repeatable reads, but at the row-set level rather than the individual-row level.
A fourth phenomenon, write skew, is not in the original ANSI definitions but is well-known and important in modern databases. Two transactions read overlapping data, make decisions based on what they read, and write disjoint changes that, taken together, violate an invariant neither transaction alone could see breaking.
Read Uncommitted
Read Uncommitted permits all of the above. It is the lowest isolation level and is essentially never the right answer for application code. Some databases (including PostgreSQL) silently upgrade Read Uncommitted to Read Committed because dirty reads are so rarely useful that supporting them is more confusing than valuable. We mention this level mostly so you can recognize it in the spec and ignore it.
Read Committed
Read Committed prevents dirty reads but permits non-repeatable reads, phantom reads, and write skew. Each statement within a transaction sees a consistent snapshot of committed data at the time the statement executes. Different statements within the same transaction can see different snapshots.
This is PostgreSQL's default. It is fast, has minimal locking overhead, and is correct for the vast majority of single-statement read patterns. The bugs it allows are the ones where application code makes a decision based on data read at one point and writes based on that decision later, with another transaction changing the data in between. The classic example is a banking transfer that checks the balance, decides it is sufficient, and then debits — if a parallel transfer happens between the check and the debit, both can pass the check and the resulting balance can go negative.
The mitigation at Read Committed is to use locking reads (SELECT ... FOR UPDATE) when the application needs to make a decision based on read state. This is the right pattern for short critical sections in OLTP code; it is the wrong pattern for read-heavy analytical queries where the locking overhead would be devastating.
Repeatable Read
Repeatable Read prevents non-repeatable reads. Within a single transaction, every read of the same row returns the same value, regardless of what other transactions commit in the meantime. The transaction operates against a consistent snapshot of the database taken at transaction start.
This is MySQL's default and is widely available in PostgreSQL. It eliminates the entire class of bugs where a transaction's logic depends on a row not changing under it. Phantom reads can still happen in standard SQL — new rows matching a predicate can appear — but PostgreSQL's Repeatable Read implementation actually prevents phantoms as well, because its snapshot includes the entire database state, not just rows already read.
Repeatable Read still allows write skew. Two transactions can each read a constraint-relevant row set, decide their writes are safe, and commit changes that together violate the constraint. The canonical example is a hospital on-call schedule with a constraint that at least one doctor must always be on call. Two doctors with overlapping shifts both run a transaction that reads the schedule, sees the other is on call, and updates themselves to off-call. Both commit. The constraint is violated. Neither transaction alone could have caused the violation.
Serializable
Serializable is the strongest isolation level and the only one that fully prevents write skew. Transactions execute as if they had run in some serial order, even if they actually ran concurrently. The database is responsible for ensuring that any pattern of concurrent execution produces results consistent with some serial ordering.
The implementation strategies vary. Older databases used pessimistic locking, holding range locks for the duration of every transaction, which made Serializable expensive enough that almost no one used it. Modern PostgreSQL uses Serializable Snapshot Isolation (SSI), which runs transactions optimistically against snapshots and aborts any transaction that would have produced a result inconsistent with serial execution. The application sees these aborts as transient errors and is expected to retry.
Serializable is correct for everything. It is also the slowest, has the highest abort rate under contention, and requires the application to handle retry. The right pattern is to use Serializable for the small set of transactions where write skew or other anomalies would cause real harm, and lower isolation levels for the rest. Promoting individual transactions to Serializable on a per-transaction basis is supported by every major database.
The choice in practice
The honest practical advice is to default to Read Committed, identify the specific transactions where stronger guarantees are required, and promote those transactions individually. The transactions that need promotion are usually identifiable by their structure: they read state, make a decision based on what they read, and write changes based on the decision. Anything matching that pattern needs either a locking read or a higher isolation level, and the choice between the two depends on contention and read patterns.
The wrong approach is to set the database default to Serializable hoping it will fix all the bugs. The retry overhead at high contention can be substantial, and code that did not anticipate retry will fail in surprising ways when the abort rate climbs.
The right discipline is to read the specific guarantees your database provides at each level, because the standard is permissive and implementations often provide stronger guarantees than the spec requires. PostgreSQL's Repeatable Read prevents phantoms; the spec only requires preventing non-repeatable reads. Treating PostgreSQL like MySQL because they share an isolation level name is a common source of subtle bugs.
Where this fits in our stack
Across our four products — DocuMint, CronPing, FlagBit, and WebhookVault — we run on SQLite with serializable isolation by default at the database level, but each transaction is short enough that contention is not a meaningful problem. The interesting case is FlagBit's flag-evaluation paths, where read-heavy traffic is served from a snapshot that is refreshed in the background, with writes serialized against a single writer. This pattern collapses the isolation level question into a write-side concurrency question, and it is one of the reasons SQLite scales further than people expect.
The deeper point is that isolation level is not an obscure database tuning knob. It is the contract under which your application logic is correct, and choosing it by accident is choosing the bugs you will spend the next year debugging. Understanding the four levels — and the cases where your specific database is stricter than the spec requires — is one of the highest-leverage pieces of database knowledge you can have, and almost no one has it until they hit a production incident that forces them to.