Loading content...
If concurrent transactions make you anxious—if you want absolute certainty that no concurrency anomaly of any kind is possible—Serializable isolation is your answer. At this level, the database guarantees that the outcome of concurrent transactions is identical to running them one after another in some serial order.
Serializable represents the theoretical ideal: perfect consistency, no anomalies, complete predictability. But perfection comes at a price. Understanding when that price is worth paying—and when it isn't—is key to effective database engineering.
By the end of this page, you will understand Serializable isolation's complete guarantee, the two primary implementation strategies (strict two-phase locking with predicate locks vs Serializable Snapshot Isolation), why this level has the highest performance cost, and when the consistency guarantees justify that cost.
Serializable is the highest isolation level in the SQL standard. It provides the ultimate guarantee: the execution of concurrent transactions is equivalent to some serial execution of those transactions.
Definition (Informal):
If you run transactions T1 and T2 concurrently at the Serializable level, the result will be the same as if you had run either:
The database doesn't promise which serial order—just that the outcome matches some serial order.
SQL Standard Definition:
Serializable prevents all phenomena defined by the standard:
| Anomaly Type | Read Uncommitted | Read Committed | Repeatable Read | Serializable |
|---|---|---|---|---|
| Dirty Read | ❌ Possible | ✅ Prevented | ✅ Prevented | ✅ Prevented |
| Non-Repeatable Read | ❌ Possible | ❌ Possible | ✅ Prevented | ✅ Prevented |
| Phantom Read | ❌ Possible | ❌ Possible | ❌ Possible* | ✅ Prevented |
| Write Skew | ❌ Possible | ❌ Possible | ❌ Possible | ✅ Prevented |
*Note: SQL standard permits phantoms at RR, but many implementations prevent them.
Beyond the SQL Standard: Write Skew:
Serializable also prevents write skew, an anomaly not explicitly mentioned in the SQL standard but absolutely possible at lower isolation levels. Write skew occurs when two transactions read the same data and then make different modifications based on what they read, resulting in a state that violates application invariants.
1234567891011121314151617181920212223242526272829303132
-- Setting Serializable isolation level in various databases -- SQL ServerSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;BEGIN TRANSACTION; -- All reads acquire range locks, preventing all anomalies SELECT * FROM Accounts WHERE balance > 1000; -- Range lock on "balance > 1000" prevents any changes/inserts to this rangeCOMMIT; -- PostgreSQLSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;-- or: BEGIN ISOLATION LEVEL SERIALIZABLE;BEGIN; -- Uses Serializable Snapshot Isolation (SSI) -- No blocking, but conflicts cause transaction abort SELECT * FROM accounts WHERE balance > 1000;COMMIT; -- OracleSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;-- Oracle's implementation is snapshot-based-- But requires careful handling of write conflicts -- MySQL/InnoDBSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;START TRANSACTION; -- Converts all SELECT to SELECT ... FOR SHARE (previously LOCK IN SHARE MODE) -- Gap locks prevent phantom inserts SELECT * FROM accounts WHERE balance > 1000; -- Implicit: FOR SHARECOMMIT;Serializable isolation doesn't mean transactions actually run one at a time. Transactions can execute concurrently—they just produce results equivalent to some serial ordering. The database uses sophisticated algorithms (locks or SSI) to enforce this equivalence while allowing concurrency.
Write skew is perhaps the most insidious concurrency anomaly because it can occur even at Repeatable Read in some implementations. It involves two transactions reading the same data and making non-conflicting writes that together violate a constraint.
Classic Example: On-Call Doctors
Consider a hospital rule: at least one doctor must always be on call. Both Dr. Alice and Dr. Bob are currently on call. Each wants to take themselves off call, but only if the other is still on call.
| Time | Transaction A (Alice) | Transaction B (Bob) | Constraint Status |
|---|---|---|---|
| T1 | BEGIN | BEGIN | Alice=ON, Bob=ON ✅ |
| T2 | Check: Is Bob on call? → Yes | Alice=ON, Bob=ON ✅ | |
| T3 | Check: Is Alice on call? → Yes | Alice=ON, Bob=ON ✅ | |
| T4 | Set Alice = OFF (Bob is on, it's safe) | Alice=OFF, Bob=ON ✅ | |
| T5 | Set Bob = OFF (Alice is on, it's safe) | Alice=OFF, Bob=OFF ❌ | |
| T6 | COMMIT | COMMIT | Constraint violated! |
123456789101112131415161718192021222324252627282930313233343536373839
-- Write Skew Demonstration-- This can occur at Repeatable Read but NOT at Serializable -- SetupCREATE TABLE OnCallDoctors ( doctor_id VARCHAR(10) PRIMARY KEY, is_on_call BOOLEAN NOT NULL);INSERT INTO OnCallDoctors VALUES ('Alice', true), ('Bob', true);-- Constraint: COUNT(*) WHERE is_on_call = true >= 1 -- Transaction A (Alice wants to go off-call)SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;BEGIN; -- Check if anyone else is on call SELECT COUNT(*) FROM OnCallDoctors WHERE is_on_call = true AND doctor_id <> 'Alice'; -- Returns: 1 (Bob is on call) -- Safe to go off-call UPDATE OnCallDoctors SET is_on_call = false WHERE doctor_id = 'Alice';COMMIT; -- Transaction B (Bob wants to go off-call) - running concurrentlySET TRANSACTION ISOLATION LEVEL REPEATABLE READ;BEGIN; -- Check if anyone else is on call SELECT COUNT(*) FROM OnCallDoctors WHERE is_on_call = true AND doctor_id <> 'Bob'; -- Returns: 1 (Alice is on call - per B's snapshot) -- Safe to go off-call (or so B thinks) UPDATE OnCallDoctors SET is_on_call = false WHERE doctor_id = 'Bob';COMMIT; -- Result: Both Alice and Bob are off-call, constraint violated!-- Each transaction's logic was correct individually, but together they broke the invariant -- At SERIALIZABLE, one of these transactions would abort:-- PostgreSQL SSI: Detects rw-conflict and aborts one transaction-- SQL Server: Predicate locks would cause one transaction to block/abortWrite skew isn't just a theoretical concern. Real-world examples include: double-booking meeting rooms, overdrawing accounts across linked cards, exceeding inventory in multi-warehouse systems, and bypassing rate limits through parallel requests. Any invariant spanning multiple rows is susceptible.
The traditional approach to Serializable isolation uses Strict Two-Phase Locking (S2PL) enhanced with predicate locks or range locks. While normal locks protect specific rows, predicate locks protect entire conditions—preventing not just modifications to existing rows but also insertions of new rows that would match a query.
How Predicate Locking Works:
SELECT * WHERE condition, the database locks not just the matching rows but the entire predicate spacecondition is blocked123456789101112131415161718192021222324252627282930313233343536
-- SQL Server Serializable with Predicate/Range Locks SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;BEGIN TRANSACTION; -- This query establishes a range lock on salary BETWEEN 50000 AND 100000 SELECT employee_id, name, salary FROM Employees WHERE salary BETWEEN 50000 AND 100000; -- Returns: 45 employees -- Range lock covers: -- 1. All existing rows with salary in [50000, 100000] -- 2. The "gaps" where new rows could be inserted -- Long processing... WAITFOR DELAY '00:00:30'; -- Re-query: guaranteed same 45 employees SELECT COUNT(*) FROM Employees WHERE salary BETWEEN 50000 AND 100000; -- Returns: 45 (phantoms prevented)COMMIT; -- Concurrent Transaction (during first transaction's delay)BEGIN TRANSACTION; -- Try to insert an employee with salary in the locked range INSERT INTO Employees (name, salary) VALUES ('New Hire', 75000); -- ⏳ BLOCKED! Waiting for first transaction's range lock -- Try to update an existing employee to fall into the range UPDATE Employees SET salary = 80000 WHERE employee_id = 999; -- ⏳ BLOCKED! Would violate the predicate lock -- Only operations outside the locked range can proceed INSERT INTO Employees (name, salary) VALUES ('Executive', 150000); -- ✅ Proceeds - 150000 is outside [50000, 100000]COMMIT;Index Range Locking:
In practice, databases implement predicate locking through index range locks. They lock ranges of index entries rather than trying to evaluate arbitrary predicates. This is efficient when queries use indexed columns but may be overly broad for complex conditions.
| Strategy | Precision | Overhead | Used By |
|---|---|---|---|
| Index Range Locks | Good on indexed cols | Moderate | SQL Server |
| Next-Key Locks | Record + gap before | Moderate | MySQL InnoDB |
| Table Locks (fallback) | Very coarse | High contention | When no suitable index |
If a Serializable query can't use an index, the database may lock the entire table. A query like 'SELECT * FROM orders WHERE YEAR(order_date) = 2024' that can't use an index might lock all of 'orders', severely impacting concurrency. Always ensure Serializable queries have supporting indexes.
PostgreSQL revolutionized Serializable isolation with Serializable Snapshot Isolation (SSI)—an approach that provides true serializability without the locking overhead of traditional methods. SSI is built on top of Snapshot Isolation (similar to Repeatable Read) with additional tracking to detect and abort transactions that would violate serializability.
How SSI Works:
12345678910111213141516171819202122232425262728293031323334353637383940
-- PostgreSQL SSI in Action -- SetupCREATE TABLE account ( id INTEGER PRIMARY KEY, balance DECIMAL(10,2) NOT NULL);INSERT INTO account VALUES (1, 500), (2, 500);-- Invariant: total balance = 1000 -- Transaction 1: Move money from account 1 to 2BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT balance FROM account WHERE id = 1; -- Reads 500 -- rw-dependency: T1 read row 1 -- T2 commits between here and T1's update UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2;COMMIT;-- May fail with: ERROR: could not serialize access due to read/write dependencies -- Transaction 2: Move money from account 2 to 1 (concurrent)BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT balance FROM account WHERE id = 2; -- Reads 500 -- rw-dependency: T2 read row 2 UPDATE account SET balance = balance - 200 WHERE id = 2; UPDATE account SET balance = balance + 200 WHERE id = 1;COMMIT; -- SSI detects the cycle:-- T1 reads row 1, T2 writes row 1 → rw-antidependency T1→T2-- T2 reads row 2, T1 writes row 2 → rw-antidependency T2→T1-- Cycle: T1→T2→T1 → one must abort -- Key difference from locking:-- Neither transaction blocks the other during execution-- Conflict is detected at commit time-- Failed transaction must be retried by applicationSSI's optimistic approach means transactions can fail at commit time. Applications MUST implement retry logic. Unlike locking (which waits), SSI aborts conflicting transactions immediately, expecting the application to retry. Test your retry logic with concurrent load.
MySQL/InnoDB implements Serializable by essentially converting all reads to locking reads. When you execute a SELECT at Serializable level, InnoDB implicitly converts it to SELECT ... FOR SHARE (or LOCK IN SHARE MODE in older syntax).
MySQL Serializable Behavior:
SELECT statements acquire shared locks (not just use snapshots)12345678910111213141516171819202122232425262728293031
-- MySQL InnoDB Serializable Behavior SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;START TRANSACTION; -- This simple SELECT actually acquires S locks! SELECT * FROM products WHERE category = 'Electronics'; -- Equivalent to: -- SELECT * FROM products WHERE category = 'Electronics' FOR SHARE; -- All matched rows are S-locked -- Gaps are gap-locked (preventing phantom inserts) -- This means: ANY concurrent transaction trying to UPDATE -- or DELETE these rows will BLOCKCOMMIT; -- Concurrent transaction (will block)START TRANSACTION; -- Attempts to acquire X lock on a row Select'd by first transaction UPDATE products SET price = price * 1.1 WHERE product_id = 100; -- ⏳ BLOCKED waiting for S lock to releaseCOMMIT; -- To see the locks MySQL is taking:SELECT * FROM performance_schema.data_locks; -- You can also see waiting locks:SELECT * FROM performance_schema.data_lock_waits; -- The key implication: Serializable in MySQL means MORE blocking-- than lower levels, because reads now compete with writes| Aspect | Repeatable Read (Default) | Serializable |
|---|---|---|
| SELECT behavior | Consistent read (MVCC) | Locking read (FOR SHARE) |
| Read locks acquired | No | Yes (S locks) |
| Gap locks | Yes (for locking reads) | Yes (for all reads) |
| Readers block writers | No | Yes |
| Writers block readers | Only locking reads | All reads |
| Concurrency | Higher | Lower |
With autocommit=1 (default), each statement is its own transaction. In Serializable mode, each autocommit SELECT acquires and immediately releases locks—minimal impact. But with autocommit=0 or explicit transactions, locks accumulate until commit, potentially causing significant blocking.
Serializable isolation provides maximum consistency at maximum cost. Understanding the performance implications is critical for deciding when this level is justified.
Performance Costs by Implementation:
| Implementation | Blocking | Abort Rate | Lock Overhead | Best For |
|---|---|---|---|---|
| S2PL + Predicate Locks | High | Low (conflicts wait) | High | Low contention, write-heavy |
| SSI (PostgreSQL) | None | Can be high | Moderate tracking | Read-heavy, low conflict |
| Locking Reads (MySQL) | Very High | Moderate | High | Short transactions |
12345678910111213141516171819202122232425262728293031323334353637383940414243
-- Measuring Serializable overhead (PostgreSQL) -- Test 1: Read operations throughput-- Setup: Create a table with 100,000 rows -- At Read Committed:BEGIN;SET TRANSACTION ISOLATION LEVEL READ COMMITTED; EXPLAIN ANALYZE SELECT * FROM large_table WHERE category = 'A'; -- Time: ~50ms, no additional overheadCOMMIT; -- At Serializable:BEGIN ISOLATION LEVEL SERIALIZABLE; EXPLAIN ANALYZE SELECT * FROM large_table WHERE category = 'A'; -- Time: ~55ms, slight overhead for dependency trackingCOMMIT;-- SSI overhead is low for individual queries -- Test 2: High-contention write scenario-- Multiple concurrent updates to overlapping rows -- At Serializable, expect:-- - More transactions aborted due to serialization failures-- - Need for retry logic-- - Overall lower throughput under contention -- Monitoring SSI aborts in PostgreSQL:SELECT datname, conflicts, confl_tablespace, confl_snapshotFROM pg_stat_database_conflictsWHERE datname = current_database(); -- Check for serialization failures in pg_stat_user_tables:SELECT relname, n_tup_ins, n_tup_upd, n_tup_delFROM pg_stat_user_tables;There's a tempting argument: 'Just use Serializable everywhere for safety.' In practice, this devastates performance. Most applications don't need Serializable semantics, and paying the cost for operations that don't require it wastes resources and causes unnecessary blocking.
Given its performance cost, Serializable should be reserved for scenarios where absolute correctness is non-negotiable and the application invariants cannot be expressed through database constraints alone.
Ideal Candidates for Serializable:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
-- Use Case 1: Financial Transfer with Total Balance Invariant-- Total across all accounts in a group must stay constant BEGIN ISOLATION LEVEL SERIALIZABLE; -- Read source and destination balances SELECT balance FROM accounts WHERE id IN (@src, @dst); -- Verify sufficient funds IF (SELECT balance FROM accounts WHERE id = @src) < @amount THEN RAISE EXCEPTION 'Insufficient funds'; END IF; -- Execute transfer UPDATE accounts SET balance = balance - @amount WHERE id = @src; UPDATE accounts SET balance = balance + @amount WHERE id = @dst;COMMIT;-- Serializable prevents: another transaction from simultaneously -- reading/modifying these accounts in a way that breaks the invariant -- Use Case 2: Meeting Room Double-Booking PreventionBEGIN ISOLATION LEVEL SERIALIZABLE; -- Check if room is available SELECT COUNT(*) FROM room_bookings WHERE room_id = @room AND start_time < @end_time AND end_time > @start_time; -- If available, book it IF count = 0 THEN INSERT INTO room_bookings (room_id, start_time, end_time, user_id) VALUES (@room, @start_time, @end_time, @user); ELSE RAISE EXCEPTION 'Room not available'; END IF;COMMIT;-- Without Serializable, two concurrent requests could both see -- count=0 and both insert bookings for the same slot -- Use Case 3: Inventory Allocation Across WarehousesBEGIN ISOLATION LEVEL SERIALIZABLE; -- Check total available across all warehouses SELECT SUM(quantity) AS total FROM warehouse_inventory WHERE product_id = @product; IF total >= @order_qty THEN -- Allocate from warehouses (complex logic here) UPDATE warehouse_inventory SET quantity = quantity - allocation WHERE product_id = @product AND warehouse_id = @chosen_warehouse; END IF;COMMIT;-- Serializable ensures total inventory check is consistent-- with the allocation that followsBefore reaching for Serializable, consider SELECT ... FOR UPDATE. Explicitly locking the rows you intend to modify often achieves the same protection with less overhead. Serializable is for when you can't predict which rows need locking until after the reads.
Whether using locking (deadlocks) or SSI (serialization failures), Serializable transactions may need to be retried. Robust retry logic is essential for production use.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
# Python example: Robust retry logic for Serializable transactions import psycopg2import randomimport timefrom functools import wraps class SerializationFailure(Exception): """Raised when transaction must be retried""" pass def with_serializable_retry(max_retries=3, base_delay=0.1): """ Decorator for functions that execute Serializable transactions. Automatically retries on serialization failures with exponential backoff. """ def decorator(func): @wraps(func) def wrapper(*args, **kwargs): last_exception = None for attempt in range(max_retries): try: return func(*args, **kwargs) except psycopg2.errors.SerializationFailure as e: last_exception = e # Exponential backoff with jitter delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) print(f"Serialization failure (attempt {attempt + 1}), " f"retrying in {delay:.2f}s...") time.sleep(delay) except psycopg2.errors.DeadlockDetected as e: last_exception = e delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) print(f"Deadlock detected (attempt {attempt + 1}), " f"retrying in {delay:.2f}s...") time.sleep(delay) # All retries exhausted raise last_exception return wrapper return decorator # Usage example@with_serializable_retry(max_retries=5, base_delay=0.05)def transfer_funds(conn, from_id, to_id, amount): """Transfer funds between accounts with Serializable isolation""" with conn.cursor() as cur: # Start Serializable transaction cur.execute("BEGIN ISOLATION LEVEL SERIALIZABLE") try: # Read both balances cur.execute( "SELECT id, balance FROM accounts WHERE id IN (%s, %s)", (from_id, to_id) ) accounts = {row[0]: row[1] for row in cur.fetchall()} # Verify sufficient funds if accounts[from_id] < amount: cur.execute("ROLLBACK") raise ValueError("Insufficient funds") # Execute transfer cur.execute( "UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, from_id) ) cur.execute( "UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, to_id) ) cur.execute("COMMIT") return True except Exception: cur.execute("ROLLBACK") raiseIf your retry rate exceeds 5-10%, you likely have a design issue. Consider: Are transactions too long? Are there hot rows? Can you use lower isolation for some operations? High retry rates indicate excessive contention that retries alone won't solve.
Serializable provides the ultimate consistency guarantee—complete freedom from concurrency anomalies—at significant performance cost. Let's consolidate the key insights:
What's Next:
We've examined all four SQL standard isolation levels. The final challenge is choosing the right level for each operation. The next page provides a decision framework for Level Selection—how to match isolation requirements to business needs while minimizing performance impact.
You now understand Serializable isolation comprehensively—its total anomaly prevention, write skew handling, locking vs SSI implementations, MySQL's locking reads, performance implications, and when to deploy it. This knowledge enables you to use the highest isolation level appropriately, not excessively.