Transactions and Concurrency
Understanding REPEATABLE READ and SERIALIZABLE
In the previous chapter, you learned that PostgreSQL uses READ COMMITTED by default.
Under READ COMMITTED, each SELECT without a locking clause receives a new snapshot when the statement begins. Two statements inside the same transaction can therefore see different committed data.
Sometimes several statements need to work from the same view of the database. PostgreSQL provides two stronger isolation levels for these situations:
REPEATABLE READSERIALIZABLE
The core difference between the three levels is:
| Isolation level | Snapshot behavior | Guarantee |
|---|---|---|
READ COMMITTED | A new snapshot for each statement | Later statements can see newer committed data |
REPEATABLE READ | One snapshot for the transaction | Statements continue seeing the same database state |
SERIALIZABLE | One snapshot for the transaction | Successfully committed transactions behave as if they ran one at a time |
Let's examine the two stronger isolation levels.
Using REPEATABLE READ
Start a transaction using REPEATABLE READ:
1BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;Now check the current isolation level:
1SHOW transaction_isolation;You should see:
1repeatable readEnd the transaction:
1ROLLBACK;When is the snapshot established?
Running BEGIN doesn't immediately establish the transaction's data snapshot.
Under REPEATABLE READ, PostgreSQL establishes the snapshot when the transaction executes its first query or data-modification statement.
Later statements continue using that snapshot. They don't see changes committed by other transactions after the snapshot was established.
The transaction can still see changes made by its own earlier statements.
Seeing the difference
This example requires two independent database sessions, so we'll follow the statements rather than run them in the SQL editor.
Suppose an order currently has:
1status = pendingUnder READ COMMITTED, Transaction A can see the order change:
1234567891011121314Transaction A Transaction BBEGINSELECT status→ pending UPDATE status = 'shipped' COMMITSELECT status→ shippedCOMMITEach SELECT in Transaction A receives a new snapshot.
Under REPEATABLE READ, both SELECT statements use the snapshot established by the first SELECT:
12345678910111213141516Transaction A Transaction BBEGIN TRANSACTIONISOLATION LEVELREPEATABLE READSELECT status→ pending UPDATE status = 'shipped' COMMITSELECT status→ pendingCOMMITTransaction B successfully changes the order to shipped, but Transaction A continues seeing pending because that was the visible status when its snapshot was established.
The key difference is:
1234READ COMMITTEDfirst SELECT → snapshot 1second SELECT → snapshot 212345REPEATABLE READfirst query establishes snapshot ↓all later queries use that snapshotWhen is REPEATABLE READ useful?
Suppose a backend request generates a report using several queries:
count the orders;
calculate their total value;
group them by status;
retrieve additional information about them.
Under READ COMMITTED, another transaction could commit changes between those queries. Each query might then observe a slightly different database state.
Under REPEATABLE READ, all the queries work from the same snapshot. The results therefore represent one consistent view of the database.
A stable snapshot isn't the same as serial execution
REPEATABLE READ gives a transaction a stable view, but it doesn't guarantee that the combined outcome of concurrent transactions could have occurred if they had run one at a time.
Suppose an application has this rule:
A customer can't have more than `$1,000` in pending orders.
The customer currently has $900 in pending orders.
Two transactions start at almost the same time. Each transaction wants to create another pending order worth $100.
Both transactions read the current total:
1234SELECT SUM(total_amount)FROM ordersWHERE customer_id = 7 AND status = 'pending';Both see:
1900.00Each transaction decides that adding another $100 is allowed.
They then insert different orders:
12345678Transaction A Transaction Breads pending total reads pending total→ 900 → 900inserts $100 order inserts $100 orderCOMMIT COMMITBecause the transactions insert different rows, they don't directly conflict over the same row.
Under REPEATABLE READ, both transactions may commit.
The customer then has:
1900 + 100 + 100 = 1,100The business rule has been violated.
This outcome couldn't have occurred if the transactions had run one at a time.
If Transaction A ran first, Transaction B should have seen a pending total of $1,000 and rejected the second order. The same would be true if Transaction B ran first.
This is called a serialization anomaly.
REPEATABLE READ prevents the data visible to one transaction from changing, but serialization anomalies are still possible.
Using SERIALIZABLE
Start a transaction using SERIALIZABLE:
1BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;Check the current isolation level:
1SHOW transaction_isolation;You should see:
1serializableEnd the transaction:
1ROLLBACK;How is SERIALIZABLE different?
In PostgreSQL, SERIALIZABLE provides the same transaction-wide snapshot behavior as REPEATABLE READ.
It also monitors how concurrent SERIALIZABLE transactions read and write data.
If their combined outcome couldn't have occurred with the transactions running one at a time, PostgreSQL rejects one of them.
The transactions can still run concurrently. PostgreSQL doesn't simply execute them one after another.
The guarantee is:
Successfully committed `SERIALIZABLE` transactions produce the same result as some order in which those transactions ran one at a time.
Preventing the serialization anomaly
Return to the pending-order example.
Two SERIALIZABLE transactions both read a pending total of $900 and attempt to insert another $100 order:
12345678Transaction A Transaction Breads pending total reads pending total→ 900 → 900inserts $100 order inserts $100 ordertries to commit tries to commitPostgreSQL detects that allowing both transactions to commit would produce an outcome that isn't possible under any one-at-a-time ordering.
One transaction can commit. PostgreSQL rejects the other with an error similar to:
1ERROR: could not serialize access due to read/write dependencies among transactionsThe rejected transaction receives the SQLSTATE error code:
140001The application must retry that transaction.
REPEATABLE READ transactions can also fail
Retries aren't limited to SERIALIZABLE.
Suppose Transaction A uses REPEATABLE READ and sees an order with a status of pending.
Transaction B changes that same order to shipped and commits.
If Transaction A then tries to update the order version from its earlier snapshot, PostgreSQL can't safely apply the change. Transaction A can fail with:
1ERROR: could not serialize access due to concurrent updateThis is also a serialization failure with SQLSTATE 40001.
Applications that use REPEATABLE READ for updates must therefore also be prepared to retry transactions.
Retry the entire transaction
When PostgreSQL returns a serialization failure, don't retry only the statement that failed.
The transaction's earlier reads and decisions were based on its previous snapshot. Retrying one statement would continue using results that may no longer be valid.
Retry the entire transaction from the beginning:
1234567891011start transaction ↓perform all reads and writes ↓try to commit ↓serialization failure? ↙ ↘ no yes ↓ ↓done retry from BEGINA serialization failure can occur while a statement is being executed or when the transaction tries to commit.
In an application, use a limited number of retry attempts rather than retrying forever.
Choosing between the isolation levels
A stronger isolation level isn't automatically better. Choose the level based on the guarantee the operation needs.
| Isolation level | Use it when | Important consideration |
|---|---|---|
READ COMMITTED | Each statement can safely work with the latest committed data available when it begins | Statements in the same transaction can see different committed states |
REPEATABLE READ | Several statements need to use one stable snapshot | Updating transactions can fail and require a retry |
SERIALIZABLE | Business rules involve multiple reads or rows and the final result must match some one-at-a-time execution | Serialization failures are expected and must be retried |
For many application transactions, READ COMMITTED is a good starting point.
Use REPEATABLE READ when several statements need to work from one consistent snapshot.
Use SERIALIZABLE when a stable snapshot isn't enough and concurrent transactions mustn't produce an outcome that would be impossible if they ran one at a time.
The important question is:
What guarantee does this operation actually need?
What you need to remember
The core ideas are:
READ COMMITTEDcreates a new snapshot for each statement.REPEATABLE READuses one snapshot established by the transaction's first query or data modification.A stable snapshot doesn't prevent every serialization anomaly.
SERIALIZABLErejects transactions when their combined result couldn't match any one-at-a-time execution.Both
REPEATABLE READandSERIALIZABLEcan produce serialization failures.After a serialization failure, the application must retry the entire transaction.
In the next chapter, you'll learn about another concurrency problem transactions can encounter: deadlocks.