Isolation Levels: READ COMMITTED Through SERIALIZABLE
Understand READ COMMITTED, REPEATABLE READ, and SERIALIZABLE isolation levels, read vs write anomalies, and SET TRANSACTION syntax.
Transaction Isolation Levels: READ UNCOMMITTED to SERIALIZABLE
Every database connection that runs concurrent queries shares the same data. Transaction isolation levels control how concurrent transactions interact. Choosing the right level is a trade-off between correctness and performance.
Introduction
Every concurrent database system has to decide how transactions interact. Do you see changes from other transactions immediately? Can two transactions modify the same row without corrupting data? Transaction isolation levels define the answer to these questions — and choosing the wrong level is how you get mysterious vanished updates, phantom reads, and data that contradicts itself despite constraints.
The SQL standard defines four isolation levels from READ UNCOMMITTED to SERIALIZABLE. Most databases default to READ COMMITTED or REPEATABLE READ, but you can change the level per transaction. This guide explains what each level actually guarantees, which anomalies it prevents, and the performance cost of tightening the guarantees.
The Four Standard Isolation Levels
The SQL standard defines four isolation levels, from least strict to most strict:
| Level | Dirty Reads | Non-Repeatable Reads | Phantom Reads |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Prevented | Possible | Possible |
| REPEATABLE READ | Prevented | Prevented | Possible |
| SERIALIZABLE | Prevented | Prevented | Prevented |
READ UNCOMMITTED
The lowest isolation level. A transaction can see uncommitted changes from other transactions.
PostgreSQL doesn’t actually implement READ UNCOMMITTED. If you set it, you get READ COMMITTED behavior instead. This is per the SQL standard — implementations are allowed to implement a level higher than specified.
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Transaction 2 (in another session)
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- Might see: 900 (uncommitted value)
-- Transaction 1
ROLLBACK; -- Balance is back to 1000
READ COMMITTED
Each query sees only data committed before that query started. This is PostgreSQL’s default.
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Transaction 2 (in another session)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- Sees: 1000 (waits for Transaction 1 to commit or rollback)
SELECT balance FROM accounts WHERE id = 1;
-- Sees: 900 (after Transaction 1 commits)
REPEATABLE READ
The transaction sees a snapshot as of the first query in the transaction. Reads are consistent within the transaction regardless of when they occur.
-- Transaction 2
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- Sees: 1000
-- Transaction 1 (in another session)
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id = 1;
-- Still sees: 1000 (snapshot from transaction start)
-- Even though the value in the database is now 900
PostgreSQL implements REPEATABLE READ using MVCC (Multi-Version Concurrency Control). Each transaction sees a consistent snapshot of the database.
SERIALIZABLE
The highest isolation level. Transactions appear to run sequentially, even if they run concurrently. Serializable is the only level that guarantees no anomalies.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
PostgreSQL implements SERIALIZABLE using a form of MVCC plus serialization conflict detection. If two concurrent transactions try to modify the same data, one will be rolled back with a serialization error.
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Transaction 2 (concurrent, also SERIALIZABLE)
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
-- Transaction 1 tries to COMMIT
-- ERROR: could not serialize access due to concurrent update
How MVCC Snapshot Behavior Changes Per Level
sequenceDiagram
participant T1 as Transaction 1
participant DB as PostgreSQL (MVCC)
participant T2 as Transaction 2
participant T3 as Transaction 3
Note over T1,DB: T1: READ COMMITTED
T1->>DB: SELECT balance (snapshot S1)
DB-->>T1: balance = 1000
T2->>DB: BEGIN (snapshot S2 created)
DB-->>T2: ok
T2->>DB: UPDATE balance = 900
T2->>DB: COMMIT
T3->>DB: BEGIN (snapshot S3 created)
DB-->>T3: ok
T3->>DB: SELECT balance (snapshot S3 = T2 committed)
DB-->>T3: balance = 900
T1->>DB: SELECT balance (new snapshot S1')
DB-->>T1: balance = 900 (sees T2's commit!)
sequenceDiagram
participant T1 as Transaction 1
participant DB as PostgreSQL (MVCC)
participant T2 as Transaction 2
Note over T1,DB: T1: REPEATABLE READ
T1->>DB: BEGIN (snapshot S1 frozen)
DB-->>T1: ok
T1->>DB: SELECT balance (snapshot S1)
DB-->>T1: balance = 1000
T2->>DB: BEGIN
DB-->>T2: ok
T2->>DB: UPDATE balance = 900
T2->>DB: COMMIT
T1->>DB: SELECT balance (still snapshot S1)
DB-->>T1: balance = 1000 (T2's change invisible!)
Note over T1,DB: Snapshot stays frozen for entire transaction
With READ COMMITTED, each statement gets a fresh snapshot. With REPEATABLE READ (or SERIALIZABLE), the snapshot is taken at transaction start and held for the duration. This is why the same query returns different results at different isolation levels.
Read vs Write Anomalies
Isolation levels prevent specific types of anomalies.
Dirty Read
Reading uncommitted data from another transaction. This is prevented by READ COMMITTED and above.
Non-Repeatable Read
The same row is read twice within a transaction, but returns different values because another transaction modified and committed it.
-- Transaction A
SELECT balance FROM accounts WHERE id = 1;
-- Returns: 1000
-- Transaction B
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;
-- Transaction A
SELECT balance FROM accounts WHERE id = 1;
-- Returns: 900 (different!)
Prevented by REPEATABLE READ and SERIALIZABLE.
Phantom Read
A transaction re-executes a query returning rows that satisfy a search condition, but receives additional rows due to another transaction inserting.
-- Transaction A
SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- Returns: 50
-- Transaction B
INSERT INTO orders (status, ...) VALUES ('pending', ...);
COMMIT;
-- Transaction A
SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- Returns: 51
Prevented by SERIALIZABLE.
Lost Update
Two transactions read and update the same row, and one update overwrites the other.
-- Transaction A
SELECT balance FROM accounts WHERE id = 1;
-- balance = 1000
-- Transaction B
SELECT balance FROM accounts WHERE id = 1;
-- balance = 1000
-- Transaction A
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- balance = 900
-- Transaction B
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
-- balance = 1100, but Transaction A's update is lost!
Prevented by SERIALIZABLE in PostgreSQL.
SET TRANSACTION Syntax
Setting Isolation Level
-- At transaction start
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- or
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- Within a transaction (must be first statement)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Setting Other Transaction Characteristics
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET TRANSACTION READ ONLY; -- or READ WRITE
SET TRANSACTION DEFERRABLE; -- waits for lock to avoid serialization failures
COMMIT;
DEFERRABLE is useful for long-running read-only transactions that you want to run without getting serialization errors.
Default Isolation Levels by Database
| Database | Default Isolation |
|---|---|
| PostgreSQL | READ COMMITTED |
| MySQL (InnoDB) | REPEATABLE READ |
| Oracle | READ COMMITTED |
| SQL Server | READ COMMITTED |
| SQLite | SERIALIZABLE |
PostgreSQL’s READ COMMITTED
PostgreSQL’s default is READ COMMITTED. Each statement sees data committed before that statement runs, which means statements within a transaction can see different data, and long-running transactions are more prone to interference.
MySQL’s REPEATABLE READ
MySQL (with InnoDB) defaults to REPEATABLE READ. The transaction sees a consistent snapshot from the first query, other transactions’ uncommitted changes are invisible, and phantom reads can still occur (though InnoDB’s gap locking reduces this in practice).
Practical Implications
When to Use SERIALIZABLE
Use it when correctness is critical and you can tolerate some performance reduction. Financial transactions, inventory updates, and booking systems often need SERIALIZABLE to prevent lost updates.
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Critical section
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Check business rules
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
The FOR UPDATE lock ensures exclusive access. The serialization level ensures the balance didn’t change between your SELECT and UPDATE.
When to Use READ COMMITTED
READ COMMITTED is fine for most OLTP workloads. It provides a good balance between consistency and performance.
Be aware of what it doesn’t prevent: non-repeatable reads and phantoms within a transaction. If you need consistent reads across multiple queries, you’ll need a higher isolation level or explicit locking.
When to Avoid SERIALIZABLE
Serializable has real costs. It can cause more transaction rollbacks under high concurrency, and serialization conflict detection adds overhead.
If your application has enough concurrency that serialization errors become frequent, you might be better off with REPEATABLE READ plus explicit locking, or handling conflicts in application code with retries.
Isolation Level Trade-offs
| Isolation Level | Latency impact | Throughput | Consistency guarantee | Best for |
|---|---|---|---|---|
| READ COMMITTED | Lowest | Highest | Sees only committed data per statement | Most OLTP, high-concurrency workloads |
| REPEATABLE READ | Moderate | Moderate | Same row values within a transaction | Reporting, consistent financial reads |
| SERIALIZABLE | Highest | Lowest | No anomalies possible | Financial transfers, inventory, booking |
| READ UNCOMMITTED | (Not implemented in PostgreSQL) | — | — | — |
Capacity Estimation: MVCC Version Bloat
MVCC keeps multiple versions of rows to support concurrent reads without blocking writers. Each UPDATE creates a new row version (tuple) while the old one stays until vacuum removes it. Under READ COMMITTED with long-running transactions, this accumulation is measurable.
Storage overhead per UPDATE: PostgreSQL writes a new tuple version approximately equal to the row size. For a 500-byte row updated 10 times, that is roughly 5 KB of dead tuple storage before VACUUM runs. On a table with 100 million rows and 10% updated daily, you accumulate around 50 GB of dead tuples before autovacuum can catch up.
The practical consequence is bloat and degraded index scan performance. Each index entry pointing to dead tuple versions adds I/O overhead to queries. If autovacuum falls behind due to a long-running READ COMMITTED transaction holding back vacuum, dead tuples pile up faster than they are cleaned. Tables with high UPDATE rates in READ COMMITTED mode need aggressive autovacuum tuning — specifically, lower autovacuum_vacuum_cost_delay and higher autovacuum_vacuum_scale_factor for tables with large row widths.
Visibility and bloat observability in pg_stat_activity: Long-running transactions prevent autovacuum from reclaiming dead tuples. You can detect this by querying pg_stat_activity for transactions that have been idle in transaction for an abnormally long time:
SELECT pid, usename, state, query_start, backend_xmin
FROM pg_stat_activity
WHERE state != 'active'
AND query_start < NOW() - INTERVAL '10 minutes'
AND backend_xmin IS NOT NULL;
The backend_xmin field shows the oldest transaction snapshot the backend is holding. Autovacuum cannot remove tuples killed by transactions older than backend_xmin. If you see backend_xmin values that are stale, those are your bloat blockers. Killing the blocking process or waiting for it to finish allows autovacuum to resume cleanup.
Under REPEATABLE READ and SERIALIZABLE, the snapshot is held from transaction start, so a long-running transaction at these levels holds references to all tuple versions created during its execution. If your reporting queries run at REPEATABLE READ and take 2 hours, all rows those queries touch accumulate dead versions for 2 hours before they become visible to autovacuum. Plan for this when setting up autovacuum thresholds on tables queried by long-running analytical transactions.
Common Production Failures
Serialization errors spiking under load: You deploy SERIALIZABLE on a high-concurrency write path. Suddenly 5% of transactions start failing with serialization errors, rolling back work and filling your error logs. The fix is to either switch to REPEATABLE READ with explicit FOR UPDATE locks, or add retry logic in your application for serialization failures.
Long-running READ COMMITTED transactions seeing stale data: A reporting query runs inside a transaction that takes 30 minutes. With READ COMMITTED, each statement sees only data committed when that statement ran — not when the transaction started. If nightly batch jobs commit while your report is running, different parts of your report will reflect different points in time.
REPEATABLE READ not actually preventing phantoms in PostgreSQL: PostgreSQL’s REPEATABLE READ uses MVCC but does not prevent phantom reads for INSERT operations — it only prevents non-repeatable reads of existing rows. The standard says REPEATABLE READ should prevent phantoms, but PostgreSQL diverges here. If you need true phantom prevention, use SERIALIZABLE.
Implicit assumption of default isolation: Most developers never set isolation level and rely on the database default. In PostgreSQL this is READ COMMITTED, which means two queries in the same transaction can see different data. If your logic assumes consistent reads across statements within a transaction, you need REPEATABLE READ or SERIALIZABLE explicitly.
Lost updates at READ COMMITTED: At READ COMMITTED, two concurrent transactions can read the same balance, compute new values, and write — losing each other’s updates. This is not prevented by READ COMMITTED. Use SELECT ... FOR UPDATE or SERIALIZABLE to prevent lost updates.
Quick Recap Checklist
- READ COMMITTED: snapshot per statement, sees committed data before each statement
- REPEATABLE READ: snapshot at transaction start, consistent reads within txn
- SERIALIZABLE: snapshot at transaction start, detects write-write conflicts
- PostgreSQL does not implement READ UNCOMMITTED — defaults to READ COMMITTED
- Non-repeatable read: same row read twice within a transaction returns different values
- Phantom read: re-running a query returns additional rows from concurrent inserts
- Lost update: two transactions read, compute, and write — one overwrites the other
- SERIALIZABLE prevents lost updates but causes serialization failures under high contention
- Long-running READ COMMITTED transactions can see different data in different statements
- backend_xmin in pg_stat_activity shows oldest held snapshot — blocks autovacuum
- Use FOR UPDATE or SERIALIZABLE to prevent lost updates at READ COMMITTED
Interview Questions
With READ COMMITTED (PostgreSQL's default), each statement in your transaction sees only data committed when that statement ran — not when the transaction began. So the first query in your report sees data as of 9:00 AM, the second sees data as of 9:15 AM when the batch job committed, and so on. Different parts of the same report reflect different points in time. This is sometimes called a "temporal anomaly" and is not prevented by READ COMMITTED. Fix it by running the report at REPEATABLE READ or SERIALIZABLE, or by taking a consistent snapshot before the report starts.
PostgreSQL's REPEATABLE READ prevents non-repeatable reads of existing rows — it freezes a snapshot of committed data at transaction start. But it does not prevent new rows from being inserted by other transactions. Those new rows are invisible to your REPEATABLE READ transaction because your snapshot does not include them, but when you re-run the same query, you get different row counts. This is a phantom read, and PostgreSQL's REPEATABLE READ allows it (unlike the SQL standard which requires REPEATABLE READ to prevent phantoms). If you need true phantom prevention, use SERIALIZABLE.
Serializable prevents lost updates by detecting write-write conflicts and rolling back one transaction, but it only works if all writes go through SERIALIZABLE transactions. If any part of your write path uses READ COMMITTED or REPEATABLE READ, the guarantee is broken. More importantly, serialization failures increase under contention — if you have 100 concurrent transactions all trying to update the same balance, most will fail and retry. At that point, SERIALIZABLE plus retry logic is often slower than REPEATABLE READ with explicit SELECT ... FOR UPDATE locking, because FOR UPDATE gives you control over which transaction waits and which proceeds.
backend_xmin is NOT NULL in a query against pg_stat_activity and you are investigating bloat on a heavily-written table. What does that tell you?backend_xmin is the transaction ID of the snapshot being held by a backend. When it is NOT NULL, that backend is holding a transaction snapshot that prevents autovacuum from removing dead tuple versions. The older the backend_xmin, the longer the snapshot has been held and the more dead tuples have accumulated behind it. Look for backends in pg_stat_activity that are in an idle state (state != 'active') but have been running for a long time — these are often the culprit. Long-running analytical queries at REPEATABLE READ are a common source.
Yes, this is possible under SERIALIZABLE — but only if they read different states. If Transaction A reads the row when it has value X and Transaction B reads it when it has value Y (because a third committed transaction changed it between the reads), both can compute and commit different updates without a serialization conflict. SERIALIZABLE detects write-write conflicts, not read-write conflicts where the write happens on a different version. However, if both transactions read the same version and both write to the same row, one is rolled back as a serialization failure. The key is that the conflict depends on what versions each transaction read.
SERIALIZABLE failures spike when contention is high — multiple transactions are trying to modify the same rows simultaneously. Each serialization failure rolls back the entire transaction, wasting all work. The fix is to either switch to REPEATABLE READ with explicit SELECT ... FOR UPDATE locking (gives you control over which transaction waits), or add retry logic in your application for serialization failures. With retry logic, the transaction re-reads the row(s), re-applies the change, and tries to commit again. For truly high-contention hot rows, consider optimistic locking with a version column and retry — SERIALIZABLE is often slower than optimistic locking under high contention because its rollback is more expensive.
After the first statement started — not after the transaction began. READ COMMITTED takes a new snapshot for every statement. If Transaction A starts at 9:00:00 and runs SELECT * FROM orders, it sees all data committed by 9:00:00. If Transaction B commits an update at 9:00:01 and Transaction A runs another SELECT at 9:00:02, the second SELECT sees Transaction B's change even though Transaction A started at 9:00:00. This means different statements in the same READ COMMITTED transaction can see different data — the transaction is not consistent across its own statements.
Autovacuum cannot remove dead tuple versions when there are active transactions with snapshots older than those tuples. The backend_xmin field in pg_stat_activity marks the oldest snapshot being held — autovacuum cannot remove tuples killed by transactions older than backend_xmin. A long-running READ COMMITTED transaction that started 2 hours ago holds a snapshot from 2 hours ago, so any dead tuples created in the last 2 hours cannot be cleaned. As INSERTs and UPDATEs continue, dead tuples accumulate. The practical fix is to ensure analytical queries run at READ COMMITTED outside long transactions, or to use a connection pooler that times out idle transactions.
READ UNCOMMITTED allows a transaction to see uncommitted changes from other transactions — dirty reads. PostgreSQL does not actually implement READ UNCOMMITTED; if you set it, you get READ COMMITTED behavior instead. This is per the SQL standard, which allows implementations to implement a level higher than specified. Oracle similarly does not support READ UNCOMMITTED. The only way to get dirty reads in PostgreSQL would be to explicitly read uncommitted data using a nolock hint or similar — which PostgreSQL does not support. READ COMMITTED is the lowest level PostgreSQL actually implements.
Use REPEATABLE READ or SERIALIZABLE for the backup transaction to get a consistent snapshot. At READ COMMITTED, different tables (or different parts of the same table) would reflect different points in time because each statement sees commits from different times. At REPEATABLE READ, the snapshot is taken at transaction start and held for the duration — the entire backup sees a consistent view of the database as of the moment the backup transaction began. SERIALIZABLE works too but risks serialization failures if the backup is long-running and concurrent writes are heavy. PostgreSQL's pg_start_backup() and streaming replication also provide consistency guarantees at the infrastructure level.
Yes, within REPEATABLE READ you should see the same value both times because the snapshot is taken at transaction start. However, if another transaction updates and commits that row, and your REPEATABLE READ transaction then does a SELECT ... FOR UPDATE or an INSERT ... ON CONFLICT DO UPDATE, PostgreSQL will update the newer committed version and your transaction will not see the intermediate update as a conflict. REPEATABLE READ freezes the read view but write operations in your transaction still see the latest committed state when updating.
No. SERIALIZABLE, like REPEATABLE READ, takes a snapshot at transaction start and does not see subsequent commits within the transaction. Your transaction computes based on stale data and writes a result. If the write conflicts with what another concurrent transaction wrote, PostgreSQL detects the write-write conflict and rolls back your transaction with a serialization error. This is the serialization anomaly — your transaction believed it was operating on the current state when it was actually operating on a snapshot.
A serialization failure occurs when two concurrent SERIALIZABLE transactions have a write-write conflict — both read the same version of a row and both try to write an update. PostgreSQL detects this at commit time and rolls back one transaction with ERROR: could not serialize access. A deadlock is when two transactions are waiting for each other to release locks — PostgreSQL detects this and rolls back one to break the cycle. Deadlocks are prevented by consistent lock ordering; serialization failures are inherent to concurrent writes at SERIALIZABLE and require retry logic in application code.
SET TRANSACTION READ ONLY do and what are its performance implications?SET TRANSACTION READ ONLY prevents the transaction from writing anything — no INSERT, UPDATE, DELETE, or temporary table creation. It allows the database to make certain optimizations because it knows the transaction will not modify data. Some databases use this to enable read replica routing (directing read-only transactions to replicas). In PostgreSQL, READ ONLY transactions cannot create or write to temporary tables, but otherwise the performance benefit is minimal. The main value is correctness — it prevents accidental writes in reporting or analytical transactions.
No. With READ COMMITTED, each statement gets a fresh snapshot as of when that statement starts. If your process runs Statement 1 at 9:00:00, then commits some other transaction at 9:00:05, then runs Statement 2 at 9:00:06, Statement 2 sees commits made after Statement 1 did. Different statements see different data. If you need consistent reads across statements, use REPEATABLE READ or SERIALIZABLE which freeze the snapshot at transaction start.
All isolation levels in PostgreSQL see your own transaction's uncommitted changes within the transaction — this is called "own transaction visibility." If you do BEGIN; UPDATE ... and then SELECT within the same transaction, you see your own UPDATE even at READ COMMITTED. Other transactions cannot see your uncommitted changes until you COMMIT. This is standard MVCC behavior — uncommitted changes are visible to the transaction that made them but invisible to others until committed.
Autovacuum cannot remove dead tuple versions when there are active transactions with snapshots older than those tuples. The backend_xmin field marks the oldest snapshot being held. A long-running transaction (even READ COMMITTED) holds a snapshot from when it started — autovacuum cannot clean tuples created after that time. This causes bloat to accumulate. Set idle_in_transaction_session_timeout to auto-terminate long idle transactions. Consider using connection pooler timeouts to prevent long-running analytical transactions from blocking vacuum.
Yes. SERIALIZABLE detects write-write conflicts on the same row (or same version of a row). If Transaction A updates Row 1 and Transaction B updates Row 2 simultaneously, there is no conflict and both can commit. The serialization failure only occurs when both transactions read the same row versions, compute values, and write — the second one to commit detects the version changed and fails. Row-level partitioning of writes (different rows per transaction) naturally avoids serialization conflicts.
A DEFERRABLE transaction can be paused and resumed later by the storage engine to avoid serialization failures. It waits for locks rather than failing with serialization errors. Use DEFERRABLE when you have long-running read-only transactions (like nightly reports) that you want to run without getting serialization errors, and you can tolerate the transaction taking longer due to waiting. The trade-off is it may take longer to complete because it yields to concurrent writers rather than failing. DEFERRABLE is only available with SERIALIZABLE isolation.
In MySQL (InnoDB), REPEATABLE READ gives you consistent reads within a transaction — the first read in a transaction freezes the snapshot. In PostgreSQL, READ COMMITTED gives each statement a fresh snapshot. This means in PostgreSQL, two SELECTs in the same transaction can return different data; in MySQL they will return the same data. PostgreSQL's default leads to more unexpected behavior for developers accustomed to MySQL. PostgreSQL REPEATABLE READ behaves like MySQL's REPEATABLE READ for reads but still allows phantoms (unlike MySQL which uses gap locking to prevent them).
Further Reading
- PostgreSQL MVCC documentation — how PostgreSQL implements concurrency with multi-version tuples
- Transaction Isolation documentation — official reference for isolation levels
- PostgreSQL Serializable Snapshot Isolation — how SERIALIZABLE detects and prevents serialization anomalies
- Autovacuum tuning guide — configuring vacuum to prevent bloat from long-running transactions
- pg_stat_activity and backend_xmin — monitoring active queries and snapshot holders
Conclusion
Transaction isolation levels trade correctness against performance. READ COMMITTED is fine for most applications. REPEATABLE READ gives you consistent reads within a transaction. SERIALIZABLE prevents all anomalies but at a performance cost. PostgreSQL’s MVCC implementation makes these levels efficient, but you should still choose deliberately rather than accepting defaults without understanding the implications.
For more on concurrent data access, see Locking and Concurrency and Relational Databases.
Category
Related Posts
PostgreSQL Locking and Concurrency Control
Learn about shared vs exclusive locks, lock escalation, deadlocks, optimistic vs pessimistic concurrency control, and FOR UPDATE clauses.
Constraint Enforcement: Database vs Application Level
A guide to CHECK, UNIQUE, NOT NULL, and exclusion constraints. Learn database vs application-level enforcement and performance implications.
Database Indexes: B-Tree, Hash, Covering, and Beyond
A practical guide to database indexes. Learn when to use B-tree, hash, composite, and partial indexes, understand index maintenance overhead, and avoid common performance traps.