Mid-level 3 min · March 17, 2026

REPEATABLE READ Phantom Inventory — DBMS Transactions

REPEATABLE READ does not prevent phantom reads — new inserts appear in range scans, causing $50k stock discrepancy.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • ACID defines the contract: Atomicity, Consistency, Isolation, Durability — violations cause silent data corruption
  • Isolation levels trade safety for throughput: READ COMMITTED is the production default for most web apps
  • MVCC keeps reads from blocking writes, but phantom reads still sneak through in REPEATABLE READ
  • Deadlocks are an application design failure, not a database bug — always lock resources in a consistent order
  • The biggest mistake: assuming your app's isolation level matches the database default (they often don't)
Plain-English First

Think of a transaction like a bank transfer. You want to send $100 from your account to a friend's. The database must either do both steps (deduct and add) or none. If the server dies after deducting but before adding, your money shouldn't vanish. That's ACID in action.

ACID in Practice: Beyond the Acronym

While every textbook defines ACID, seeing it handle a failure in real-time clarifies why we pay the performance tax for a relational engine. Atomicity is the 'all-or-nothing' guarantee, but Durability is the 'promise'—ensuring that once a COMMIT is acknowledged, even a total power failure won't revert the change.

ExampleSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- io.thecodeforge: Atomic Bank Transfer Example
BEGIN;

-- Step 1: Deduct from source
UPDATE io_thecodeforge.accounts 
SET balance = balance - 500, updated_at = NOW() 
WHERE account_id = 'ACC-001' AND balance >= 500;

-- Step 2: Add to destination
UPDATE io_thecodeforge.accounts 
SET balance = balance + 500, updated_at = NOW() 
WHERE account_id = 'ACC-002';

-- If a network timeout or constraint violation occurs here,
-- the entire block is invalidated.
COMMIT;

-- PRO TIP: In Spring Boot, use @Transactional(propagation = Propagation.REQUIRED)
-- to wrap these calls in a single proxy-managed transaction.
Output
Query OK, 2 rows affected (0.01 sec)
Transaction committed successfully.
The Outbox Pattern Rescue
If you publish a message inside a transaction, and the transaction rolls back, the message is still sent. Use the transactional outbox pattern: insert the message into a separate table within the same transaction, then have a background process read and send it.
Production Insight
The most common ACID failure in production is forgetting that Atomicity applies only to database operations.
External side effects (HTTP calls, message queues) are not rolled back — you need the Outbox Pattern for that.
Rule: your transaction scope must include only database work, or use a compensating transaction mechanism.
Key Takeaway
ACID is not magic — it guarantees database consistency within boundaries you define.
Atomicity does not extend to side effects outside the database.
Always design for rollback by cleaning up partial work in the application layer.

Isolation Levels and Read Phenomena

Isolation levels are a slider between 'Perfect Safety' and 'Maximum Speed.' As you lower the level, you introduce specific anomalies: 1. Dirty Reads: Reading data that hasn't been committed yet. 2. Non-Repeatable Reads: A row changes its value while you're still looking at it. 3. Phantom Reads: New rows appear in a range query because another transaction inserted them.

ExampleSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- io.thecodeforge: Demonstrating Read Phenomena

-- 1. READ COMMITTED (Default for Postgres)
-- Prevents Dirty Reads but allows Non-Repeatable Reads.
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 2. REPEATABLE READ (Default for MySQL InnoDB)
-- Uses MVCC (Multi-Version Concurrency Control) to ensure you see a snapshot.
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 3. SERIALIZABLE (Highest Safety)
-- Prevents Phantoms by placing range locks or using predicate locking.
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Production Pattern: Use 'SELECT ... FOR UPDATE' to skip isolation logic
-- and force a row-level write lock immediately.
BEGIN;
SELECT balance FROM io_thecodeforge.accounts WHERE account_id = 'ACC-001' FOR UPDATE;
-- This row is now locked until we COMMIT.
Output
Transaction isolation level set to REPEATABLE READ.
Production Insight
Phantom reads are the silent killer of reporting accuracy.
Non-repeatable reads cause inconsistent lookups (e.g., fetching the same row twice in a transaction yields different values).
Rule: don't assume REPEATABLE READ is enough for range queries — test with concurrent inserts.
Key Takeaway
Isolation levels prevent specific anomalies — know which ones apply to your use case.
READ COMMITTED prevents dirty reads but allows non-repeatable and phantom reads.
SERIALIZABLE prevents all three but reduces concurrency — use it for reports, not every request.
Choosing an Isolation Level
IfRead-only, tolerance for stale data
UseUse READ UNCOMMITTED / READ COMMITTED
IfTransactional writes with moderate contention
UseUse READ COMMITTED (default for PostgreSQL)
IfNeed consistent view within a transaction, no phantoms
UseUse REPEATABLE READ + explicit range locks
IfAbsolutely no anomalies allowed, low concurrency expected
UseUse SERIALIZABLE

Deadlocks: Detection and Prevention

A deadlock occurs when Transaction A holds Lock 1 and wants Lock 2, while Transaction B holds Lock 2 and wants Lock 1. They will wait forever. Most modern RDBMS (Postgres, MySQL) run a background 'Deadlock Detector' thread that identifies these cycles and kills the 'cheapest' transaction to break the loop.

ExampleSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- io.thecodeforge: Deadlock Prevention via Deterministic Ordering

-- BAD: Locking in random order based on app logic
-- TX1: Update A, then B
-- TX2: Update B, then A  <-- High Deadlock Risk!

-- GOOD: Always sort IDs before locking
BEGIN;
-- By ordering our IDs, all transactions wait in the same 'queue'
SELECT * FROM io_thecodeforge.accounts 
WHERE account_id IN ('ACC-001', 'ACC-002') 
ORDER BY account_id ASC 
FOR UPDATE;

UPDATE io_thecodeforge.accounts SET balance = balance - 100 WHERE account_id = 'ACC-001';
UPDATE io_thecodeforge.accounts SET balance = balance + 100 WHERE account_id = 'ACC-002';
COMMIT;
Output
Rows locked in ascending order. No deadlock cycle possible.
Deadlock as a Traffic Circle
  • Each transaction (car) holds a resource (position in the roundabout).
  • Each wants another resource that is held by another car.
  • The database traffic cop (deadlock detector) picks one car to reverse out.
  • That transaction is rolled back and must retry.
  • You can avoid the gridlock by enforcing a consistent direction of travel (lock order).
Production Insight
Deadlocks are often an application design failure, not a database config problem.
The classic fix: order your lock acquisition (e.g., always process accounts by ID ascending).
Rule: if you see recurring deadlocks, fix the code — don't just increase lock timeout.
Key Takeaway
Deadlocks are inevitable in high-concurrency systems — your app must handle retries.
Standardize lock ordering across all code paths.
If you use serializable isolation, be prepared for serialization failures (40001) and retry.

MVCC: How Isolation Levels Actually Work

Multi-Version Concurrency Control (MVCC) allows each transaction to see a snapshot of the data at the start of the transaction (or at the statement level, depending on isolation). Instead of locking every row a reader might touch, the database keeps multiple versions of each row. When a transaction writes, it creates a new version; concurrent readers see the old version until the writer commits. This is why PostgreSQL can run thousands of concurrent SELECTs without blocking — readers never block writers, and writers never block readers (unless they conflict).

ExampleSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- io.thecodeforge: MVCC Snapshot Demonstration (PostgreSQL)

-- Session 1: Start a repeatable read transaction
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM io_thecodeforge.accounts WHERE account_id = 'ACC-001';
-- Returns 1000

-- Session 2 (concurrent): Update and commit
BEGIN;
UPDATE io_thecodeforge.accounts SET balance = 800 WHERE account_id = 'ACC-001';
COMMIT;

-- Session 1: Still sees 1000 (its snapshot is from the start)
SELECT balance FROM io_thecodeforge.accounts WHERE account_id = 'ACC-001';
-- Returns 1000

COMMIT;

-- After Session 1 commits, new queries see 800.
Output
REPEATABLE READ snapshot isolation works as expected.
Production Insight
MVCC works beautifully until you have long-running write transactions that accumulate many old row versions.
Vacuum is essential: in PostgreSQL, autovacuum must keep up with transaction ID wraparound and bloat.
Rule: monitor your transaction age — any transaction open for hours will prevent cleanup of dead tuples.
Key Takeaway
MVCC is the key reason modern databases handle high concurrency without locking everything.
Understanding MVCC snapshots explains why REPEATABLE READ sees a point-in-time view.
PostgreSQL's SERIALIZABLE uses Serializable Snapshot Isolation (SSI) — it detects conflicts and forces retries.

Transaction Management in Practice: Application-Level Patterns

Real-world apps rarely use raw SQL transactions. Frameworks like Spring Boot, Hibernate, and Entity Framework manage transaction boundaries declaratively. You need to understand: - Transaction propagation: REQUIRED, REQUIRES_NEW, NESTED — when does a new transaction start? - Isolation level override: @Transactional(isolation = Isolation.REPEATABLE_READ) can clash with database defaults. - Transactional outbox pattern for reliable messaging. - Handling rollback-only exceptions: sometimes a caught exception still marks the transaction for rollback. - Setting timeout and read-only hints to optimize connection usage.

ExampleJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge.service.TransferService
@Service
public class TransferService {

    @Transactional(isolation = Isolation.READ_COMMITTED, timeout = 5)
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepo.findByIdForUpdate(fromId);
        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientBalanceException();
        }
        Account to = accountRepo.findByIdForUpdate(toId);
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        accountRepo.save(from);
        accountRepo.save(to);
        // If an exception is thrown here (e.g., optimistic lock failure),
        // the entire transaction rolls back automatically.
    }
}
Output
Transaction completes with no issues.
Rollback-Only Trap
If you catch an exception inside a @Transactional method and do not rethrow it, the transaction is still marked rollback-only. The commit will fail with Transaction rolled back because it has been marked as rollback-only. Always rethrow the exception or use TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() deliberately.
Production Insight
The #1 Spring Boot transaction mistake is using @Transactional on a private method — it's ignored because the proxy can't intercept it.
Another common failure: calling @Transactional method from within the same class bypasses the proxy.
Rule: always use public methods and call @Transactional from another bean to trigger the proxy.
Key Takeaway
Annotations like @Transactional are syntactic sugar — they still rely on database locks and isolation levels.
Read-only transactions can be optimized by the database — always set readOnly=true for SELECT-only operations.
Never assume a transaction that catches an exception commits — check for rollback-only state.
● Production incidentPOST-MORTEMseverity: high

The Phantom Inventory: How REPEATABLE READ Caused a $50k Stock Discrepancy

Symptom
Daily reconciliation reports were inconsistent by hundreds of units, yet individual transaction logs showed no errors.
Assumption
The team assumed REPEATABLE READ prevented all read phenomena, so concurrency problems were ruled out.
Root cause
The reporting query ran under REPEATABLE READ. While it prevented non-repeatable reads, it did not prevent phantom reads. New inventory additions (inserts) from concurrent warehouse receipt transactions could appear in the report's second range scan, causing phantom rows.
Fix
Changed the reporting transaction to use SERIALIZABLE isolation level (PostgreSQL's SSI mode) and added explicit locking with SELECT ... FOR UPDATE on the product range.
Key lesson
  • REPEATABLE READ stops row updates from changing your view, but it does not stop new rows from appearing.
  • For any report that aggregates across a dynamic range of rows, use SERIALIZABLE or lock the expected key range.
  • Always verify which anomalies your isolation level actually prevents — don't assume it's all three.
Production debug guideSymptom → Action guide for common transaction failures4 entries
Symptom · 01
High lock wait timeout errors (ERROR: 1205 Lock wait timeout exceeded)
Fix
Run SHOW ENGINE INNODB STATUS (MySQL) or SELECT * FROM pg_locks WHERE NOT granted (PostgreSQL) to find the blocking transaction.
Symptom · 02
Deadlock detected (error 1213 or deadlock detected)
Fix
Check the deadlock details in database logs. Identify the two transactions and the resources involved. Ensure they lock resources in the same order.
Symptom · 03
Transaction log growing rapidly (WAL or redo log)
Fix
Look for long-running open transactions. Run SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction' or SHOW PROCESSLIST (MySQL). Kill idle transactions over a threshold.
Symptom · 04
Phantom rows appearing in reports
Fix
Set the reporting transaction isolation level to SERIALIZABLE or use range locks (FOR UPDATE with condition). Verify by testing with concurrent inserts.
★ Quick Transaction Debug CommandsCommands for diagnosing transaction issues in PostgreSQL and MySQL (InnoDB).
Deadlock error
Immediate action
Check the database log for deadlock details
Commands
PostgreSQL: tail -f /var/log/postgresql/postgresql-*.log | grep -i deadlock
MySQL: SHOW ENGINE INNODB STATUS\G
Fix now
Ensure all transactions lock tables/rows in the same order. Add retry logic in the application.
Long-running transaction+
Immediate action
Find the PID and duration
Commands
PostgreSQL: SELECT pid, state, query_start, now() - query_start AS duration FROM pg_stat_activity WHERE state = 'active';
MySQL: SELECT * FROM information_schema.INNODB_TRX WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;
Fix now
Kill the transaction (SELECT pg_terminate_backend(pid) or KILL CONNECTION id). Add statement_timeout on the application connection.
Lock contention+
Immediate action
Identify blocked queries
Commands
PostgreSQL: SELECT blocked_locks.pid AS blocked_pid, blocking_locks.pid AS blocking_pid FROM pg_locks blocked_locks JOIN pg_locks blocking_locks ON...;
MySQL: SELECT * FROM sys.innodb_lock_waits;
Fix now
Optimize the slow query holding locks. Consider decreasing transaction isolation level or using index to reduce lock range.
Isolation Levels Quick Reference
Isolation LevelDirty ReadNon-repeatable ReadPhantom ReadStandard SQL
READ UNCOMMITTEDPossiblePossiblePossibleYes
READ COMMITTEDPreventedPossiblePossibleYes
REPEATABLE READPreventedPreventedPossible (InnoDB prevents)Yes
SERIALIZABLEPreventedPreventedPreventedYes

Key takeaways

1
ACID is the gold standard
Atomicity, Consistency, Isolation, and Durability ensure data integrity even during hardware failure.
2
Isolation levels are a trade-off
READ COMMITTED is usually the sweet spot for web apps, while REPEATABLE READ is safer for complex reporting.
3
MVCC (Multi-Version Concurrency Control) allows readers to not block writers in engines like Postgres and InnoDB.
4
Deadlocks are an architectural failure, not a database bug. Prevent them by implementing consistent lock ordering in your code.
5
PostgreSQL is 'Pessimistic' by default with row-locks, whereas some distributed DBs use 'Optimistic' concurrency (OCC) which fails at commit time if a conflict occurred.

Common mistakes to avoid

3 patterns
×

Using READ UNCOMMITTED without understanding the consequences

Symptom
Dirty reads cause application state inconsistencies. For example, a dashboard shows an account balance that was never committed, leading to incorrect decisions.
Fix
Never use READ UNCOMMITTED in production unless you fully accept dirty reads. Use READ COMMITTED as the minimum default. If you need better performance, optimize query or index, not isolation.
×

Not handling serialization failures when using SERIALIZABLE isolation

Symptom
Random 'could not serialize access' errors (PostgreSQL error 40001) cause transactions to fail intermittently. Without retry logic, the user sees error pages or semantic failures.
Fix
Always implement retry logic for serialization failures. In Spring, consider using a retry advice on @Transactional methods. Typically retry 3 times with exponential backoff.
×

Assuming REPEATABLE READ prevents all read phenomena

Symptom
Reports become inconsistent because phantom rows appear. Inventory systems show more stock than exists.
Fix
Add explicit range locks (SELECT ... WHERE ... FOR UPDATE) to the reading transaction, or switch to SERIALIZABLE for range-reporting transactions.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the 'Phantom Read' anomaly. In which isolation level is it resol...
Q02SENIOR
You are seeing high 'Lock Wait Timeout' errors in a Spring Boot applicat...
Q03SENIOR
What is the difference between Optimistic and Pessimistic locking? When ...
Q04SENIOR
LeetCode SQL Context: Write a query to find and resolve duplicate record...
Q05SENIOR
How does MVCC (Multi-Version Concurrency Control) allow a database to pr...
Q01 of 05SENIOR

Explain the 'Phantom Read' anomaly. In which isolation level is it resolved in standard SQL, and how does MySQL's InnoDB handle it differently?

ANSWER
A phantom read occurs when a transaction reads a set of rows twice and sees different rows (newly inserted) in the second read. Standard SQL resolves it at SERIALIZABLE isolation. InnoDB also prevents phantom reads in REPEATABLE READ by using next-key locking (line of record locks + gap locks) on the index. So InnoDB's REPEATABLE READ actually prevents phantoms, which is stronger than the SQL standard requires.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What exactly is a 'Dirty Read' and why is it dangerous?
02
What is the difference between a non-repeatable read and a phantom read?
03
How does the 'Durability' in ACID handle a literal power out?
🔥

That's DBMS. Mark it forged?

3 min read · try the examples if you haven't

Previous
Indexing in DBMS
7 / 11 · DBMS
Next
Deadlock in DBMS