Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 12

Understanding Transaction Isolation Levels

Recommended resources

Introduction

In our last lesson, you mastered how to formulate a robust indexing strategy to optimize the performance of individual database queries. We saw how tools like composite and partial indexes can dramatically speed up data retrieval. However, in a real-world backend system, you're not dealing with one query at a time; you're handling hundreds or thousands of concurrent requests from different users, all reading and writing to the same database.

This concurrency introduces a new layer of complexity. What happens when two users try to modify the same piece of data at the same time? This is where transaction isolation becomes critical. Your learning outcome for this lesson is to explain transaction isolation levels (e.g., Read Committed, Repeatable Read) and their trade-offs between data consistency and concurrency.

Understanding this topic is non-negotiable for a senior backend role. It's the "I" (Isolation) in ACID, and it governs the fundamental trade-off between system performance and data correctness. In an interview, you must be able to articulate not just what the isolation levels are, but why you would choose one over another, and what the practical consequences are for your application code.


The Core Problem: Concurrency Anomalies

When database transactions run concurrently without proper isolation, they can interfere with each other, leading to bizarre and incorrect data states known as concurrency anomalies. These are subtle, hard-to-reproduce bugs that can corrupt your data.

Let's start with a quick, clear overview of the most common anomalies.

ACID Properties in Databases With Examples

The video 'ACID Properties in Databases With Examples' from ByteByteGo provides excellent, concise explanations of these anomalies. Pay close attention to the banking examples, as they make the concepts very intuitive.

Watch from 01:33 to 03:53. This segment defines isolation and then walks through three key anomalies: Dirty Reads, Non-Repeatable Reads, and Phantom Reads.

To summarize the anomalies you just saw:

  • Dirty Read: Transaction A reads data that Transaction B has modified but not yet committed. If B rolls back, A has read data that never technically existed.
  • Non-Repeatable Read: Transaction A reads a row. Transaction B then updates that same row and commits. If A reads the same row again, it gets a different value. The read is not "repeatable."
  • Phantom Read: Transaction A runs a query that returns a set of rows (e.g., SELECT COUNT(*) FROM users WHERE role='admin'). Transaction B then inserts a new row that matches the query's criteria and commits. If A re-runs its query, it gets a different set of rows (a "phantom" row appears).

A Practical Race Condition: The "Lost Update"

These anomalies aren't just theoretical. A very common and dangerous problem that arises from a lack of isolation is the lost update. This is a classic race condition scenario.

Imagine you have a simple counter in your database. Two concurrent requests try to increment it.

  1. Request A reads the count: 0.
  2. Request B reads the count: 0.
  3. Request A calculates 0 + 1 = 1 and writes 1 back to the database.
  4. Request B calculates 0 + 1 = 1 and writes 1 back to the database.

The counter should be 2, but it's 1. The first update was "lost." This is a catastrophic bug in any system that tracks inventory, balances, or any shared resource.

This is why understanding database concurrency control is important

To see how this manifests in code, let's watch 'This is why understanding database concurrency control is important' by Web Dev Cody. He demonstrates this exact race condition using Python and PostgreSQL.

Watch from the beginning to 03:05. He first diagrams the race condition and then runs a Python script that makes concurrent requests, proving that the final count in the database is wrong.


The Solution: Isolation Levels in PostgreSQL

Database systems provide isolation levels as a mechanism to prevent these anomalies. Each level offers a different degree of protection, creating a spectrum of trade-offs between consistency and performance. Stricter levels prevent more anomalies but can reduce concurrency by making transactions wait for each other.

This table provides a high-level map of the standard levels and the anomalies they prevent.

Summary Table of Isolation Levels and Anomalies
This table summarizes the common anomalies (Dirty Reads, Non-Repeatable Reads, Phantom Reads, Write Skew) and indicates whether they are possible or prevented at each standard isolation level.

Now, let's dive into how PostgreSQL specifically implements these levels. PostgreSQL's implementation is based on a mechanism called Multi-Version Concurrency Control (MVCC), where each transaction sees a consistent "snapshot" of the data. This has some important consequences for how the levels behave compared to the SQL standard.

MVCC in PostgreSQL — 1. Isolation

The article 'MVCC in PostgreSQL — 1. Isolation' from Postgres Professional is an authoritative, deep dive into this topic. We'll read specific sections to understand the guarantees and quirks of each level in Postgres.

First, read the sections 'Isolation levels and anomalies in SQL standard' and 'Isolation levels in PostgreSQL'. Focus on the two summary tables. Notice how Postgres's implementation differs from the standard, particularly for Read Uncommitted and Repeatable Read.

Read Committed (The Default)

This is the default isolation level in PostgreSQL. It strikes a balance between performance and safety.

  • Guarantees: It prevents Dirty Reads. A transaction can only see data that has been committed.
  • Anomalies Allowed: It allows Non-Repeatable Reads and Phantom Reads.
  • Postgres Specifics: A key behavior to understand is the inconsistent read. Within a single Read Committed transaction, each statement gets a new snapshot of the database. This means if you run two SELECT statements back-to-back, another transaction could commit in between, causing your second SELECT to see different data than the first.

MVCC in PostgreSQL — 1. Isolation

Let's explore the practical behavior of Read Committed in more detail.

Read the section titled 'Read Committed'. Pay special attention to the subsections 'Non-repeatable read' and 'Inconsistent read'. The examples clearly demonstrate why you cannot make decisions based on data read in a previous statement within the same transaction at this level.

Why it's the default: It offers good protection against the most egregious anomaly (dirty reads) without the performance cost or complexity of stricter levels. Transactions don't typically fail with serialization errors, which simplifies application logic.

Repeatable Read

This level provides a more stable view of the data.

  • Guarantees: It prevents Dirty Reads and Non-Repeatable Reads. In PostgreSQL's implementation, it also prevents Phantom Reads. The transaction operates on a single, consistent snapshot of the data taken at the beginning of the transaction.
  • The New Trade-off: If Transaction A reads some data and Transaction B tries to update that same data and commits, Transaction A won't see the change. But if Transaction A then tries to update that same data itself, it will conflict with B's committed change. PostgreSQL resolves this by aborting Transaction A with a serialization error.
  • Anomalies Allowed: While strong, it can still suffer from a subtle anomaly called write skew.

MVCC in PostgreSQL — 1. Isolation

Now, let's see how Repeatable Read works and where it can still fail.

Read the section titled 'Repeatable Read'. Focus on three key points: 1) the absence of non-repeatable and phantom reads, 2) the introduction of the serialization error, and 3) the write skew anomaly example.

Practical Implication: If you use Repeatable Read, your application code must be prepared to catch serialization errors and retry the entire transaction.

Serializable

This is the strictest isolation level, providing the strongest guarantees.

  • Guarantees: It prevents all previously mentioned anomalies, including write skew. It ensures that running transactions concurrently has the same effect as running them one after another in some serial order.
  • How it Works: It builds on Repeatable Read by adding monitoring to detect if a transaction's execution would be inconsistent with a serial execution. If it detects such a "dangerous" situation (like a write skew), it will proactively abort one of the transactions with a serialization error.
  • The Cost: This monitoring adds overhead. It can also lead to more frequent transaction failures compared to Repeatable Read, which can impact throughput.

Making the Trade-off: A Real-World Case Study

Knowing the theory is one thing; deciding which level to use in a production system is another. The choice is rarely about simply picking Serializable for everything.

The following article describes the real-world journey of a team that started with Serializable for maximum safety, encountered performance issues, and strategically moved to Repeatable Read combined with targeted manual locking. This is exactly the kind of nuanced, trade-off-driven thinking expected in a senior interview.

Repeatable Read vs Serializable Isolation Level in Postgres

This article, 'Repeatable Read vs Serializable Isolation Level in Postgres', gives a fantastic practical perspective on the trade-offs.

Read the sections 'Serializable all the way' and 'Moving on to repeatable read'. Focus on why they were getting so many read/write dependencies errors with Serializable and how they solved their specific problem by switching to Repeatable Read and using a manual locking strategy for the critical code path.

This case study highlights a critical lesson: often, the best approach is to use a generally "good enough" isolation level (like Read Committed or Repeatable Read) and apply stricter controls (like explicit locking) only on the specific, critical parts of your code that need it.


Interview-Style Synthesis: The Inventory Problem

Let's bring this all together to answer a classic backend interview question.

Interviewer: "You're designing an e-commerce platform. During a flash sale, customers are complaining that they successfully placed an order, but later received an email saying the item was out of stock. The inventory count in the database is also becoming inaccurate. What could be the cause, and how would you fix it?"

Your Thought Process & Answer:

  1. Identify the Problem: "This sounds like a classic race condition, specifically a lost update or write skew anomaly. Under the default Read Committed isolation level, two concurrent transactions could be processing orders for the last available item."

  2. Explain the Anomaly: "For example, Transaction A reads the inventory count (1). At the same time, Transaction B also reads the count (1). Both transactions see that an item is available. Transaction A decrements the count to 0 and commits. Then Transaction B, which is unaware of A's change, also decrements the count to 0 and commits. We've sold two items but only decremented the inventory by one, resulting in overselling."

  3. Propose Solutions with Trade-offs (from simplest to most complex):

    • "Solution 1: Atomic Update." "For a simple counter, the most direct fix is to avoid reading the value into the application at all. We can use a single atomic SQL statement: UPDATE products SET inventory_count = inventory_count - 1 WHERE id = ? AND inventory_count > 0;. The database's own internal locking ensures this operation is safe. This is efficient and works well for simple cases."

    • "Solution 2: Pessimistic Locking." "If the business logic is more complex—for example, if we need to check multiple tables before confirming the order—an atomic update might not be possible. In this case, I'd use pessimistic locking. Within a transaction, I would issue SELECT ... FOR UPDATE on the inventory row. This places an exclusive lock on the row, forcing any other transaction trying to read it for an update to wait until my transaction commits or rolls back. This guarantees correctness but can reduce concurrency if these transactions are long."

    • "Solution 3: Change Isolation Level." "A broader approach is to increase the transaction isolation level.

      • Switching to Repeatable Read or Serializable would likely catch this conflict and cause one of the transactions to fail with a serialization error.
      • The crucial trade-off here is that my application logic would now have to implement a retry mechanism to handle these expected errors. This is a significant change. Serializable would provide the strongest guarantee but might cause more retries than necessary, impacting performance during a high-traffic flash sale. Repeatable Read is a good middle ground but might still allow more complex write-skew anomalies.
      • This is a heavy-handed solution. I would generally prefer explicit locking on the critical code path (like Solution 2) over changing the isolation level for the entire application, as it's more targeted."
  4. Final Recommendation: "My recommendation would be to start with pessimistic locking (SELECT ... FOR UPDATE) for the order processing logic. It's explicit, robust, and directly solves the race condition. It clearly signals to other developers that this is a critical section. If performance testing under load shows this to be a bottleneck, I would then investigate more advanced patterns like optimistic locking."


Conclusion

You've now explored one of the most fundamental concepts in database engineering. Moving beyond the default settings and making conscious, informed decisions about consistency is a hallmark of a senior engineer.

Key Takeaways:

  • Concurrent transactions can cause anomalies like Dirty Reads, Non-Repeatable Reads, Phantom Reads, and Lost Updates.
  • PostgreSQL offers isolation levels (Read Committed, Repeatable Read, Serializable) to prevent these, each with a different trade-off between consistency and concurrency.
  • Read Committed is the default. Each statement gets a new data snapshot. It's fast but allows non-repeatable reads.
  • Repeatable Read uses a single snapshot for the whole transaction. It's more consistent but introduces serialization errors, which your application must be prepared to retry.
  • Serializable is the strictest level, preventing all anomalies but at the highest potential cost to performance and with the most frequent need for retries.
  • For critical operations like inventory management, often the best solution is not to change the global isolation level but to use explicit locking (SELECT ... FOR UPDATE) on the specific rows involved.

Preview of the Next Lesson:

We've gone deep into indexing and concurrency within a single relational database. Now, we'll zoom out. In the next lesson, we will tackle the CAP theorem and discuss the high-level architectural decision of when to use a SQL vs. a NoSQL database, weighing their fundamental differences in data models, consistency guarantees, and scalability patterns.