Handling Concurrency in Python Services — Part 1: Transaction Isolation Levels

This is Part 1 of a three-part series on handling concurrency in Python services. Part 2 covers the four isolation levels and the anomalies each one permits; Part 3 covers practical locking patterns — pessimistic, optimistic, and atomic updates.
Consider an online bookstore built in Python — FastAPI, Django, Flask, take your pick — backed by a PostgreSQL database. The store isn't too popular yet, so a single database instance suffices.
Now imagine there's only one copy of a book left in inventory, and multiple customers attempt to purchase it simultaneously. How do we ensure that:
- only one customer successfully purchases the book?
- inventory never goes negative?
- there are no race conditions?
- the database stays consistent?
The naïve solution: a Python lock
The instinctive move is to protect the critical section with a lock:
import threading
lock = threading.Lock()
def buy_book(book_id):
with lock:
# read inventory
# deduct stock
# save changes
...
Only one thread can execute the body of with lock: at a time. If two HTTP requests arrive simultaneously:
Request A ─────┐
├── acquires lock
├── updates inventory
└── releases lock
Request B ───────────── waits ─────────► executes
Request B waits until A finishes its purchase, then proceeds and finds the book gone. Race condition handled — within a single Python process.
The problem with Python locks
A threading.Lock lives in one process's memory. Now look at how the app is actually deployed:
Load Balancer
│
┌────────────┴────────────┐
│ │
FastAPI Instance 1 FastAPI Instance 2
│ │
└────────────┬────────────┘
│
PostgreSQL
Each application instance has its own memory, and therefore its own lock object:

If two customers hit different servers, Instance 1 acquires its lock, Instance 2 acquires its own lock, and both transactions update the same inventory row simultaneously. The lock provides no protection across application servers — and the same limitation applies to threading.RLock, asyncio.Lock, and multiprocessing.Lock. These primitives coordinate execution within a single process or machine, nothing more.
(You could reach for a distributed lock — Redis, ZooKeeper — but that's heavy machinery for a problem the database already solves. More on when distributed locks are the right tool in Part 3.)
Better: let the database handle concurrency
Instead of implementing locking in Python, let the database manage concurrent access to the row. Relational databases have spent decades solving exactly this problem, efficiently. They give you:
- transactions
- row-level locks
- MVCC (Multi-Version Concurrency Control)
- transaction isolation levels
For inventory updates, this is almost always the preferred solution.
ACID, briefly
Every modern relational database follows the ACID principles:
- Atomicity — the entire transaction succeeds, or nothing changes.
- Consistency — the database always moves from one valid state to another.
- Isolation — concurrent transactions don't interfere with one another.
- Durability — once committed, data survives crashes.
If anything goes amiss mid-transaction — a failure, a rollback — the whole operation reverts to its former state. This series focuses on the I: isolation.
Transaction isolation levels
Most relational databases support four isolation levels:
- Serializable
- Repeatable Read
- Read Committed
- Read Uncommitted
Each level trades between consistency, concurrency, and performance. Higher isolation means stronger guarantees but lower throughput.
Serializable
Serializable is the strictest level. The database behaves as though every transaction executes one after another, even when many users submit requests simultaneously — the final result is guaranteed to be identical to some serial execution.
Back to the last copy of the book. Inventory = 1, and customers A and B buy at the same moment. Under Serializable isolation:
Transaction A Transaction B
-------------- --------------
Read stock = 1
Update stock = 0 waits...
Commit
Reads stock = 0
Purchase fails
Only one transaction succeeds. No overselling. The trade-off is throughput: transactions may block, or be aborted and retried, because the database is enforcing a serial order on a hot row.
Serializable prevents all four classic concurrency anomalies: dirty reads, lost updates, non-repeatable reads, and phantom reads. These are the scenarios that leave a database inconsistent. Let's take the first two now; the other two open Part 2.
Dirty reads
Two transactions run against the same row. Transaction A updates it but hasn't committed:
-- Transaction A
BEGIN;
UPDATE books SET stock = 0 WHERE id = 1;
-- not committed yet
Before A commits, Transaction B reads:
-- Transaction B
SELECT stock FROM books WHERE id = 1;
-- reads stock = 0
Then Transaction A rolls back:
ROLLBACK;
-- stock is 1 again
Transaction B has read data that never officially existed. That's a dirty read.
A real-world version: a salesperson at our bookstore starts a large corporate order. The application inserts the order but hasn't committed — payment is still pending. Meanwhile, a background worker that generates invoices reads the uncommitted order and emails a bill. If the payment then fails and the transaction rolls back, the customer receives an invoice for an order that never existed. If the salesperson edits the order before committing, the customer receives an incorrect bill. Either way, not a great look for the business — and entirely preventable with an isolation level that refuses to read uncommitted changes.
Lost updates
Consider stock = 10, and two customers purchasing concurrently.
Transaction A reads and writes:
# Transaction A
stock = read_stock(book_id) # 10
new_stock = stock - 2 # buys 2 → 8
update_stock(book_id, new_stock)
At nearly the same time, Transaction B does the same — but it read before A's write landed:
# Transaction B
stock = read_stock(book_id) # still 10!
new_stock = stock - 4 # buys 4 → 6
update_stock(book_id, new_stock)
Final inventory: 6. Correct inventory: 4 (A bought 2, B bought 4). Transaction A's update has effectively disappeared — a lost update. Serializable isolation prevents it by ensuring one transaction completes before the other can modify the same row.
Summary
Python gives you threading.Lock and asyncio.Lock, but they only coordinate within a single process. The moment you run more than one application instance — which is every production deployment — they silently stop protecting anything. For inventory, payments, and anything financial, concurrency control belongs in the database, and transaction isolation levels are the proven mechanism for it.
In Part 2, we'll walk through all four isolation levels, the two anomalies we haven't covered yet — non-repeatable reads and phantom reads — and how to actually set the isolation level from Python.