---
title: "Read Committed vs Serializable: The Write-Skew Bug Your Default Isolation Level Allows"
description: "ACID promised isolation — concurrent transactions behave as if they ran alone — and your database is not doing that by default. Read Committed makes exactly one promise, and the gap it leaves is write skew: two transactions each check a rule, each see it satisfied, each write a different row, and together they break it. No lock collides, no error fires. Here is what each isolation level actually buys, what Serializable really costs, and the narrower fix that is usually the right one."
keywords: "read committed vs serializable, database isolation levels, write skew, postgres isolation level, ERROR 40001, SELECT FOR UPDATE, ACID isolation, serializable snapshot isolation, non-repeatable read, transaction concurrency, postgres transactions"
created_at: "2026-08-23T10:15:00"
post_type: "anonym_post"
content_type: "technical_article"
---

![Banner](./banner.webp)

<div style="display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 1rem 1.25rem; margin: 1.5rem 0 2rem; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; border-radius: 12px;">
  <span style="font-size: 0.95rem; font-weight: 600; color: #475569; margin-right: 0.25rem;">Prefer to watch?</span>
  <a href="https://youtube.com/shorts/rTWG6U4tUN0" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #ff0000; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">▶ The 100-second version</a>
  <a href="https://t.me/SoftwareEngineerBlog" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #229ed9; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">✈ Telegram</a>
</div>

Every backend engineer can recite the **I** in ACID: *isolation* — concurrent transactions behave as if they ran one at a time.

Almost nobody has checked whether their database is actually doing that.

It is not. Not by default. Postgres, Oracle and SQL Server all ship at **Read Committed**, and Read Committed does not promise you anything like "as if they ran alone." It promises one much smaller thing, and the gap between what you remember and what you bought is where a specific, expensive class of bug lives.

---

## Read Committed makes exactly one promise

Here is the entire contract:

> You will never read data that another transaction has not committed.

That's it. No dirty reads. Nothing else is guaranteed.

Read it again and notice what is *missing*. There is no promise that the data stays still while you look at it. Inside a **single** transaction, two identical `SELECT`s can return different answers, because in Read Committed each statement takes a **fresh snapshot** of the database at the moment it starts:

```sql
BEGIN;                                   -- isolation: READ COMMITTED

SELECT count(*) FROM oncall WHERE active;   -- → 2

-- another transaction commits here, entirely legally

SELECT count(*) FROM oncall WHERE active;   -- → 1
COMMIT;
```

Neither read is wrong. Both saw committed data. The world simply moved between them, and Read Committed never said it wouldn't. This is a **non-repeatable read**, and at this level it is not a bug — it is the documented behaviour.

And in exchange for that looseness you get real things: statements never block on a stale snapshot, transactions rarely deadlock, and for the roughly 95% of queries that touch **one row** — insert this order, decrement that balance with `UPDATE ... SET n = n - 1` — Read Committed is completely correct. Row locks handle the single-row case perfectly well.

The trouble starts when your rule is not about one row.

---

## Write skew: the bug where nothing conflicts

The canonical example is a hospital on-call roster. The invariant: **at least one doctor must be on call at all times.**

Two doctors, Alice and Bob, both currently on call, both feeling unwell, both hit "go off duty" at the same instant.

```sql
-- Alice's transaction                  -- Bob's transaction
BEGIN;                                  BEGIN;

SELECT count(*) FROM oncall             SELECT count(*) FROM oncall
  WHERE active;      -- → 2               WHERE active;      -- → 2

-- "2 > 1, safe to leave"               -- "2 > 1, safe to leave"

UPDATE oncall SET active = false        UPDATE oncall SET active = false
  WHERE doctor = 'alice';                 WHERE doctor = 'bob';

COMMIT;                                 COMMIT;
```

Both transactions read `2`. Both concluded — **correctly, given what they saw** — that their own write was safe. Both committed. The hospital now has nobody on call.

The cruel part is what did *not* happen:

- **No lock collided.** Alice wrote the `alice` row; Bob wrote the `bob` row. They are different rows. Row-level locking has nothing to say about it.
- **No error fired.** Both transactions were perfectly legal at Read Committed. The database did exactly what it promised.
- **No amount of care in either transaction would have helped.** Each one checked the rule and each one passed.

This is **write skew**: two transactions read an overlapping set of rows, make disjoint writes based on what they read, and *together* violate a constraint that neither violated alone.

The reason no lock saves you is worth stating plainly, because it is the whole insight:

> The constraint was a fact about the **whole table**. No single row can enforce it.

A `CHECK` constraint sees one row. A row lock protects one row. `count(*) >= 1` is a property of the *set*, and the set is exactly what Read Committed refuses to hold still.

---

## Serializable: track the reads, not just the writes

`SERIALIZABLE` closes the gap by changing **what the database watches**.

Under Serializable Snapshot Isolation (Postgres's implementation), the engine records not just what each transaction *wrote*, but what each transaction **read** — which rows, which index ranges, which predicates. At commit time it asks a single question:

> Could this outcome have been produced by running these transactions one after another, in *some* order?

In the roster example: if Alice ran first, Bob's `SELECT` would have returned `1` and Bob would have stayed. If Bob ran first, likewise for Alice. There is **no** serial order that produces "both leave" — so the result is impossible, and Postgres refuses it:

```
ERROR:  could not serialize access due to read/write dependencies
        among transactions
SQLSTATE: 40001
HINT:  The transaction might succeed if retried.
```

One transaction commits. The other is aborted. The invariant holds.

---

## The honest trade nobody states

Here is the part that gets skipped in most explanations, and it matters more than the mechanism:

> **Serializable does not make concurrency safe. It makes unsafety loud.**

The guarantee does not arrive as "your code now works." It arrives as **an error your application must catch and retry**. Serialization failures are not exceptional conditions to be logged and paged on — they are the normal, expected operating mode of a Serializable workload.

Which means: **code that does not retry is not running at Serializable. It is just failing differently.** You have swapped a silent data-corruption bug for a loud 500 to the user, and if that is where you stop, you may not have improved anything.

The retry loop is mandatory, not optional:

```python
for attempt in range(5):
    try:
        with db.transaction(isolation="serializable"):
            n = db.query("SELECT count(*) FROM oncall WHERE active")
            if n <= 1:
                raise TooFewDoctors()
            db.execute("UPDATE oncall SET active = false WHERE doctor = %s", me)
        break
    except SerializationFailure:            # SQLSTATE 40001
        sleep(backoff(attempt))             # jittered; then try again
else:
    raise CouldNotCommit()
```

Note the shape: the **entire** transaction re-runs, re-reading everything. You cannot retry just the failed statement, because the reads are the thing that went stale.

And it is not free. Tracking read dependencies costs memory and CPU. Worse, under contention it degrades in a nasty way: on a **hot row** — the one counter every request touches — transactions abort each other, retry, and abort again. The abort-and-retry storm can cost you more throughput, and more incident hours, than the bug you were preventing.

---

## Which is why the narrow fix usually wins

Raising the global isolation level is a blunt instrument: you pay the tax on *every* transaction to protect the handful that actually need it.

Most of the time the better move is to defend **the one invariant** that no single row can enforce:

- **`SELECT ... FOR UPDATE`** — take an explicit row lock on the rows you are about to reason about. In the roster case, lock the on-call rows before counting; now the two transactions serialize on a real lock, at Read Committed, with no retry loop.
- **A `UNIQUE` constraint** — let the database own the rule. This is the answer to the classic "check if username exists, then insert" race: don't check, just insert, and catch the unique violation. The constraint is enforced by the index, atomically, and no isolation level is involved at all.
- **A single writer** — route all mutations of the contended resource through one queue or one partition. Concurrency you never create needs no protection.
- **`SERIALIZABLE` on that one transaction** — Postgres lets you set the isolation level per transaction. Use it where the invariant is genuinely a set property and no lock or constraint expresses it.

---

## Where this bites in AI and LLM systems

If you build LLM applications rather than classic CRUD, this is not a database-trivia problem — write skew shows up in the exact places agent systems put their state, and usually under Read Committed because that is what the ORM defaulted to.

**Token budgets and rate limits.** An agent worker checks "has this tenant used less than its monthly token allowance?", sees yes, and starts a $4 generation. Ten workers do it in the same second. Ten different `usage` rows get written, no lock collides, and you have blown the budget by 10×. This is the on-call roster with a spend cap: the rule (`SUM(tokens) <= limit`) is a fact about the whole table.

**Concurrent tool calls from one agent.** Parallel tool execution is the default in most agent frameworks now. Two tools in the same turn each read the shared scratchpad, each decide their write is consistent with it, and each write a different key. The agent's state is now a combination neither tool ever validated — and unlike a human user double-clicking, an agent will do this on *every* run, deterministically.

**RAG ingestion dedup.** "Does a chunk with this hash already exist? No → insert." Two ingestion workers process the same document at the same moment, both see no, both insert. Now the same passage sits twice in your vector index, and it will win retrieval twice, crowding out the diversity your reranker was counting on. The fix here is the boring one: a `UNIQUE` index on the content hash, and let the insert fail.

**Idempotency keys on model calls.** Retries are constant when you call a flaky inference endpoint. "Has this request id been served? No → call the model" is check-then-act, and at Read Committed both retries can pass the check — so you pay twice and, worse, may return two different generations for what the caller believes was one request.

The pattern is identical every time: **check-then-act on a rule that spans rows.** The remedy is the same too — a unique constraint where one exists, `SELECT ... FOR UPDATE` where the rule is a sum or a count, and Serializable-with-retry where it genuinely is a set property.

---

## The verdict

<table>
  <thead>
    <tr>
      <th align="left"></th>
      <th align="left">Read Committed (the default)</th>
      <th align="left">Serializable</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>What it promises</strong></td>
      <td>You never read uncommitted data. That is the whole contract.</td>
      <td>The result is one that <em>some</em> serial order could have produced.</td>
    </tr>
    <tr>
      <td><strong>What it watches</strong></td>
      <td>Writes — via row locks</td>
      <td>Reads <em>and</em> writes — read/write dependencies between transactions</td>
    </tr>
    <tr>
      <td><strong>Repeated SELECT in one txn</strong></td>
      <td>Can return different answers (fresh snapshot per statement)</td>
      <td>Stable — the transaction sees one consistent view</td>
    </tr>
    <tr>
      <td><strong>Write skew</strong></td>
      <td><strong>Allowed.</strong> Silent, no lock, no error.</td>
      <td>Prevented — one transaction is aborted</td>
    </tr>
    <tr>
      <td><strong>How failure reaches you</strong></td>
      <td>As corrupted data, weeks later, in a support ticket</td>
      <td>As <code>ERROR 40001</code>, immediately, at commit</td>
    </tr>
    <tr>
      <td><strong>What your app must do</strong></td>
      <td>Nothing extra — and that is the trap</td>
      <td><strong>Retry the whole transaction.</strong> Non-negotiable.</td>
    </tr>
    <tr>
      <td><strong>Cost</strong></td>
      <td>Fast, deadlock-light, correct for single-row work</td>
      <td>Read-tracking overhead; abort-and-retry storms on hot rows</td>
    </tr>
    <tr>
      <td><strong>Reach for it when</strong></td>
      <td>Queries touch one row and row locks express the rule</td>
      <td>The invariant spans rows and no constraint or lock can state it</td>
    </tr>
  </tbody>
</table>

The one-line takeaway:

> **An isolation level is not a safety setting. It is the list of anomalies you have agreed to live with.**

So do the three things in order. **Read your default** — actually run `SHOW transaction_isolation;` rather than assuming. **Find the rule that no single row can enforce** — the count, the sum, the "at least one", the "no overlapping booking". **Defend that one on purpose** — with a unique constraint, a `FOR UPDATE`, or Serializable *plus the retry loop that makes it real*.

Everything else can stay at Read Committed, exactly where your database left it.

---

▶ **Watch the 100-second version:** [Read Committed vs Serializable — the write-skew bug your default allows](https://youtube.com/shorts/rTWG6U4tUN0)
