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.
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:
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:
FROM python:3.14.8-slim
WORKDIR /lab
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ENV PYTHONUNBUFFERED=1
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:
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:
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:
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: 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:
--- 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:
--- 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:
--- 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: 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:
--- 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:
--- 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: 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:
--- 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:
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:
--- 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:
--- 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:
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():
"""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:
--- 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:
--- 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:
--- 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:
"""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:
--- 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 studiesGet new posts
I share every new in-depth tutorial on X and LinkedIn. Connect with me there to get the next one.