DEV Community

Cover image for Database Transactions Are Lying to You: What Isolation Levels Actually Guarantee
Anas Issath
Anas Issath

Posted on Originally published at Medium

Database Transactions Are Lying to You: What Isolation Levels Actually Guarantee

Two requests hit your database at the same time. One overwrites the other's work, and neither one errors. Here's why, and how to stop it.

Here's a scenario I've seen dozens of times.

A "buy now" button works perfectly in every manual test. One person clicks it, stock decrements by one, order gets created. Ships to production.

A few weeks later, a flash sale runs, and support tickets start coming in: customers charged for items that were actually out of stock. The inventory count went negative. Nobody's code has a bug you can point to. Every individual request did exactly what it was told.

The problem wasn't one request. It was two requests, reading the same stock count at the same instant, both deciding there was enough left, both writing "success."

This is what transaction isolation is supposed to prevent, and most developers wrap code in BEGIN/COMMIT assuming it fully does, without knowing which guarantees their isolation level actually makes.

This guide explains what a transaction really promises, the four standard isolation levels, and the concurrency bugs that slip through the cracks depending on which one you're using.

Let's make your writes actually safe under load.


What a Transaction Actually Guarantees

A transaction groups multiple operations so they succeed or fail as one unit. That's the "atomic" in ACID.

What it does not automatically guarantee is that your transaction is isolated from every other transaction running at the same time. That part is configurable, and the default varies by database.

BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 42;
INSERT INTO orders (product_id, user_id) VALUES (42, 7);
COMMIT;
Enter fullscreen mode Exit fullscreen mode

This looks safe. It isn't, by itself. It only guarantees that within this transaction, both statements happen together. It says nothing about what a second, concurrent transaction running the exact same code is allowed to see or overwrite while this one is still in flight.

That's what isolation levels control.


The Core Problem: Two Transactions, One Row

Time    Transaction A                Transaction B
----    -------------------------    -------------------------
t1      BEGIN
t2      SELECT stock FROM inventory
        WHERE id=42;  → stock = 1
t3                                   BEGIN
t4                                   SELECT stock FROM inventory
                                      WHERE id=42;  → stock = 1
t5      UPDATE stock = 0
t6      COMMIT
t7                                   UPDATE stock = 0
t8                                   COMMIT
Enter fullscreen mode Exit fullscreen mode

Both transactions read stock = 1. Both decided it was safe to sell. Both committed. The final stock is 0, but two orders were created for one unit of inventory. Nothing errored.

This is a lost update, and it's the textbook example of what isolation levels exist to prevent.


The Four Standard Isolation Levels

The SQL standard defines four levels, each permitting fewer anomalies than the last, at the cost of more locking, more blocking, or more retries.

1. Read Uncommitted

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Enter fullscreen mode Exit fullscreen mode

The weakest level. A transaction can read data another transaction has written but not yet committed, including data that later gets rolled back. This is called a dirty read.

Transaction A: UPDATE balance SET amount = 1000 WHERE id = 1;  (not committed yet)
Transaction B: SELECT amount FROM balance WHERE id = 1;  → sees 1000
Transaction A: ROLLBACK;
-- Transaction B made a decision based on data that never existed
Enter fullscreen mode Exit fullscreen mode

Almost nobody should use this in production. PostgreSQL doesn't even implement it as distinct from Read Committed. It's there for standards compliance only.

2. Read Committed

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Enter fullscreen mode Exit fullscreen mode

The default in PostgreSQL, Oracle, and SQL Server. A transaction only ever sees data that has been committed: no dirty reads.

But it doesn't protect you from a non-repeatable read: if you query the same row twice in one transaction, another transaction's commit in between can change what you see the second time.

Transaction A: SELECT stock FROM inventory WHERE id=42;  → 1
Transaction B: UPDATE inventory SET stock=0 WHERE id=42; COMMIT;
Transaction A: SELECT stock FROM inventory WHERE id=42;  → 0
-- Same query, same transaction, different answer
Enter fullscreen mode Exit fullscreen mode

3. Repeatable Read

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Enter fullscreen mode Exit fullscreen mode

The default in MySQL/InnoDB. Once a transaction reads a row, it will see the same data for that row for the rest of the transaction, even if another transaction commits a change.

This fixes non-repeatable reads, but the standard still permits a phantom read: a query returning a set of rows can see new rows appear on a second run, even though individual rows you already read stay stable.

Transaction A: SELECT * FROM orders WHERE status='pending';  → 5 rows
Transaction B: INSERT INTO orders (status) VALUES ('pending'); COMMIT;
Transaction A: SELECT * FROM orders WHERE status='pending';  → 6 rows
-- A new row "phantom" appeared in the result set
Enter fullscreen mode Exit fullscreen mode

(In practice, PostgreSQL's implementation of Repeatable Read is stricter than the SQL standard requires and also blocks phantom reads. But don't assume that behavior is portable to every database.)

4. Serializable

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
Enter fullscreen mode Exit fullscreen mode

The strongest level. Guarantees that the result of running transactions concurrently is identical to running them one after another in some order. No dirty reads, no non-repeatable reads, no phantoms, no lost updates.

The cost: the database has to detect conflicts and abort one of the transactions when it can't guarantee that ordering.

BEGIN;
-- ... transaction logic ...
COMMIT;
-- Sometimes returns:
-- ERROR: could not serialize access due to concurrent update
Enter fullscreen mode Exit fullscreen mode

Your application must be ready to catch this and retry.

Serializable isn't "safe and free." It's "safe, and sometimes it tells you to try again."


Quick Reference: What Each Level Prevents

The takeaway: your database's default isolation level is Read Committed in most systems, and Read Committed does not prevent the lost-update bug from the flash sale example. If you never explicitly thought about isolation, you're running at the level that permits it.


Common Isolation Problems

Problem 1: Assuming BEGIN/COMMIT alone prevents race conditions

# Looks safe. Isn't, under Read Committed.
with transaction.atomic():
    product = Product.objects.get(id=42)
    if product.stock > 0:
        product.stock -= 1
        product.save()
Enter fullscreen mode Exit fullscreen mode

Two concurrent requests can both fetch stock = 1, both pass the if, both decrement to 0, and both succeed. Wrapping code in a transaction guarantees atomicity of the write. It does not, by itself, guarantee the read-then-write is protected from another transaction doing the same thing in between.

Fix: use a row-level lock (SELECT ... FOR UPDATE) to force serialization at the row.

with transaction.atomic():
    product = Product.objects.select_for_update().get(id=42)
    if product.stock > 0:
        product.stock -= 1
        product.save()
Enter fullscreen mode Exit fullscreen mode
-- What select_for_update does under the hood:
SELECT * FROM products WHERE id=42 FOR UPDATE;
-- Second transaction's identical query now BLOCKS until this one commits
Enter fullscreen mode Exit fullscreen mode

Problem 2: Using a lock when an atomic update would do

For simple counters, you often don't need a lock at all. You need to stop reading the value into application code in the first place.

# Race-prone: read, modify in Python, write
product.stock -= 1
product.save()
Enter fullscreen mode Exit fullscreen mode
-- Race-free: the decrement happens entirely inside the database
UPDATE products SET stock = stock - 1 WHERE id = 42 AND stock > 0;
Enter fullscreen mode Exit fullscreen mode

The second version pushes the read-modify-write into a single atomic statement the database executes as one operation. There's no window where two transactions can both see the same stale value. This is usually cheaper and simpler than a lock for pure counter updates.

Problem 3: Deadlocks from inconsistent lock ordering

Transaction A: locks row 1, then tries to lock row 2
Transaction B: locks row 2, then tries to lock row 1
-- Both wait for each other forever
Enter fullscreen mode Exit fullscreen mode
-- Transaction A
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

-- Transaction B, running concurrently
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
Enter fullscreen mode Exit fullscreen mode

If A locks row 1 first and B locks row 2 first, each is now waiting on a row the other holds. The database detects this and kills one transaction with a deadlock error, but only after both have been stalled.

Fix: always acquire locks on multiple rows in a consistent order, e.g. always lock the lower ID first.

-- Both transactions now always touch id=1 before id=2
UPDATE accounts SET balance = balance - :amt WHERE id = LEAST(:a, :b);
UPDATE accounts SET balance = balance + :amt WHERE id = GREATEST(:a, :b);
Enter fullscreen mode Exit fullscreen mode

Problem 4: Long transactions holding locks under load

with transaction.atomic():
    order = Order.objects.select_for_update().get(id=order_id)
    send_confirmation_email(order)   # slow network call, 2–3 seconds
    charge_payment(order)            # slow network call, 2–5 seconds
    order.status = 'confirmed'
    order.save()
Enter fullscreen mode Exit fullscreen mode

The row lock from select_for_update is held for the entire transaction, including both slow external calls. Every other request trying to touch that order queues up behind it for as long as those network calls take.

Fix: keep locks held only as long as the database work itself takes. Do slow, unrelated I/O outside the transaction.

send_confirmation_email_async(order_id)   # queued, not inline
result = charge_payment(order)            # done before opening the lock

with transaction.atomic():
    order = Order.objects.select_for_update().get(id=order_id)
    order.status = 'confirmed' if result.success else 'failed'
    order.save()
Enter fullscreen mode Exit fullscreen mode

Problem 5: Picking Serializable and not handling retries

# Set isolation to Serializable, then treat it like it can't fail
with transaction.atomic(isolation_level='SERIALIZABLE'):
    transfer_funds(from_account, to_account, amount)
Enter fullscreen mode Exit fullscreen mode

Serializable transactions can and will be aborted by the database whenever it can't guarantee a consistent ordering. This is expected, normal behavior, not an edge case.

Fix: wrap serializable transactions in a retry loop.

for attempt in range(3):
    try:
        with transaction.atomic(isolation_level='SERIALIZABLE'):
            transfer_funds(from_account, to_account, amount)
        break
    except SerializationError:
        if attempt == 2:
            raise
        continue
Enter fullscreen mode Exit fullscreen mode

Choosing a Level for Your Use Case

Approach Use it for
Read Committed (the default, mostly) The majority of reads, and writes that don't depend on a prior read of the same row within the transaction. Most CRUD operations.
Row locks (SELECT ... FOR UPDATE) at Read Committed The standard fix for read-then-write races on a specific row: inventory, balances, seat reservations. Cheaper and more predictable than jumping to Serializable.
Atomic SQL updates (SET x = x - 1 WHERE x > 0) The simplest, fastest option whenever the operation can be expressed as pure SQL without application logic between the read and the write.
Serializable Logic too complex for a single atomic statement or a simple row lock: multi-row invariants, complex financial transfers, anything where "occasionally retried" is an acceptable tradeoff for full correctness.

Checking What's Actually Happening

-- PostgreSQL: see current isolation level
SHOW TRANSACTION ISOLATION LEVEL;

-- PostgreSQL: see locks currently held and what's blocked on them
SELECT blocked_locks.pid AS blocked_pid,
       blocking_locks.pid AS blocking_pid,
       blocked_activity.query AS blocked_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_locks blocking_locks
  ON blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocked_activity
  ON blocked_activity.pid = blocked_locks.pid
WHERE NOT blocked_locks.granted;
Enter fullscreen mode Exit fullscreen mode

If you're seeing mysterious slow requests under concurrent load with no slow query in sight, checking for blocked locks is often more revealing than checking query plans.


The Bottom Line

Wrapping code in a transaction feels like it should make concurrency problems disappear. It doesn't. It only guarantees the specific things ACID promises, and by default, most databases run at a level that still allows two requests to read the same stale value and both act on it.

The flash-sale overselling bug isn't a bug in anyone's code; it's the predictable result of Read Committed doing exactly what it's documented to do.

Remember:

  • BEGIN/COMMIT guarantees atomicity, not isolation from concurrent reads. Those are separate guarantees.
  • Read Committed (most databases' default) still allows lost updates. Know your default, don't assume.
  • SELECT ... FOR UPDATE is usually the right fix for read-then-write races on a single row.
  • Atomic SQL updates (SET x = x - 1 WHERE x > 0) beat locks when the logic is simple enough to express in one statement.
  • Serializable transactions can fail on purpose. Your code needs a retry loop, not just a try/except that gives up.

The bug that only shows up during your highest-traffic moment (the flash sale, the ticket drop, the Black Friday spike) is usually a concurrency bug, not a capacity bug. Understanding isolation levels is how you catch it before that moment finds it for you.


Your Turn

Have you ever shipped a race condition that only showed up under real traffic? What gave it away? Tell me in the comments 👇


If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.

Top comments (0)