Serializable and Repeatable Read
Why transaction isolation exists
Imagine two requests hit an application at almost exactly the same time.
Both requests:
- read the database,
- make a decision based on what they read,
- change the database,
- commit.
The dangerous part is not simply that they run concurrently. Databases are specifically designed to allow concurrency.
The dangerous part is that each transaction can make a locally reasonable decision based on an incomplete view of what the other transaction is doing.
For example, suppose a hospital requires:
At least one doctor must always remain on call.
Initially:
| Doctor | On call |
|---|---|
| Alice | Yes |
| Bob | Yes |
Two requests execute concurrently:
| Transaction T1 | Transaction T2 |
|---|---|
| Reads: Alice + Bob are on call | Reads: Alice + Bob are on call |
| Decides Alice can leave | Decides Bob can leave |
Sets Alice on_call = false |
Sets Bob on_call = false |
| Commits | Commits |
The final state is:
| Doctor | On call |
|---|---|
| Alice | No |
| Bob | No |
Neither transaction individually did anything irrational. Each saw another doctor available.
The problem is that the two decisions were not safe when combined.
Transaction isolation determines which of these concurrent interactions a transaction is allowed to observe and which outcomes the database must prevent.
The central distinction: Repeatable Read versus Serializable
The most useful mental model is:
Repeatable Read asks: "Will my view remain stable? " Serializable asks: "Could the committed result have happened if the transactions had run one at a time? "
Those are different guarantees.
A database can give a transaction a perfectly stable snapshot while still allowing two transactions to make incompatible decisions from that snapshot.
PostgreSQL is an especially clear example. Its Repeatable Read implementation gives the transaction a stable snapshot and even prevents ordinary phantom reads, but serialization anomalies can still occur. Serializable adds additional detection specifically to prevent committed results that have no valid serial ordering.
What does "serial execution" mean?
Suppose there are two transactions, T1 and T2.
A serial execution means one finishes before the other starts:
T1 β COMMIT β T2 β COMMIT
or:
T2 β COMMIT β T1 β COMMIT
Serializable isolation does not require the database to literally execute transactions one at a time.
The database can still execute:
T1 βββββββββββββββ
T2 βββββββββββββββ
T1 βββββββ COMMIT β
T2 βββββββ COMMIT
The requirement is that the final committed result must be equivalent to some legal serial order.
In the doctors example, no serial ordering produces both doctors off call.
If Alice's transaction ran first:
Alice sees:
Alice = on
Bob = on
Alice goes off call.
Bob's transaction would then see:
Alice = off
Bob = on
and should refuse to take Bob off call.
The reverse ordering has the same effect.
Therefore:
Alice = off
Bob = off
cannot be explained by any serial execution.
That is a serialization anomaly.
The concurrency anomalies you need to recognize
| Anomaly | What happens | Concrete example |
|---|---|---|
| Dirty read | A transaction reads another transaction's uncommitted change. | T1 changes an account balance to β¬0 but later rolls back. T2 temporarily reads β¬0. |
| Non-repeatable read | The same row returns a different value when read again. | T1 reads product price β¬100. T2 commits price β¬120. T1 reads again and gets β¬120. |
| Phantom read | Repeating a predicate query returns a different set of rows. | T1 queries all unpaid invoices. T2 inserts another unpaid invoice. T1 repeats the query and sees an extra row. |
| Lost update | One write effectively overwrites another concurrent decision. | Two requests read counter = 10, calculate 11, and both write 11 instead of reaching 12. |
| Write skew | Transactions read overlapping business-rule data but update different rows, jointly violating a rule. | Two doctors both see another doctor available and independently go off call. |
| Serialization anomaly | The final committed state cannot be explained by any serial ordering of the transactions. | The doctors example is one form of serialization anomaly. |
An important distinction is that write skew usually does not involve two transactions updating the same row.
That is why ordinary row-conflict detection is insufficient.
Why Repeatable Read sounds stronger than it actually is
Suppose a transaction starts and sees:
Alice = on
Bob = on
Under snapshot-based Repeatable Read, it can continue seeing exactly that snapshot throughout the transaction.
That sounds extremely safe.
But consider two transactions.
Transaction T1
Sample doctors table:
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
SELECT *
FROM doctors
WHERE on_call = true;
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
T1 decides Alice can leave:
UPDATE doctors
SET on_call = false
WHERE name = 'Alice';
Transaction T2
At approximately the same time, T2 runs the same query against its snapshot:
SELECT *
FROM doctors
WHERE on_call = true;
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
and executes:
UPDATE doctors
SET on_call = false
WHERE name = 'Bob';
The writes touch different rows:
T1 writes Alice
T2 writes Bob
A system checking only for:
"Did T1 and T2 modify the same row?"
finds no conflict.
Both can commit.
This is the essential weakness of Snapshot Isolation / snapshot-style Repeatable Read:
A stable snapshot protects what an individual transaction sees. It does not automatically validate the decisions that multiple transactions make from those snapshots.
PostgreSQL explicitly documents this distinction: Repeatable Read provides a stable database view but that view is not necessarily consistent with a serial execution of concurrent transactions.
A second real-world example: overselling capacity
Consider an event with ten available seats.
Instead of representing every physical seat separately, the application checks how many bookings exist. Sample rows:
| booking_id | event_id |
|---|---|
| 1 | 42 |
| 2 | 42 |
| 3 | 42 |
| 4 | 42 |
| 5 | 42 |
| 6 | 42 |
| 7 | 42 |
| 8 | 42 |
| 9 | 42 |
SELECT COUNT(*)
FROM bookings
WHERE event_id = 42;
| count |
|---|
| 9 |
Suppose there are currently nine bookings.
Two customers attempt to buy the final seat.
Transaction A
COUNT = 9
9 < 10
Therefore inserting one booking is safe.
Transaction B
At the same time:
COUNT = 9
9 < 10
Therefore inserting one booking is safe.
They insert different booking rows:
INSERT INTO bookings(event_id, customer_id)
VALUES (42, 1001);
and:
INSERT INTO bookings(event_id, customer_id)
VALUES (42, 2002);
Both transactions can individually appear valid.
The result is:
11 bookings
10-seat capacity
The business rule:
COUNT(bookings) <= capacity
has been violated.
A stable snapshot alone does not solve the problem because both transactions legitimately saw the old count.
This pattern appears in:
| Domain | Business rule |
|---|---|
| Ticketing | Reservations must not exceed capacity |
| Warehouses | Allocated stock must not exceed available stock |
| Banking | Combined exposure must remain below a credit limit |
| Scheduling | At least one employee must cover a shift |
| Resource allocation | Allocated CPU/storage/quota must remain below a maximum |
| Approval workflows | A document must not end up with mutually incompatible approvals |
| Multiplayer / bidding systems | A unique logical resource must not be allocated twice |
Repeatable Read is not one universal behavior
This is one of the most important practical points.
The SQL isolation-level names describe guarantees, but actual database engines implement them differently.
In particular:
PostgreSQL Repeatable Read
β
MySQL InnoDB Repeatable Read
β
SQL Server Repeatable Read
You therefore cannot safely read:
Isolation = REPEATABLE READ
in application configuration and infer its exact behavior without knowing the database engine.
Technology summary
| Technology | What Repeatable Read means in practice | Ordinary phantom reads? | Can snapshot-style serialization anomalies remain? | How Serializable is implemented |
|---|---|---|---|---|
| PostgreSQL | One stable MVCC snapshot for the transaction; effectively Snapshot Isolation | No | Yes | Serializable Snapshot Isolation: tracks read/write dependencies and aborts transactions when necessary |
| MySQL InnoDB | Plain SELECT uses a stable snapshot; locking reads/writes use locking/current-read behavior |
Plain snapshot reads remain stable; locking range operations use gap/next-key locking | Yes if decisions are made using ordinary snapshot reads without protecting the invariant | Stronger locking; plain SELECT becomes a shared locking read in normal explicit Serializable transactions |
| SQL Server | Shared locks on rows that were read are held until transaction end | Yes | Different behavior from snapshot isolation; row locking may block/deadlock competing updates | Key-range locks prevent inserts/updates into ranges previously read |
| SQL Server SNAPSHOT | Separate isolation level using row versions and a transaction-level snapshot | No | Yes; it is not equivalent to Serializable | Use SERIALIZABLE or explicit locking/constraints when the invariant requires it |
PostgreSQL explicitly identifies its Repeatable Read implementation as Snapshot Isolation and Serializable as Serializable Snapshot Isolation. MySQL InnoDB documents that plain Repeatable Read SELECTs share a snapshot, while locking reads and writes use different locking rules including gap and next-key locks. SQL Server instead defines Repeatable Read primarily through locks held on rows and Serializable through additional key-range locking.
PostgreSQL: Repeatable Read
PostgreSQL's implementation is particularly important because it is stronger than the minimum SQL-standard definition of Repeatable Read.
At Repeatable Read:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
the transaction essentially gets one database snapshot.
Suppose customer 1 currently has five orders.
T1
Suppose orders contains these five rows for customer 1:
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT *
FROM orders
WHERE customer_id = 1;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
Meanwhile T2 executes:
INSERT INTO orders(customer_id, ...)
VALUES (1, ...);
COMMIT;
There are now six committed rows globally.
T1 repeats the same query:
SELECT *
FROM orders
WHERE customer_id = 1;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
T1 still sees the same five rows.
because it continues using its earlier snapshot.
PostgreSQL therefore prevents an ordinary phantom read at Repeatable Read even though the SQL standard does not require Repeatable Read to do so.
PostgreSQL Repeatable Read still does not mean Serializable
Return to the doctor example.
Two PostgreSQL transactions can both start at Repeatable Read:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
With the doctors rows Alice and Bob both marked on call, both execute:
SELECT COUNT(*)
FROM doctors
WHERE on_call = true;
| count |
|---|
| 2 |
Both see a count of 2.
T1:
UPDATE doctors
SET on_call = false
WHERE id = 1;
T2:
UPDATE doctors
SET on_call = false
WHERE id = 2;
They modified different rows.
The stable snapshots themselves do not tell PostgreSQL that:
"at least one doctor must remain on call"
is the logical dependency connecting those writes.
This is the classic reason why Snapshot Isolation is not the same as Serializable isolation.
PostgreSQL Serializable: how the problem is solved
PostgreSQL's Serializable level is implemented using Serializable Snapshot Isolation (SSI).
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
The database still uses MVCC snapshots.
It does not simply put a giant lock around the database.
Instead, PostgreSQL additionally monitors dependencies between concurrent transactions.
Conceptually, PostgreSQL asks:
T1 read something that T2 changed.
T2 read something that T1 changed.
Could allowing both transactions to commit produce
a result impossible under every serial ordering?
If PostgreSQL detects a dangerous dependency structure, one transaction is aborted.
An application may receive an error such as:
ERROR:
could not serialize access due to
read/write dependencies among transactions
Serializable failures use SQLSTATE:
40001
PostgreSQL's documentation explains that Serializable behaves like Repeatable Read plus monitoring for conditions that could make the execution inconsistent with every possible serial ordering. It uses predicate-lock information such as SIReadLock entries to track relevant dependencies; these predicate locks are used for conflict detection rather than ordinary blocking.
The important consequence is:
PostgreSQL may deliberately abort a transaction even though the SQL statements themselves were individually valid.
That abort is the mechanism protecting correctness.
Why PostgreSQL's predicate locks are different from normal locks
The phrase predicate lock can be confusing.
Suppose the sample bookings table has two rows for event 42:
| booking_id | event_id |
|---|---|
| 501 | 42 |
| 502 | 42 |
The application reads:
SELECT COUNT(*)
FROM bookings
WHERE event_id = 42;
| count |
|---|
| 2 |
The logical thing being depended on is not necessarily one row.
It is effectively:
"the set of rows satisfying event_id = 42"
A transaction that later inserts another matching booking can affect the truth of the first transaction's decision.
PostgreSQL Serializable needs to recognize this dependency.
Its SSI mechanism therefore tracks what transactions have read sufficiently to detect dangerous read/write relationships. These SIReadLock predicate locks do not behave like ordinary row locks that simply block another writer. PostgreSQL documents them as part of its serialization-anomaly detection system.
PostgreSQL Serializable does not mean "no concurrency"
This misconception is common:
Serializable = only one transaction can run
That is false.
For example:
T1 reads customers
T2 reads products
T3 modifies invoices
T4 modifies an unrelated account
can still proceed concurrently.
Serializable determines which combinations may successfully commit.
If concurrent execution is equivalent to:
T2 β T1 β T4 β T3
then the database can allow all of them to commit.
If no possible ordering explains the result, PostgreSQL aborts one of the participating transactions.
MySQL InnoDB: Repeatable Read is a hybrid you must understand
MySQL's InnoDB engine defaults to:
REPEATABLE READ
For ordinary non-locking SELECTs, a transaction uses a consistent snapshot established by its first consistent read.
Suppose orders has five rows for customer 1:
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
For example:
START TRANSACTION;
SELECT *
FROM orders
WHERE customer_id = 1;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
If this establishes a snapshot containing five orders, another ordinary SELECT later in the same transaction normally continues to see those same rows.
MySQL documents this behavior explicitly.
However, there is a crucial complication.
These statements are locking operations:
SELECT ... FOR UPDATE;
SELECT ... FOR SHARE;
UPDATE ...;
DELETE ...;
They do not behave exactly like the earlier non-locking snapshot read.
MySQL explicitly warns that mixing non-locking snapshot reads with locking statements inside the same Repeatable Read transaction can expose two different notions of database state and can be difficult to reason about.
MySQL example: snapshot read versus locking read
Suppose products contains (id=100, name='Desk lamp', stock=8). A transaction does an ordinary snapshot read:
START TRANSACTION;
SELECT *
FROM products
WHERE id = 100;
| id | name | stock |
|---|---|---|
| 100 | Desk lamp | 8 |
That ordinary SELECT may come from the transaction's snapshot.
Another transaction changes product 100 and commits.
After another transaction commits a change to stock from 8 to 7, your transaction executes:
SELECT *
FROM products
WHERE id = 100
FOR UPDATE;
| id | name | stock |
|---|---|---|
| 100 | Desk lamp | 7 |
This is not simply another read of exactly the same historical snapshot.
It is a locking operation intended to find and lock the relevant current row.
That is why code such as:
ordinary SELECT
business decision
SELECT FOR UPDATE
UPDATE
must be understood much more carefully than:
"Everything is Repeatable Read, therefore every operation
must be seeing exactly the same version."
That inference is wrong for InnoDB.
MySQL gap locks and next-key locks
Suppose bookings contains two rows for event 42:
| booking_id | event_id |
|---|---|
| 501 | 42 |
| 502 | 42 |
The transaction executes:
SELECT *
FROM bookings
WHERE event_id = 42
FOR UPDATE;
| booking_id | event_id |
|---|---|
| 501 | 42 |
| 502 | 42 |
A problem remains if the database locks only the currently existing rows.
Imagine bookings with IDs:
100
101
102
Another transaction could potentially insert:
103
which did not exist when the first transaction acquired its row locks.
For indexed range operations, InnoDB can use gap locks and next-key locks.
These protect not just existing records but also relevant gaps in an index so that another transaction cannot freely create a new matching record inside the protected range.
MySQL's documentation describes next-key/gap locking for locking reads and range scans under Repeatable Read.
This is one reason good indexing is not merely a query-performance concern in InnoDB concurrency control: the index range scanned can affect what gets locked.
MySQL Serializable
At InnoDB Serializable isolation, behavior becomes more lock-oriented.
For explicit transactions with autocommit disabled, MySQL documents that plain:
SELECT ...
is effectively converted into a shared locking read:
SELECT ... FOR SHARE;
This prevents the application from treating ordinary reads as harmless historical observations while simultaneously making decisions that need serializable protection.
That means MySQL's implementation strategy is quite different from PostgreSQL's SSI.
A simplified comparison is:
PostgreSQL Serializable
run concurrently using snapshots
detect dangerous dependency structures
abort when necessary
MySQL InnoDB Serializable
make reads more strongly locking
make conflicting transactions wait
possibly encounter deadlocks
Both aim to enforce stronger correctness, but the operational behavior can be very different.
SQL Server Repeatable Read
SQL Server uses yet another model for its traditional Repeatable Read level.
At:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION;
when the transaction reads existing rows, SQL Server keeps shared locks on those rows until the transaction finishes.
Therefore, another transaction cannot normally modify those already-read rows while the first transaction remains active.
But there is an important limitation:
Repeatable Read protects the rows that were read, not necessarily the gaps where new matching rows could appear.
Microsoft documents that Repeatable Read does not use the range locks necessary to prevent phantom rows.
SQL Server phantom example
Suppose orders initially has five rows for customer 1:
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION;
SELECT *
FROM orders
WHERE customer_id = 1;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
The query returns five rows.
SQL Server keeps appropriate shared locks on those existing rows.
But T2 may be able to insert a new order:
INSERT INTO orders(customer_id, ...)
VALUES (1, ...);
COMMIT;
T1 repeats the query after T2 inserts order 106:
SELECT *
FROM orders
WHERE customer_id = 1;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
| 106 | 1 |
The result now has six rows.
No previously read row needed to change.
A new matching row appeared.
That is the classic phantom problem.
SQL Server Serializable: range locking
SQL Server Serializable closes this gap with key-range locks.
Suppose orders contains five rows for customer 1. T1 executes:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
SELECT *
FROM orders
WHERE customer_id = 1;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
SQL Server protects the relevant key range.
A concurrent transaction trying to insert another row whose key falls inside that protected range may have to wait.
Microsoft describes Serializable as preventing another transaction from inserting key values that fall inside ranges previously read by the current transaction, with those range locks held until the transaction completes.
The strategy is therefore approximately:
PostgreSQL:
detect dangerous dependency β abort one transaction
SQL Server:
protect key range β make conflicting transaction wait
Both can produce serializable behavior while using very different mechanisms.
Do not confuse SQL Server SNAPSHOT with SQL Server REPEATABLE READ
SQL Server additionally provides:
SNAPSHOT
as a separate isolation level.
This is important because discussions about database isolation often casually use:
Repeatable Read
Snapshot Isolation
as though they were synonyms.
They are not synonyms in SQL Server.
Traditional SQL Server Repeatable Read is primarily lock-based.
SQL Server SNAPSHOT uses row versioning so that a transaction observes a transactionally consistent historical view.
Microsoft's documentation explicitly treats SNAPSHOT as a separate level from REPEATABLE READ and SERIALIZABLE.
A stable SNAPSHOT still should not automatically be interpreted as:
"every cross-row business invariant is serializable"
because snapshot isolation and serializable isolation solve different problems.
Same SQL isolation name, three different implementations
Suppose orders contains five rows for customer 1:
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
Consider this query followed by a concurrent insert:
SELECT *
FROM orders
WHERE customer_id = 1;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
PostgreSQL Repeatable Read
T1:
5 rows
T2 inserts row 6 and commits.
T1 repeats:
5 rows
because T1 keeps using its transaction snapshot.
MySQL InnoDB Repeatable Read, ordinary SELECT
The basic outcome is similar:
5 rows
because ordinary consistent reads use the transaction snapshot.
But locking reads and writes introduce different current/locking semantics that must be understood separately.
SQL Server Repeatable Read
T1 can observe:
6 rows
because locks protect the rows already read but Repeatable Read does not generally protect the entire search range from inserts.
This is why statements such as:
"Repeatable Read prevents phantom reads. "
or:
"Repeatable Read allows phantom reads. "
are incomplete unless the database engine is specified.
Summary: phantom behavior
| Isolation / technology | Repeating WHERE customer_id = 1 after another transaction inserts a matching row |
|---|---|
PostgreSQL READ COMMITTED |
New row can appear |
PostgreSQL REPEATABLE READ |
New row does not appear in ordinary snapshot read |
PostgreSQL SERIALIZABLE |
Serializable semantics; incompatible patterns may cause transaction abort |
MySQL InnoDB READ COMMITTED |
Each consistent read gets a fresh snapshot; new row can appear |
MySQL InnoDB REPEATABLE READ, ordinary SELECT |
Stable snapshot; new row normally does not appear |
| MySQL locking range read | Relevant next-key/gap locking can block matching inserts |
SQL Server REPEATABLE READ |
New matching rows can appear |
SQL Server SNAPSHOT |
Historical transaction snapshot remains stable |
SQL Server SERIALIZABLE |
Key-range locking prevents conflicting inserts into the read range |
MySQL documents that Repeatable Read consistent reads share a snapshot, while READ COMMITTED creates a fresh snapshot per consistent read and locking range operations can use gap/next-key locks. SQL Server documents the distinction between Repeatable Read and Serializable specifically in terms of whether key ranges are protected.
The most important application question: what invariant are you protecting?
Choosing an isolation level should start from the business rule, not from the name of the isolation level.
Examples:
balance >= 0
COUNT(bookings) <= capacity
at least one doctor is on call
total_credit_exposure <= customer_limit
only one active subscription of this type exists
stock_reserved <= stock_available
Then ask:
Can two transactions each independently verify this condition
and modify different rows in a way that jointly breaks it?
If yes, a stable snapshot alone may not be sufficient.
Sometimes the right solution is a database constraint, not Serializable
Do not solve every concurrency problem by switching the entire application to Serializable.
Suppose the rule is:
username must be unique
Suppose users has no alice row yet:
| user_id | username |
|---|
Do not implement:
SELECT COUNT(*)
FROM users
WHERE username = 'alice';
| count |
|---|
| 0 |
followed by:
if count == 0:
INSERT ...
and depend on transaction isolation.
Instead create:
CREATE UNIQUE INDEX users_username_uq
ON users(username);
Now the database directly represents the invariant.
Likewise:
quantity >= 0
may be expressible with a CHECK constraint.
Foreign-key relationships should normally use:
FOREIGN KEY
rather than an application doing:
SELECT parent
then later INSERT child
without database enforcement.
A useful principle is:
If the invariant can be expressed directly as a database constraint, prefer the constraint.
Serializable isolation becomes especially valuable for multi-row or predicate-based invariants that cannot easily be represented by a simple uniqueness, foreign-key, or check constraint.
Another solution: atomic conditional UPDATE
Consider inventory.
Suppose the products row is (id=42, available_stock=1). A naive implementation reads:
SELECT available_stock
FROM products
WHERE id = 42;
| available_stock |
|---|
| 1 |
Application:
if stock >= 1:
stock = stock - 1
Then:
UPDATE products
SET available_stock = ?
WHERE id = 42;
That separates:
check
from:
write
and creates room for concurrency bugs.
A safer design can combine them:
UPDATE products
SET available_stock = available_stock - 1
WHERE id = 42
AND available_stock >= 1;
Then inspect:
rows affected
If:
1
the reservation succeeded.
If:
0
there was no available stock.
This often gives a much simpler correctness argument than:
read β decide β write
because the condition and modification are handled atomically against the same row.
Another solution: explicit row locking
Suppose an operation must read an account before deciding how to modify it.
Suppose accounts contains (id=100, balance=500). Instead of an ordinary read:
SELECT *
FROM accounts
WHERE id = 100;
| id | balance |
|---|---|
| 100 | 500 |
use a locking read:
SELECT *
FROM accounts
WHERE id = 100
FOR UPDATE;
The intention is:
I am not merely looking at this row.
I am reading it because I am about to make a decision
whose correctness depends on nobody changing it underneath me.
This technique is useful for well-defined row-level resources.
But it becomes harder when the invariant depends on:
all rows satisfying a predicate
or:
several tables
or:
rows that do not exist yet
At that point you need to understand range/predicate locking or use Serializable isolation.
Fixing the doctors example with explicit locking
With Alice and Bob on call, an ordinary read returns these rows:
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
SELECT *
FROM doctors
WHERE on_call = true;
The transaction could deliberately lock the same rows:
SELECT *
FROM doctors
WHERE on_call = true
FOR UPDATE;
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
Then decide whether the selected doctor may leave.
Because competing transactions follow the same locking protocol, one transaction must wait for the other instead of independently making its decision from the same old state.
However, this is only safe if:
every piece of code modifying this invariant follows the protocol
If one service uses FOR UPDATE but another service simply executes:
UPDATE doctors
SET on_call = false
...
without respecting the same design, the application's correctness argument can collapse.
Serializable isolation can sometimes simplify this because the database validates interactions between participating Serializable transactions rather than requiring application code to manually predict every conflicting access pattern.
Serializable transactions require retry logic
Serializable does not mean:
BEGIN
do stuff
COMMIT
always succeeds
The database may intentionally reject the transaction because allowing it to commit would violate serializable execution.
For PostgreSQL this commonly appears as:
SQLSTATE 40001
The correct algorithm is conceptually:
for limited number of attempts:
begin transaction
perform ALL reads
make decision
perform writes
try commit
if serialization failure:
rollback
retry entire transaction
else:
success
The important phrase is:
retry the entire transaction
PostgreSQL's documentation specifically instructs applications encountering these concurrency failures to restart the transaction from the beginning so that its reads and decisions are recomputed from an appropriate state.
Why retrying only the UPDATE is wrong
Suppose the transaction originally did:
READ:
2 doctors are on call
DECISION:
Alice may leave
WRITE:
Alice = off
If PostgreSQL aborts because the decision was no longer serializable, this is wrong:
retry only:
UPDATE Alice = off
The decision:
Alice may leave
came from the old transaction.
The retry must perform:
READ AGAIN
DECIDE AGAIN
WRITE AGAIN
because the database state may now be:
Alice = on
Bob = off
and the correct decision is therefore different.
Be careful with external side effects during retries
Suppose a Serializable transaction does:
1. read database
2. send confirmation email
3. update database
4. COMMIT
and step 4 fails with a serialization error.
The application retries.
Now it may send the email again.
The database rollback cannot undo:
HTTP API request
email
payment-provider call
Kafka publication already acknowledged externally
SMS
because those effects occurred outside the database transaction.
A common architecture is an outbox pattern.
Instead of:
database transaction
update order
call email service
commit
do:
database transaction
update order
INSERT INTO outbox(
event_type,
payload
)
commit
A separate worker later processes the committed outbox row.
Now a serialization retry rolls back both:
order change
outbox event
together.
Idempotency keys can provide additional protection when talking to payment providers and other external APIs.
PostgreSQL syntax
Stable snapshot
Suppose orders contains five rows for customer 1, IDs 101β105.
BEGIN TRANSACTION
ISOLATION LEVEL REPEATABLE READ;
SELECT *
FROM orders
WHERE customer_id = 1;
-- Other transactions may commit here.
SELECT *
FROM orders
WHERE customer_id = 1;
COMMIT;
Each read returns the same rows from the transaction snapshot:
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
The ordinary reads continue to use the transaction snapshot.
Serializable
Suppose doctors Alice and Bob are both on call. The transaction counts them:
BEGIN TRANSACTION
ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*)
FROM doctors
WHERE on_call = true;
| count |
|---|
| 2 |
It then updates Alice and commits:
UPDATE doctors SET on_call = false WHERE id = 1;
COMMIT;
| name | on_call |
|---|---|
| Alice | false |
| Bob | true |
The application must be prepared for the commit or one of the statements to fail with a serialization error and retry the transaction.
MySQL InnoDB syntax
Repeatable Read
Suppose orders contains five rows for customer 1 (order IDs 101β105).
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT *
FROM orders
WHERE customer_id = 1;
SELECT *
FROM orders
WHERE customer_id = 1;
COMMIT;
Both reads return the same five rows:
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
InnoDB's default isolation level is Repeatable Read, and ordinary consistent reads within the transaction use the transaction's snapshot.
Lock rows that drive a decision
Suppose doctors Alice and Bob are both on call. The locking read returns both rows:
START TRANSACTION;
SELECT *
FROM doctors
WHERE on_call = true
FOR UPDATE;
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
The transaction then updates Alice and commits:
UPDATE doctors SET on_call = false WHERE id = 1;
COMMIT;
| name | on_call |
|---|---|
| Alice | false |
| Bob | true |
Serializable
Suppose Alice and Bob are on call. The query reads both doctors before deciding whether one can leave:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT *
FROM doctors
WHERE on_call = true;
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
After the update commits, the table contains:
UPDATE doctors SET on_call = false WHERE id = 1;
COMMIT;
| name | on_call |
|---|---|
| Alice | false |
| Bob | true |
For an explicit Serializable transaction, InnoDB makes ordinary reads more strongly locking rather than treating every SELECT as an ordinary historical snapshot read.
SQL Server syntax
Repeatable Read
Suppose orders has five rows for customer 1, IDs 101β105.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION;
SELECT *
FROM orders
WHERE customer_id = 1;
COMMIT TRANSACTION;
| order_id | customer_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 1 |
| 104 | 1 |
| 105 | 1 |
Existing rows read by the transaction remain protected by shared locks, but new matching rows are not generally excluded because Repeatable Read does not use the key-range protection of Serializable.
Serializable
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRANSACTION;
SELECT *
FROM orders
WHERE customer_id = 1;
COMMIT TRANSACTION;
Here SQL Server can acquire key-range locks so that another transaction cannot freely insert rows into the range protected by the query until the first transaction finishes.
What happens under contention?
Stronger isolation does not make conflicts disappear.
It changes how conflicts are handled.
| Mechanism | Typical consequence |
|---|---|
| Ordinary row locking | One transaction waits |
| Range / gap locking | Inserts or updates in a protected range wait |
| Deadlock detection | Database chooses a transaction to abort |
| PostgreSQL SSI | Dangerous dependency causes serialization failure |
| Optimistic concurrency control | Version/check failure forces application retry |
| Unique/check constraint | Invalid write is rejected |
So when somebody says:
"Serializable is slower. "
that is too vague to be useful.
The real questions are:
How often do transactions conflict?
How long are transactions open?
How many rows/ranges do they touch?
Do they block or abort?
What is the retry rate?
Are indexes allowing narrow access paths?
Is the application doing network calls while holding a transaction open?
A workload with short, mostly independent transactions can behave very differently from one where hundreds of requests compete for the same logical resource.
A practical decision table
| Situation | Usually consider |
|---|---|
| Simple reporting requiring a stable point-in-time view | Snapshot / Repeatable Read depending on database |
| Prevent one known row from changing during a decision | SELECT. .. FOR UPDATE or an atomic conditional update |
| Unique username/email/order number | UNIQUE constraint |
| Parent-child integrity | FOREIGN KEY |
| Simple value invariant on one row | CHECK constraint or atomic update |
| Capacity/inventory represented by one counter row | Atomic conditional UPDATE can often be simplest |
| Invariant spans several rows | Explicit locking protocol or Serializable |
| Invariant depends on a query/predicate | Serializable is often easier to reason about |
| Several independent services can modify the same invariant | Prefer database-enforced rules or Serializable over informal application conventions |
| Long-running analytical read | Snapshot-style reading may be preferable to extensive locks |
This is a design table, not a universal prescription. The exact solution depends on how the invariant is represented and how every writer interacts with it.
The easiest way to reason about isolation problems
For every concurrency-sensitive operation, write down four things.
What did the transaction read?
Suppose the doctors table contains:
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
Example:
SELECT *
FROM doctors
WHERE on_call = true;
| name | on_call |
|---|---|
| Alice | true |
| Bob | true |
What decision did it derive?
There are at least two doctors,
therefore one may leave.
What did it write?
UPDATE doctors
SET on_call = false
WHERE id = ?;
Could another transaction make the same decision concurrently while writing somewhere else?
If yes, you may have:
write skew
predicate conflict
serialization anomaly
even if neither transaction modifies the same row.
This method is usually more useful than trying to memorize isolation-level definitions.
The main conceptual trap
The following reasoning is wrong:
I use Repeatable Read.
Therefore my transaction always sees consistent data.
Therefore decisions based on that data are safe.
Therefore two concurrent transactions cannot violate
a business invariant.
The first part may be true for a snapshot-based database.
The conclusion does not follow.
Correct reasoning is:
Repeatable Read may give each transaction
an internally stable view.
But two different transactions can have
compatible-looking individual snapshots
while making mutually incompatible decisions.
Serializable additionally constrains
the combined committed execution.
Database implementations compared
| Database | Core technique | Repeatable Read | Serializable |
|---|---|---|---|
| PostgreSQL | MVCC | Snapshot Isolation; stable snapshot, no ordinary phantoms, but serialization anomalies remain possible | SSI: MVCC + dependency/predicate tracking + transaction aborts |
| MySQL InnoDB | MVCC + row/index locking | Stable ordinary consistent reads; locking reads/writes use record, gap, and next-key locking | Stronger locking semantics; explicit transactions make ordinary reads shared locking reads |
| SQL Server | Locks plus optional row versioning | Shared locks on rows retained until transaction end; phantoms remain possible | Shared/exclusive locking plus key-range locks |
| SQL Server SNAPSHOT | Row versioning | Separate isolation mode rather than SQL Server's Repeatable Read | Still distinct from lock-based Serializable |
PostgreSQL documents that Serializable builds on Snapshot Isolation by adding serialization-anomaly detection. MySQL documents its combination of transaction snapshots with record/gap/next-key locking and its stronger behavior under Serializable. Microsoft documents SQL Server's transition from row protection at Repeatable Read to range protection at Serializable.
Final mental model
Do not memorize:
Repeatable Read = good
Serializable = better
Memorize this instead:
READ COMMITTED
"What is committed when this statement runs?"
REPEATABLE READ / SNAPSHOT-STYLE READING
"Give this transaction a stable view."
SERIALIZABLE
"Only allow committed results that can be explained
as some one-at-a-time execution."
Then remember that the implementation differs:
PostgreSQL:
stable MVCC snapshot
+
dependency detection
+
abort/retry
MySQL InnoDB:
MVCC snapshots
+
record/gap/next-key locking
+
stronger locking at Serializable
SQL Server:
row locking at Repeatable Read
+
key-range locking at Serializable
SQL Server SNAPSHOT:
separate row-versioned isolation mode
The practical lesson is:
A transaction seeing an internally consistent snapshot does not imply that several transactions can safely make concurrent decisions from those snapshots.
That is the gap Serializable isolation is intended to close.
References
PostgreSQL's current transaction-isolation documentation describes its stronger-than-standard Repeatable Read behavior, Snapshot Isolation implementation, Serializable Snapshot Isolation, predicate locks, serialization failures, and SQLSTATE 40001.
The MySQL InnoDB documentation describes Repeatable Read as the default, transaction-level consistent reads, the distinction between ordinary and locking reads, gap/next-key locking, and the stronger locking behavior of Serializable transactions.
Microsoft's SQL Server documentation describes Repeatable Read as retaining locks on already-read data without protecting key ranges, while Serializable adds key-range locking; it also documents SNAPSHOT as a separate isolation model.