Skip to content

PostgreSQL Isolation Levels Explained with Python

PostgreSQL isolation levels explained in plain words, with Python and psycopg 3: reproduce lost updates and write skew, then fix them with retries or locks.

24 min read

Somewhere in almost every backend there's a check that looks harmless. Before an order goes through, the app makes sure the item is in stock. Before a seat is booked, it counts the seats left. Before an expense is approved, it adds up what the team has already spent and makes sure the new amount still fits. Read, check, write: three lines of code that never fail in development.

Now picture a corporate-card product. The design team has a $5,000 monthly budget, and late on a busy afternoon two managers each approve a $3,000 expense within the same second. Each request adds up the month's approvals and gets $0, because neither has saved its row yet. Each sees that $3,000 fits, inserts its expense and returns 200. The books now show $6,000 approved against a $5,000 budget.

No log shows an error, and every test still passes, because tests send one request at a time. Each transaction was correct on its own. The trouble is what PostgreSQL let them do together, and that's set by the transaction's isolation level: the rule for how much one transaction can see of others running at the same time.

PostgreSQL's default, READ COMMITTED, allows the overspend. The obvious upgrade, REPEATABLE READ, doesn't stop it either. It gives each transaction a frozen snapshot of the data, and two transactions that read the same total and insert different rows pass it cleanly.

There are two ways to make the rule hold. Run the check at SERIALIZABLE and retry whenever PostgreSQL stops one of the transactions, or move the rule onto a single row so that requests for the same team take turns.

In this tutorial, you'll learn what each isolation level does in plain words. Then you'll reproduce three concurrency bugs in Python with psycopg 3, fix each one, and load-test the fixes with 400 approvals: a lost update, a report that doesn't add up, and that overspent budget. Everything was tested with PostgreSQL 18.6, Python 3.14.8 and psycopg 3.3.6.

PostgreSQL Isolation Levels in Plain Words#

A transaction is a group of statements that succeed or fail together. While yours runs, other transactions run beside it and change the same tables. The isolation level answers one question: while my transaction runs, how much of their work do I see?

Picture the team's numbers in a shared spreadsheet that several managers edit at once:

  • READ COMMITTED, PostgreSQL's default: every statement looks at the latest saved data. It's like refreshing the spreadsheet before every glance. You never see someone's half-typed edit, but two glances a second apart can show different numbers.
  • REPEATABLE READ: the transaction takes a snapshot at its first query and reads only that snapshot until it ends. It's like working from a printout. The numbers never shift under you, but you don't see anything saved after you printed. If you try to change a row that someone else changed after your printout, PostgreSQL stops you with an error.
  • SERIALIZABLE: the same printout, plus a referee. PostgreSQL keeps track of what each transaction read. If two transactions together produce a result that couldn't happen had they run one after the other, it cancels one of them, and your code runs it again.

The SQL standard also defines READ UNCOMMITTED, which would let you see other people's unsaved changes. PostgreSQL accepts the name but runs it as READ COMMITTED, so you can ignore it.

Each level shuts out more of the bugs you're about to meet:

Bug What goes wrong READ COMMITTED REPEATABLE READ SERIALIZABLE
Lost update Two requests read the same total, each adds to it, and the second write erases the first Happens Stopped with an error Stopped with an error
Read skew One transaction reads two numbers from two different moments, so they don't agree Happens Stopped Stopped
Write skew Two requests check the same data, each decides it's safe, and each writes a different row Happens Happens Stopped with an error

How each level treats the three bugs in this tutorial, per the PostgreSQL 18 isolation chapter. "With an error" means your code has to retry.

Stronger levels aren't free: they stop bugs by raising errors that your code must handle. So you'll watch each bug happen first, then pick the cheapest fix that stops it.

Prerequisites#

To follow along, you need:

  • Docker with Docker Compose. PostgreSQL and Python both run in containers, so you don't install either locally.
  • Working knowledge of Python and of SQL transactions (BEGIN, COMMIT, ROLLBACK).
  • One terminal, open in an empty folder.

Step 1: Set Up the Expense Approval Lab#

You need a database with a budget in it and a Python image to run the demos. Each script plays two or more managers, each on its own connection, and runs their statements in a fixed order, so you get the same result every time.

Create the project folder:

mkdir isolation-lab && cd isolation-lab

Create the Compose file:

docker-compose.yml
services:
  db:
    image: postgres:18.6
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: spend
    volumes:
      - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h localhost -U app -d spend"]
      interval: 1s
      timeout: 3s
      retries: 30

  app:
    build: .
    environment:
      DATABASE_URL: postgresql://app:app@db:5432/spend
    volumes:
      - .:/lab
    working_dir: /lab
    depends_on:
      db:
        condition: service_healthy

The db service runs schema.sql on its first start, through the official image's /docker-entrypoint-initdb.d folder, and only reports healthy once the schema has loaded. The app service mounts the project folder, so it runs the scripts you write.

The image installs one library:

Dockerfile
FROM python:3.14.8-slim
WORKDIR /lab
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ENV PYTHONUNBUFFERED=1
requirements.txt
psycopg[binary]==3.3.6

The schema has two tables. teams holds each team's budget and a running total of what it has spent, and expenses holds one row per approved expense. Amounts are whole dollars, so the output stays easy to read:

schema.sql
CREATE TABLE teams (
    id     text PRIMARY KEY,
    name   text,
    budget integer NOT NULL,
    spent  integer NOT NULL DEFAULT 0
);

CREATE TABLE expenses (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    team_id     text REFERENCES teams,
    amount      integer NOT NULL,
    approved_by text NOT NULL,
    created_at  timestamptz DEFAULT now()
);

CREATE INDEX expenses_team_id_idx ON expenses (team_id);

-- The design team has a $5,000 monthly budget.
INSERT INTO teams (id, name, budget) VALUES ('design', 'Design', 5000);

common.py holds the helpers every script shares:

common.py
import os
import time

import psycopg
from psycopg import IsolationLevel

DSN = os.environ.get("DATABASE_URL", "postgresql://app:app@localhost:5432/spend")

LEVELS = {
    "read-committed": IsolationLevel.READ_COMMITTED,
    "repeatable-read": IsolationLevel.REPEATABLE_READ,
    "serializable": IsolationLevel.SERIALIZABLE,
}


def connect(name: str, level: IsolationLevel = IsolationLevel.READ_COMMITTED) -> psycopg.Connection:
    """One manager's session. The first statement opens a transaction at
    `level`, and it stays open until commit() or rollback()."""
    conn = psycopg.connect(DSN, application_name=name)
    conn.isolation_level = level
    return conn


def reset(budget: int = 5_000) -> None:
    """Back to the start: design team, nothing spent."""
    with psycopg.connect(DSN, autocommit=True) as conn:
        conn.execute("TRUNCATE expenses RESTART IDENTITY")
        conn.execute("UPDATE teams SET budget = %s, spent = 0 WHERE id = 'design'", (budget,))


def wait_until_blocked(name: str, timeout: float = 5.0) -> None:
    """Wait until session `name` is stuck waiting for a lock."""
    with psycopg.connect(DSN, autocommit=True) as conn:
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            row = conn.execute(
                "SELECT wait_event_type FROM pg_stat_activity WHERE application_name = %s",
                (name,),
            ).fetchone()
            if row and row[0] == "Lock":
                return
            time.sleep(0.01)
    raise TimeoutError(f"{name} never blocked")


def log(who: str, message: str) -> None:
    print(f"[{who}] {message}", flush=True)


def usd(amount: int) -> str:
    return f"${amount:,}"

connect() opens one session per manager. psycopg sets the level through conn.isolation_level, and only between transactions: once a statement has run, the transaction is open and its level is fixed until commit() or rollback(). wait_until_blocked() watches pg_stat_activity, PostgreSQL's live list of sessions, until a manager is stuck waiting for a lock. reset() puts the data back, so you can run the scripts in any order.

Start PostgreSQL, waiting until it's healthy, and build the image:

docker compose up -d --wait db
docker compose build app

Check that the schema loaded:

docker compose exec db psql -U app -d spend -c "SELECT id, budget, spent FROM teams"

The output will look similar to:

Output
   id   | budget | spent 
--------+--------+-------
 design |   5000 |     0
(1 row)

One team, a $5,000 budget, nothing spent. Compose prints container status lines before each script's output; add 2>/dev/null to a docker compose run command to hide them.

Step 2: Lose an Update, Then Let PostgreSQL Do the Math#

Start with the simplest way to lose money. Say every approval adds its amount to teams.spent. Alice approves $1,200 and Bob approves $800 at the same moment. Each reads the total, adds their amount in Python and writes the result back. lost_update.py stages that read-modify-write:

lost_update.py
"""Lost update: two managers add to the team's running total at the same time.

Usage: python lost_update.py read-committed | repeatable-read | atomic
"""
import sys
import threading

from psycopg.errors import SerializationFailure

from common import LEVELS, connect, log, reset, usd, wait_until_blocked

ALICE_AMOUNT = 1_200
BOB_AMOUNT = 800


def read_modify_write(level_name: str) -> None:
    level = LEVELS[level_name]
    alice = connect("alice", level)
    bob = connect("bob", level)

    # Both managers read the total before either writes.
    a_spent = alice.execute("SELECT spent FROM teams WHERE id = 'design'").fetchone()[0]
    log("alice", f"read spent = {usd(a_spent)}")
    b_spent = bob.execute("SELECT spent FROM teams WHERE id = 'design'").fetchone()[0]
    log("bob", f"read spent = {usd(b_spent)}")

    # Each adds in Python and writes the result back.
    alice.execute("UPDATE teams SET spent = %s WHERE id = 'design'", (a_spent + ALICE_AMOUNT,))
    log("alice", f"set spent = {usd(a_spent + ALICE_AMOUNT)}")
    alice.commit()
    log("alice", "commit")

    try:
        bob.execute("UPDATE teams SET spent = %s WHERE id = 'design'", (b_spent + BOB_AMOUNT,))
        log("bob", f"set spent = {usd(b_spent + BOB_AMOUNT)}")
        bob.commit()
        log("bob", "commit")
    except SerializationFailure as e:
        log("bob", f"UPDATE failed: SQLSTATE {e.sqlstate}: {e.diag.message_primary}")
        bob.rollback()


def atomic() -> None:
    alice = connect("alice")
    bob = connect("bob")

    # Let the database do the adding, on whatever the row holds right now.
    new = alice.execute(
        "UPDATE teams SET spent = spent + %s WHERE id = 'design' RETURNING spent",
        (ALICE_AMOUNT,),
    ).fetchone()[0]
    log("alice", f"add {usd(ALICE_AMOUNT)} -> spent = {usd(new)} (not committed yet)")

    def bob_adds() -> None:
        new = bob.execute(
            "UPDATE teams SET spent = spent + %s WHERE id = 'design' RETURNING spent",
            (BOB_AMOUNT,),
        ).fetchone()[0]
        log("bob", f"add {usd(BOB_AMOUNT)} -> spent = {usd(new)}")
        bob.commit()
        log("bob", "commit")

    t = threading.Thread(target=bob_adds)
    t.start()
    wait_until_blocked("bob")
    log("bob", "waiting for alice's row lock")
    log("alice", "commit")
    alice.commit()
    t.join()


def main() -> None:
    variant = sys.argv[1] if len(sys.argv) > 1 else "read-committed"
    reset()
    print(f"--- lost update, {variant} ---")
    if variant == "atomic":
        atomic()
    else:
        read_modify_write(variant)

    with connect("check") as conn:
        spent = conn.execute("SELECT spent FROM teams WHERE id = 'design'").fetchone()[0]
    print(f"final spent = {usd(spent)} (expected {usd(ALICE_AMOUNT + BOB_AMOUNT)})")


if __name__ == "__main__":
    main()

Run it at READ COMMITTED first:

docker compose run --rm app python lost_update.py read-committed

The output will look similar to:

Output
--- lost update, read-committed ---
[alice] read spent = $0
[bob] read spent = $0
[alice] set spent = $1,200
[alice] commit
[bob] set spent = $800
[bob] commit
final spent = $800 (expected $2,000)

Both managers approved, and the total says $800. Bob added his $800 to the $0 he had read, which was already out of date by the time he wrote, so his write erased Alice's $1,200. That's a lost update. PostgreSQL saw nothing wrong, because SET spent = 800 is a perfectly valid statement.

Now run the same code at REPEATABLE READ:

docker compose run --rm app python lost_update.py repeatable-read

The output will look similar to:

Output
--- lost update, repeatable-read ---
[alice] read spent = $0
[bob] read spent = $0
[alice] set spent = $1,200
[alice] commit
[bob] UPDATE failed: SQLSTATE 40001: could not serialize access due to concurrent update
final spent = $1,200 (expected $2,000)

This time Bob's UPDATE fails with SQLSTATE 40001, a serialization failure. Bob is working from his printout, and the row changed after he printed it, so PostgreSQL refuses to let him overwrite it. The documentation says to retry the whole transaction from the start; on the retry Bob would read $1,200 and write $2,000. Step 5 builds that retry.

You don't need a stronger level for this bug, though. The atomic() variant lets the database do the adding with spent = spent + %s, at plain READ COMMITTED:

docker compose run --rm app python lost_update.py atomic

The output will look similar to:

Output
--- lost update, atomic ---
[alice] add $1,200 -> spent = $1,200 (not committed yet)
[bob] waiting for alice's row lock
[alice] commit
[bob] add $800 -> spent = $2,000
[bob] commit
final spent = $2,000 (expected $2,000)

Bob's UPDATE found the row locked by Alice's uncommitted change and waited. Once she committed, PostgreSQL ran his + 800 against the new value, $1,200, not the $0 from before, as the READ COMMITTED documentation describes.

So the bug was never the isolation level. It was doing the math in Python, where the database can't tell that the number is stale.

Step 3: Read a Consistent Report with REPEATABLE READ#

Readers have their own trap. At month-end, finance runs a report that prints the team's running total next to the sum of its expense rows. Every approval writes both in one transaction, so they should always match. read_skew.py runs the report while Carol approves $250 between its two queries:

read_skew.py
"""Read skew: a month-end report reads two numbers that should always agree.

Usage: python read_skew.py read-committed | repeatable-read
"""
import sys

import psycopg

from common import LEVELS, connect, log, reset, usd


def approve(conn: psycopg.Connection, amount: int, approver: str) -> None:
    """Record an expense and add it to the team's total, in ONE transaction."""
    conn.execute(
        "INSERT INTO expenses (team_id, amount, approved_by) VALUES ('design', %s, %s)",
        (amount, approver),
    )
    conn.execute("UPDATE teams SET spent = spent + %s WHERE id = 'design'", (amount,))
    conn.commit()


def main() -> None:
    level_name = sys.argv[1] if len(sys.argv) > 1 else "read-committed"
    reset()
    print(f"--- read skew, report at {level_name} ---")

    carol = connect("carol")
    approve(carol, 1_000, "carol")
    log("carol", "approved $1,000 (earlier this month)")

    report = connect("report", LEVELS[level_name])
    spent = report.execute("SELECT spent FROM teams WHERE id = 'design'").fetchone()[0]
    log("report", f"teams.spent          = {usd(spent)}")

    approve(carol, 250, "carol")
    log("carol", "approved $250 and committed")

    total = report.execute(
        "SELECT coalesce(sum(amount), 0) FROM expenses WHERE team_id = 'design'"
    ).fetchone()[0]
    log("report", f"sum(expenses.amount) = {usd(total)}")
    report.commit()

    verdict = "agree" if spent == total else "DISAGREE"
    print(f"report numbers {verdict}: {usd(spent)} vs {usd(total)}")


if __name__ == "__main__":
    main()
docker compose run --rm app python read_skew.py read-committed

The output will look similar to:

Output
--- read skew, report at read-committed ---
[carol] approved $1,000 (earlier this month)
[report] teams.spent          = $1,000
[carol] approved $250 and committed
[report] sum(expenses.amount) = $1,250
report numbers DISAGREE: $1,000 vs $1,250

The report says the books are $250 apart, and they aren't. At READ COMMITTED each statement refreshes its view, so the first query saw the total before Carol's commit and the second saw the sum after it. That's read skew: one transaction reading two different moments.

Run the report at REPEATABLE READ:

docker compose run --rm app python read_skew.py repeatable-read

The output will look similar to:

Output
--- read skew, report at repeatable-read ---
[carol] approved $1,000 (earlier this month)
[report] teams.spent          = $1,000
[carol] approved $250 and committed
[report] sum(expenses.amount) = $1,000
report numbers agree: $1,000 vs $1,000

Now both numbers come from one printout, and Carol's $250 will show up in the next run. A read-only report never conflicts with anyone, so REPEATABLE READ gives it a consistent answer with no retries to handle.

Step 4: Overspend the Budget with Write Skew#

Now the bug from the introduction. This time approvals don't touch the running total. Each one sums the team's expenses, checks the budget, and inserts its own row. Alice and Bob each approve $3,000 against the $5,000 budget, and both check before either inserts:

write_skew.py
"""Write skew: two managers each check the budget, then each insert an expense.

Usage: python write_skew.py read-committed | repeatable-read | serializable
"""
import sys

import psycopg
from psycopg.errors import SerializationFailure

from common import LEVELS, connect, log, reset, usd

AMOUNT = 3_000  # each, against a $5,000 budget


def check(conn: psycopg.Connection, who: str) -> bool:
    budget = conn.execute("SELECT budget FROM teams WHERE id = 'design'").fetchone()[0]
    approved = conn.execute(
        "SELECT coalesce(sum(amount), 0) FROM expenses WHERE team_id = 'design'"
    ).fetchone()[0]
    ok = approved + AMOUNT <= budget
    log(who, f"check: approved {usd(approved)} + {usd(AMOUNT)} <= {usd(budget)}? {ok}")
    return ok


def insert(conn: psycopg.Connection, who: str) -> None:
    conn.execute(
        "INSERT INTO expenses (team_id, amount, approved_by) VALUES ('design', %s, %s)",
        (AMOUNT, who),
    )
    log(who, f"insert {usd(AMOUNT)}")


def main() -> None:
    level_name = sys.argv[1] if len(sys.argv) > 1 else "read-committed"
    reset()
    print(f"--- write skew, {level_name} ---")
    alice = connect("alice", LEVELS[level_name])
    bob = connect("bob", LEVELS[level_name])

    # Both checks happen before either insert.
    check(alice, "alice")
    check(bob, "bob")
    insert(alice, "alice")
    insert(bob, "bob")
    alice.commit()
    log("alice", "commit")
    try:
        bob.commit()
        log("bob", "commit")
    except SerializationFailure as e:
        log("bob", f"COMMIT failed: SQLSTATE {e.sqlstate}: {e.diag.message_primary}")
        bob.rollback()
        log("bob", "retry the whole transaction")
        if check(bob, "bob"):
            insert(bob, "bob")
        else:
            log("bob", "rejected: over budget")
        bob.commit()

    with connect("check") as conn:
        total, count = conn.execute(
            "SELECT coalesce(sum(amount), 0), count(*) FROM expenses WHERE team_id = 'design'"
        ).fetchone()
    print(f"approved {count} expense(s) totalling {usd(total)} on a $5,000 budget")


if __name__ == "__main__":
    main()

Run it at READ COMMITTED:

docker compose run --rm app python write_skew.py read-committed

The output will look similar to:

Output
--- write skew, read-committed ---
[alice] check: approved $0 + $3,000 <= $5,000? True
[bob] check: approved $0 + $3,000 <= $5,000? True
[alice] insert $3,000
[bob] insert $3,000
[alice] commit
[bob] commit
approved 2 expense(s) totalling $6,000 on a $5,000 budget

Both checks saw $0, both inserts succeeded, and the team is $1,000 over budget:

sequenceDiagram participant A as Alice participant P as PostgreSQL participant B as Bob A->>P: sum(expenses) = $0, $3,000 fits B->>P: sum(expenses) = $0, $3,000 fits A->>P: INSERT $3,000 B->>P: INSERT $3,000 A->>P: COMMIT B->>P: COMMIT Note over P: $6,000 approved on $5,000

Write skew: both managers read the same total, and each writes a different row, so they never touch the same row.

You might expect REPEATABLE READ to stop this. It doesn't:

docker compose run --rm app python write_skew.py repeatable-read

The output will look similar to:

Output
--- write skew, repeatable-read ---
[alice] check: approved $0 + $3,000 <= $5,000? True
[bob] check: approved $0 + $3,000 <= $5,000? True
[alice] insert $3,000
[bob] insert $3,000
[alice] commit
[bob] commit
approved 2 expense(s) totalling $6,000 on a $5,000 budget

The same $6,000, and no error. This is write skew: two transactions read the same data, each makes a decision that's right for what it read, and each writes a different row. REPEATABLE READ only complains when two transactions change the same row, as Bob's UPDATE did in Step 2. Alice and Bob each inserted a brand-new row, so there was nothing to complain about.

Now try SERIALIZABLE:

docker compose run --rm app python write_skew.py serializable

The output will look similar to:

Output
--- write skew, serializable ---
[alice] check: approved $0 + $3,000 <= $5,000? True
[bob] check: approved $0 + $3,000 <= $5,000? True
[alice] insert $3,000
[bob] insert $3,000
[alice] commit
[bob] COMMIT failed: SQLSTATE 40001: could not serialize access due to read/write dependencies among transactions
[bob] retry the whole transaction
[bob] check: approved $3,000 + $3,000 <= $5,000? False
[bob] rejected: over budget
approved 1 expense(s) totalling $3,000 on a $5,000 budget

Every statement ran, then Bob's COMMIT failed with 40001, and his retry saw Alice's $3,000 and rejected his own expense. That's the referee at work. PostgreSQL noted what each manager read, and saw that Alice's total would have changed if Bob's insert had gone first, and Bob's would have changed if Alice's had gone first:

flowchart TB A["Alice read the design total"] -- "Bob's insert changes it, so Alice must go first" --> B["Bob read the design total"] B -- "Alice's insert changes it, so Bob must go first" --> A

Each manager has to come before the other, so no one-at-a-time order explains the result. PostgreSQL cancels one transaction to break the loop.

The error doesn't always arrive at COMMIT. Depending on timing it can come from any statement, so a retry has to cover the whole transaction.

Want this checked in your own codebase?

Write skew passes every single-request test and every level below SERIALIZABLE, so the first sign is usually a reconciliation that doesn't add up. I offer a focused review of concurrency on money paths: which checks read data another request can change, which isolation level each transaction runs at, how 40001 and deadlocks are retried, and a load test like Step 7's, with written findings. Ask for a concurrency review or book a free 20-minute intro call.

Step 5: Retry Serialization Failures in Python#

write_skew.py retried once, by hand; a real approval needs a loop. The documentation's rule is to retry the complete transaction, including the logic that decided what to write, which is why PostgreSQL can't retry for you.

approve.py holds the business rule and three safe ways to run it. This step uses the first, approve_serializable():

approve.py
"""Three safe ways to approve an expense against a team budget, and a demo of each.

Usage: python approve.py serializable | for-update | conditional
"""
import random
import sys
import threading
import time
from collections.abc import Callable

import psycopg
from psycopg import IsolationLevel
from psycopg.errors import DeadlockDetected, SerializationFailure

from common import connect, log, reset, usd, wait_until_blocked

MAX_ATTEMPTS = 10
RETRYABLE = (SerializationFailure, DeadlockDetected)  # SQLSTATE 40001, 40P01
VERBOSE = True


def say(who: str, message: str) -> None:
    if VERBOSE:
        log(who, message)


def nothing() -> None:
    pass


def check_and_insert(
    conn: psycopg.Connection, team: str, amount: int, who: str, pause: Callable[[], None]
) -> bool:
    """The business rule: approve only if the expense fits what is left of the budget."""
    budget = conn.execute("SELECT budget FROM teams WHERE id = %s", (team,)).fetchone()[0]
    approved = conn.execute(
        "SELECT coalesce(sum(amount), 0) FROM expenses WHERE team_id = %s", (team,)
    ).fetchone()[0]
    if approved + amount > budget:
        say(who, f"check: {usd(approved)} + {usd(amount)} > {usd(budget)}, rejected")
        return False
    say(who, f"check: {usd(approved)} + {usd(amount)} <= {usd(budget)}, ok")
    pause()
    conn.execute(
        "INSERT INTO expenses (team_id, amount, approved_by) VALUES (%s, %s, %s)",
        (team, amount, who),
    )
    say(who, f"insert {usd(amount)}")
    return True


def approve_naive(conn: psycopg.Connection, team: str, amount: int, who: str) -> bool:
    """Check, then insert, at whatever level `conn` uses. Not safe."""
    ok = check_and_insert(conn, team, amount, who, nothing)
    conn.commit()
    return ok


def approve_serializable(
    conn: psycopg.Connection,
    team: str,
    amount: int,
    who: str,
    pause: Callable[[], None] = nothing,
) -> tuple[bool, int]:
    """Fix 1: run at SERIALIZABLE and retry the whole transaction when PostgreSQL
    aborts it. Returns (approved, attempts)."""
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            ok = check_and_insert(conn, team, amount, who, pause)
            conn.commit()
            say(who, "commit")
            return ok, attempt
        except RETRYABLE as e:
            conn.rollback()
            say(who, f"attempt {attempt} failed: {e.diag.message_primary}")
            if attempt == MAX_ATTEMPTS:
                raise
            time.sleep(random.uniform(0, min(0.1, 0.002 * 2**attempt)))
    raise AssertionError("unreachable")


def approve_for_update(
    conn: psycopg.Connection,
    team: str,
    amount: int,
    who: str,
    pause: Callable[[], None] = nothing,
) -> bool:
    """Fix 2: READ COMMITTED, but lock the team's row first, so approvals
    for the same team take turns."""
    conn.execute("SELECT 1 FROM teams WHERE id = %s FOR UPDATE", (team,))
    say(who, f"locked team {team}")
    ok = check_and_insert(conn, team, amount, who, pause)
    say(who, "commit")
    conn.commit()
    return ok


def approve_conditional(
    conn: psycopg.Connection,
    team: str,
    amount: int,
    who: str,
    pause: Callable[[], None] = nothing,
) -> bool:
    """Fix 3: READ COMMITTED, check and reserve the money in one UPDATE."""
    row = conn.execute(
        "UPDATE teams SET spent = spent + %s"
        " WHERE id = %s AND spent + %s <= budget"
        " RETURNING spent",
        (amount, team, amount),
    ).fetchone()
    if row is None:
        say(who, f"UPDATE matched 0 rows: {usd(amount)} does not fit, rejected")
        conn.rollback()
        return False
    say(who, f"UPDATE reserved {usd(amount)}, spent now {usd(row[0])}")
    conn.execute(
        "INSERT INTO expenses (team_id, amount, approved_by) VALUES (%s, %s, %s)",
        (team, amount, who),
    )
    pause()
    say(who, "commit")
    conn.commit()
    return True


AMOUNT = 3_000  # each, against a $5,000 budget


def demo_serializable() -> None:
    alice = connect("alice", IsolationLevel.SERIALIZABLE)
    bob = connect("bob", IsolationLevel.SERIALIZABLE)
    alice_checked, bob_checked, alice_done = threading.Event(), threading.Event(), threading.Event()

    def alice_pause() -> None:
        alice_checked.set()
        bob_checked.wait()

    def bob_pause() -> None:
        bob_checked.set()
        alice_done.wait()

    def run_alice() -> None:
        approve_serializable(alice, "design", AMOUNT, "alice", alice_pause)
        alice_done.set()

    a = threading.Thread(target=run_alice)
    a.start()
    alice_checked.wait()
    ok, attempts = approve_serializable(bob, "design", AMOUNT, "bob", bob_pause)
    a.join()
    say("bob", f"approved={ok} after {attempts} attempt(s)")


def demo_locking(approve: Callable[..., bool], waiting: str) -> None:
    alice = connect("alice")
    bob = connect("bob")
    bob_thread = threading.Thread(target=approve, args=(bob, "design", AMOUNT, "bob"))

    def alice_pause() -> None:
        bob_thread.start()
        wait_until_blocked("bob")
        say("bob", waiting)

    approve(alice, "design", AMOUNT, "alice", alice_pause)
    bob_thread.join()


def main() -> None:
    variant = sys.argv[1] if len(sys.argv) > 1 else "serializable"
    reset()
    print(f"--- approve, {variant} ---")
    if variant == "serializable":
        demo_serializable()
    elif variant == "for-update":
        demo_locking(approve_for_update, "SELECT ... FOR UPDATE is waiting for alice")
    else:
        demo_locking(approve_conditional, "UPDATE is waiting for alice's row lock")

    with connect("check") as conn:
        total, count = conn.execute(
            "SELECT coalesce(sum(amount), 0), count(*) FROM expenses WHERE team_id = 'design'"
        ).fetchone()
    print(f"approved {count} expense(s) totalling {usd(total)} on a $5,000 budget")


if __name__ == "__main__":
    main()

check_and_insert() is the business rule, and the whole transaction. approve_serializable() calls it once per attempt. When PostgreSQL cancels an attempt, it rolls back, waits a short random time that grows with each attempt, and starts again from the top, re-reading everything. The random wait stops managers who failed together from retrying together. After 10 attempts it gives up and raises. conn.commit() sits inside the try, because that's often where the failure arrives.

The except catches two errors: SerializationFailure (SQLSTATE 40001) and DeadlockDetected (40P01), which PostgreSQL raises when two transactions wait on each other's locks. In psycopg 3.3.6 both subclass OperationalError, so except TransactionRollback catches neither. In a high-concurrency payments ledger I worked on, concurrent updates to the same accounts deadlocked, and the service retried the transaction after a short, growing wait.

Run the demo:

docker compose run --rm app python approve.py serializable

The output will look similar to:

Output
--- approve, serializable ---
[alice] check: $0 + $3,000 <= $5,000, ok
[bob] check: $0 + $3,000 <= $5,000, ok
[alice] insert $3,000
[alice] commit
[bob] attempt 1 failed: could not serialize access due to read/write dependencies among transactions
[bob] check: $3,000 + $3,000 > $5,000, rejected
[bob] commit
[bob] approved=False after 2 attempt(s)
approved 1 expense(s) totalling $3,000 on a $5,000 budget

Bob's second attempt saw $3,000 already approved and rejected cleanly: the caller gets "over budget", not a database error.

Keep side effects out of the retried function

Anything inside check_and_insert() runs once per attempt. If it sends an email, publishes to Kafka or calls the card provider, a retried approval does that twice. Write the side effect into an outbox table in the same transaction, as in the transactional outbox tutorial, and give any external call an idempotency key so a repeat is harmless.

SERIALIZABLE also only protects transactions that run at SERIALIZABLE. A batch job inserting expenses at READ COMMITTED slips past the referee.

Step 6: Guard the Budget with a Row Lock or One UPDATE#

Retries aren't free. The other approach makes approvals for the same team take turns, at plain READ COMMITTED, so there's nothing to retry.

approve_for_update() starts with SELECT 1 FROM teams WHERE id = %s FOR UPDATE. The budget rule spans many expense rows, but they all belong to one team row, and locking that row makes every other approval for the team wait in line:

docker compose run --rm app python approve.py for-update

The output will look similar to:

Output
--- approve, for-update ---
[alice] locked team design
[alice] check: $0 + $3,000 <= $5,000, ok
[bob] SELECT ... FOR UPDATE is waiting for alice
[alice] insert $3,000
[alice] commit
[bob] locked team design
[bob] check: $3,000 + $3,000 > $5,000, rejected
[bob] commit
approved 1 expense(s) totalling $3,000 on a $5,000 budget

Bob waited until Alice committed. Because READ COMMITTED refreshes its view for every statement, his sum, which ran after the wait, included her $3,000. The catch is discipline: every code path that adds expenses must take the same lock first.

Keep the row lock at READ COMMITTED

At REPEATABLE READ the printout is taken by the transaction's first query, and here that's the FOR UPDATE itself, before the wait. Bob would still read $0 after Alice committed and approve $6,000 in total, with no error. Use this fix only at READ COMMITTED.

approve_conditional() goes one step further and puts the whole rule in one statement. It keeps the running total in teams.spent and makes the budget check part of the write:

UPDATE teams SET spent = spent + %s
WHERE id = %s AND spent + %s <= budget
RETURNING spent
docker compose run --rm app python approve.py conditional

The output will look similar to:

Output
--- approve, conditional ---
[alice] UPDATE reserved $3,000, spent now $3,000
[bob] UPDATE is waiting for alice's row lock
[alice] commit
[bob] UPDATE matched 0 rows: $3,000 does not fit, rejected
approved 1 expense(s) totalling $3,000 on a $5,000 budget

This is Step 2's wait-and-recheck doing real work. Bob's UPDATE waited on Alice's row lock. When she committed, PostgreSQL checked his WHERE clause again against the new total, found that $3,000 + $3,000 is over budget, and updated nothing. It's the same guard as the WHERE status = 'authorized' capture in the outbox tutorial.

Step 7: Load-Test Five Approval Strategies#

Two managers in a fixed order show how each fix works, not what happens under real traffic. load_test.py runs 16 workers, each with its own connection, through 400 approvals of $50 to $500 for one team. Together they ask for $108,488 against a $50,000 budget:

load_test.py
"""Many managers at once: 16 workers share 400 random approvals for one team.

Usage: python load_test.py naive-rc | naive-rr | serializable | for-update | conditional | all
"""
import queue
import random
import sys
import threading

from psycopg import IsolationLevel

import approve
from common import connect, reset, usd

WORKERS = 16
APPROVALS = 400
BUDGET = 50_000  # the 400 requests ask for roughly twice this

STRATEGIES = {
    "naive-rc": IsolationLevel.READ_COMMITTED,
    "naive-rr": IsolationLevel.REPEATABLE_READ,
    "serializable": IsolationLevel.SERIALIZABLE,
    "for-update": IsolationLevel.READ_COMMITTED,
    "conditional": IsolationLevel.READ_COMMITTED,
}


def approve_once(strategy: str, conn, amount: int, who: str) -> tuple[bool, int]:
    match strategy:
        case "naive-rc" | "naive-rr":
            return approve.approve_naive(conn, "design", amount, who), 1
        case "serializable":
            return approve.approve_serializable(conn, "design", amount, who)
        case "for-update":
            return approve.approve_for_update(conn, "design", amount, who), 1
        case "conditional":
            return approve.approve_conditional(conn, "design", amount, who), 1
    raise ValueError(strategy)


def run(strategy: str) -> str:
    reset(BUDGET)
    jobs: queue.Queue = queue.Queue()
    rng = random.Random(42)
    for _ in range(APPROVALS):
        jobs.put(rng.randint(50, 500))

    results: list[tuple[bool, int]] = []

    def worker(n: int) -> None:
        conn = connect(f"w{n:02}", STRATEGIES[strategy])
        while True:
            try:
                amount = jobs.get_nowait()
            except queue.Empty:
                break
            results.append(approve_once(strategy, conn, amount, f"w{n:02}"))
        conn.close()

    threads = [threading.Thread(target=worker, args=(n,)) for n in range(WORKERS)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    with connect("check") as conn:
        total = conn.execute(
            "SELECT coalesce(sum(amount), 0) FROM expenses WHERE team_id = 'design'"
        ).fetchone()[0]
    approved = sum(ok for ok, _ in results)
    retries = sum(attempts - 1 for _, attempts in results)
    most = max(attempts for _, attempts in results)
    return (
        f"{strategy:<13} approved={approved:<3} retries={retries:<4} most_attempts={most:<2}"
        f" total={usd(total)} over_budget={usd(max(0, total - BUDGET))}"
    )


def main() -> None:
    approve.VERBOSE = False
    choice = sys.argv[1] if len(sys.argv) > 1 else "all"
    strategies = list(STRATEGIES) if choice == "all" else [choice]
    print(f"--- {WORKERS} workers, {APPROVALS} approvals, {usd(BUDGET)} budget ---")
    for strategy in strategies:
        print(run(strategy), flush=True)


if __name__ == "__main__":
    main()

The two naive strategies run the unprotected check-then-insert, at READ COMMITTED and at REPEATABLE READ. Run all five:

docker compose run --rm app python load_test.py all

The race makes the numbers vary between runs. The output will look similar to:

Output
--- 16 workers, 400 approvals, $50,000 budget ---
naive-rc      approved=191 retries=0    most_attempts=1  total=$51,899 over_budget=$1,899
naive-rr      approved=194 retries=0    most_attempts=1  total=$52,719 over_budget=$2,719
serializable  approved=183 retries=123  most_attempts=7  total=$49,961 over_budget=$0
for-update    approved=185 retries=0    most_attempts=1  total=$49,952 over_budget=$0
conditional   approved=184 retries=0    most_attempts=1  total=$49,952 over_budget=$0

Read the over_budget column first. I ran the full set six times; across all six runs:

Strategy Over budget by Approved Retries per 400 Most attempts for one approval
Check-then-insert, READ COMMITTED $1,216 to $2,354 189 to 192 0 1
Check-then-insert, REPEATABLE READ $2,340 to $3,049 192 to 196 0 1
SERIALIZABLE + retry $0 183 to 185 101 to 123 7 to 8
READ COMMITTED + FOR UPDATE $0 185 every run 0 1
READ COMMITTED + one conditional UPDATE $0 183 to 187 0 1

400 approvals worth $108,488 against a $50,000 budget, 16 workers, PostgreSQL 18.6 in Docker. Six runs per strategy.

Three things stand out. REPEATABLE READ didn't help at all: the printout keeps each check consistent with itself, and does nothing about the manager next to you.

SERIALIZABLE was correct every time, at the cost of about one retry for every four approvals. Some approvals needed 7 or 8 attempts before they got an answer, so a cap of 3 would have failed real requests in every run. Measure attempts at your own busiest moment before you pick a cap.

Both locking fixes were correct with no retries at all. Their cost is that one team's approvals run one at a time.

When you're done, remove the lab and its data:

docker compose down -v

How to Choose an Isolation Level for Each Rule#

Ask what shape each rule has, then pick the cheapest fix that covers it:

Shape of the rule Example Fix What it costs
A single update to a number spent = spent + amount Nothing extra; let the database do the math at READ COMMITTED Nothing
One row holds the whole rule spent + amount <= budget on teams One conditional UPDATE ... WHERE ... RETURNING, READ COMMITTED A running total you keep in step with the expense rows
Many rows, one parent row The sum of a team's expenses SELECT ... FOR UPDATE on the parent first, READ COMMITTED Every write path must take the lock; one team's approvals take turns
Rules spread across many tables or checks Dozens of checks across a codebase SERIALIZABLE + whole-transaction retry Retries, side effects moved out of the transaction, every writer at SERIALIZABLE
Read-only reports Month-end reconciliation REPEATABLE READ Nothing; it never sees later changes

I'd start from the top of the table. A conditional UPDATE is one readable statement, and when the money runs out it simply updates nothing. SERIALIZABLE earns its cost when the rules multiply, because then you don't have to find and lock every one of them by hand.

One more cost to know about: SERIALIZABLE can cancel transactions that never truly conflicted. When a table is small, PostgreSQL often reads all of it, and then an approval for one team can be cancelled because of another team's insert. That costs a retry, never wrong data, but under load it adds to the real conflicts.

Questions Interviewers Ask About Isolation Levels#

These come up in backend interviews, and the lab answers each one:

Question Short answer
What's the difference between a lost update and write skew? A lost update is two read-modify-writes of the same row, where the second erases the first. Write skew is two transactions reading the same data and writing different rows. REPEATABLE READ catches the first and not the second.
What is snapshot isolation? It's what PostgreSQL's REPEATABLE READ is: every read comes from one snapshot, and a transaction fails if it changes a row someone else changed after that snapshot. SERIALIZABLE adds tracking of what each transaction read, so it catches write skew too.
Is PostgreSQL's REPEATABLE READ the same as MySQL's? No. In InnoDB, the consistent-read documentation says the snapshot "applies to SELECT statements within a transaction, not necessarily to DML statements", so Step 2's read-modify-write would overwrite silently there instead of failing with 40001.
What does SERIALIZABLE guarantee? That the committed transactions have the same effect as running them one at a time in some order. Not the order they arrived in, and not freedom from errors you must retry.

Troubleshooting#

Symptom Cause Fix
relation "teams" does not exist An old volume skipped the schema script docker compose down -v, then docker compose up -d --wait db
except TransactionRollback never catches a serialization failure In psycopg 3.3.6, SerializationFailure (40001) and DeadlockDetected (40P01) subclass OperationalError Catch (SerializationFailure, DeadlockDetected), or check e.sqlstate
ProgrammingError: can't change 'isolation_level' now A statement already opened the transaction Commit or roll back first, or set the level right after connecting
40001 escapes the retry loop The failure came from COMMIT, outside the try Keep conn.commit() inside the try, as approve_serializable() does
Approvals give up under load The retry cap is lower than your busiest moment needs Measure attempts under load and raise the cap, or use a row lock for busy rules

Conclusion#

You reproduced a lost update, a report that read two moments at once, and write skew that slipped past both READ COMMITTED and REPEATABLE READ. Then you stopped the overspend three ways, from up to $3,049 over budget per run down to $0.

The choice comes down to cost. The row lock and the conditional UPDATE need no retries but guard one rule each. SERIALIZABLE guards every rule in every transaction that runs at that level, for 101 to 123 retries per 400 requests here and side effects kept out of the transaction.

Next, look for check-then-act code in your own service: any transaction that reads a sum, a count or a MAX and then writes a different row. A sync service I worked on read MAX(seq) and inserted MAX + 1, so two batches could pick the same number. Point a load test like Step 7's at code like that, and read the PostgreSQL chapter on concurrency control. If a retried transaction calls anything outside the database, add an idempotency key and an outbox first.

If your team moves money through concurrent requests and wants the rules checked, I can review the transaction design and build the load tests that prove it. You can see how I approach distributed systems, or book a free 20-minute intro call.

Frequently asked questions

What is the default isolation level in PostgreSQL?

READ COMMITTED. Each statement sees the data committed before that statement began, so two SELECTs in one transaction can return different answers. The SQL standard's default is SERIALIZABLE, which PostgreSQL offers but only uses when you ask for it, per transaction with SET TRANSACTION or as a default with default_transaction_isolation. In psycopg 3 you set conn.isolation_level between transactions.

What is write skew, and does REPEATABLE READ prevent it?

Write skew happens when two transactions read the same data, each decides its write is safe, and each writes a different row, so neither sees the other's change. Two $3,000 expenses approved against a $5,000 budget is the classic case. PostgreSQL's REPEATABLE READ is snapshot isolation, which only stops two transactions changing the same row, so it allows write skew. SERIALIZABLE prevents it.

How do I retry a serialization failure (SQLSTATE 40001) in Python?

Put the whole transaction in a function that re-reads everything and decides again, and call it in a loop with a capped number of attempts and jittered backoff. In psycopg 3, catch psycopg.errors.SerializationFailure and DeadlockDetected, roll back, and try again. Keep the commit inside the try, because SERIALIZABLE often fails at COMMIT, and keep emails and API calls out of the retried function.

What is the difference between REPEATABLE READ and SERIALIZABLE in PostgreSQL?

Both read from one snapshot taken at the transaction's first statement. REPEATABLE READ aborts a transaction only when it tries to change a row that another transaction changed and committed after that snapshot. SERIALIZABLE also tracks what each transaction read, with non-blocking predicate locks, and aborts one transaction when it sees a pattern of read/write dependencies that could mean no serial order exists. The check is conservative, so it sometimes aborts safe transactions.

Shahid Yousuf

Senior Software Engineer

I build and consult on software across web, mobile, cloud and AI. Have something in mind? Let's talk.

Hire me Book a free intro call See case studies

Get new posts

I share every new in-depth tutorial on X and LinkedIn. Connect with me there to get the next one.

Prefer a feed reader? RSS or Atom.

Have a project in mind?

Share your goals, timeline and any constraints, whether you need it built or want expert advice. I read every message myself, as the engineer who would build it, and reply with a clear view on approach, scope and next steps.

Hire me