Transactions, ACID & Concurrency
The bank transfer that stopped halfway 🏦
Amal sends 30 to Bilal. The bank's program runs two statements: take 30 from Amal, then add 30 to Bilal. The server loses power right between them. Amal is 30 poorer, Bilal is not richer, and the money has simply vanished.
The cure is a transaction: a group of statements the database treats as one unit. Either every statement takes effect, or none of them does. There is no "halfway".
What you'll learn
BEGIN,COMMITandROLLBACK, and what autocommit does when you write none of them- The four ACID guarantees: atomicity, consistency, isolation, durability
- Isolation levels and the anomalies they prevent: dirty reads, non-repeatable reads, phantoms and lost updates
- Locking, deadlocks, and optimistic concurrency control with a version column
In the labs you control real SQLite transactions from Python, prevent a lost update and detect a deadlock.
One unit of work
BEGIN; -- start the unit of work
UPDATE accounts SET balance = balance - 30 WHERE id = 'amal';
UPDATE accounts SET balance = balance + 30 WHERE id = 'bilal';
COMMIT; -- make both changes permanent
-- if anything went wrong before COMMIT:
ROLLBACK; -- undo everything since BEGIN
BEGIN TRANSACTION and START TRANSACTION are common spellings of the same idea. Until COMMIT, other connections do not see the changes (except at the READ UNCOMMITTED level described below), and ROLLBACK erases them as if they never happened.
Autocommit: when you do not write BEGIN, PostgreSQL, MySQL and SQLite run each statement as its own tiny transaction and commit it immediately. That is fine for one statement, and exactly the bug in the story for two. One trap in Python: by default the sqlite3 module opens a transaction for you before INSERT, UPDATE and DELETE, and waits for commit(). The labs pass isolation_level=None so that nothing happens implicitly and you write BEGIN yourself.
Inside a transaction, a savepoint lets you undo only part of the work: SAVEPOINT item; ... ROLLBACK TO item; keeps everything before the savepoint and still lets you COMMIT the rest.
ACID: four promises
Atomicity: all or nothing. If a transaction fails or the server crashes before COMMIT, none of its changes remain. Databases make this possible by writing changes to a log first: SQLite uses a rollback journal or a write-ahead log (WAL), PostgreSQL uses its WAL. After a crash, recovery reads the log and discards whatever was not committed.
Consistency: from one valid state to another. Every constraint you declared (PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK) holds after the transaction, otherwise the offending statement fails and you roll back. Note the limit: the database only enforces the rules it knows. "The total amount of money never changes" is a rule your code keeps by putting both updates in one transaction.
Isolation: concurrent transactions do not trip over each other. Ideally the result is the same as if the transactions had run one after another (serializable). Because that can cost speed, databases offer weaker isolation levels, covered next.
Durability: committed means saved. Once COMMIT returns, the change survives a crash or power cut, because the log was flushed to disk before the database reported success. Some settings trade this safety for speed (PostgreSQL's synchronous_commit = off, SQLite's PRAGMA synchronous), and no setting protects you from losing the disk itself: that is the job of backups and replicas.
BEGIN ──▶ change 1 ──▶ change 2 ──▶ COMMIT ──▶ durable, visible to everyone
│ │
└─ error or crash before COMMIT ──▶ ROLLBACK: as if nothing happened
The first transfer commits. The second one crashes after the debit, and the undo log puts Amal's old balance back, so the total stays 150. A real database keeps such a log on disk, which is how it can still roll back unfinished work after the machine restarts.
# A tiny "database" with an undo log: the idea behind ROLLBACK.
balances = {"amal": 100, "bilal": 50}
class Crash(Exception):
pass
def transfer(src, dst, amount, crash_after_debit=False):
undo = [] # old values, recorded BEFORE each change
try:
undo.append((src, balances[src]))
balances[src] -= amount
if crash_after_debit:
raise Crash("power cut")
undo.append((dst, balances[dst]))
balances[dst] += amount
print("commit: ", balances)
except Crash as err:
for key, old in reversed(undo): # roll back: restore old values, newest first
balances[key] = old
print("rollback:", balances, "after a", err)
transfer("amal", "bilal", 30)
transfer("amal", "bilal", 30, crash_after_debit=True)
print("total is still", sum(balances.values()))Lab exercise 1
PythonGraded in Code LabThe table accounts holds amal (100), bilal (50) and carla (0), with CHECK (balance >= 0). The function transfer(src, dst, amount) runs two separate statements, so a failure in the middle leaves money missing or created from nothing.
Make transfer all or nothing: run BEGIN before the first UPDATE and COMMIT after the second. If the sender or recipient does not exist (an UPDATE changes rowcount != 1 rows) or the CHECK fails (sqlite3.IntegrityError), run ROLLBACK and return "rejected". Otherwise return "ok".
Input: one transfer per line: <from> <to> <amount>.
Output: one result line per transfer, then the balances and total: N. Whatever happens, the total must stay 150.
Example: amal zed 40 prints amal -> zed 40: rejected and Amal keeps her 100.
- A valid transfer commits both updates
- An overdraft is rejected by the CHECK and rolled back
- Unknown recipient: the debit is undone
- Unknown sender: no money from nowhere
- A sequence: exact balance allowed, later overdrafts rejected
Example run
- Input
amal bilal 30
- Expected output
amal -> bilal 30: ok amal=70 bilal=80 carla=0 total: 150
import sqlite3
import sys
# isolation_level=None: Python's sqlite3 never opens a transaction behind your back.
# Every statement commits on its own unless YOU run BEGIN ... COMMIT.
db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
balance INTEGER NOT NULL CHECK (balance >= 0) -- no overdrafts
);
INSERT INTO accounts (id, balance) VALUES ('amal', 100), ('bilal', 50), ('carla', 0);More lines in Code Lab: 25
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Isolation levels and the anomalies they prevent
When transactions run at the same time, these are the classic things that can go wrong:
- Dirty read: you read a change another transaction has not committed yet. If it rolls back, you acted on data that never existed.
- Non-repeatable read: you read the same row twice in one transaction and get different values, because another transaction committed a change in between.
- Phantom read: you run the same
WHEREquery twice and get a different set of rows, because another transaction inserted or deleted matching rows. - Lost update: two transactions read a value, both compute a new one from it, and the second write silently overwrites the first.
time T1 (Amal's phone) T2 (Bilal's laptop)
1 SELECT stock -> 10
2 SELECT stock -> 10
3 UPDATE stock = 10 - 3 (7)
4 COMMIT
5 UPDATE stock = 10 - 2 (8)
6 COMMIT -- Amal's sale is lost: it should be 5
The SQL standard (ISO/IEC 9075) defines four isolation levels by the anomalies they still allow:
- READ UNCOMMITTED: dirty reads are possible.
- READ COMMITTED: no dirty reads; non-repeatable reads and phantoms are possible.
- REPEATABLE READ: no dirty or non-repeatable reads; phantoms are possible.
- SERIALIZABLE: the result equals running the transactions one at a time, in some order.
Real databases differ in the details, so read your database's manual. PostgreSQL defaults to READ COMMITTED, treats READ UNCOMMITTED as READ COMMITTED, and its REPEATABLE READ (a snapshot of the data) also prevents phantoms. Its SERIALIZABLE level may abort a transaction with a serialization failure (SQLSTATE 40001), and your code must then retry the whole transaction. MySQL's InnoDB defaults to REPEATABLE READ. SQLite transactions are serializable, because it allows only one writer at a time.
BEGIN ISOLATION LEVEL SERIALIZABLE; -- PostgreSQL; MySQL uses SET TRANSACTION ISOLATION LEVEL ...
Four ways to stop a lost update
- Let the database do the arithmetic:
UPDATE products SET stock = stock - 2 WHERE id = 1. The read and the write happen inside one statement, so no other write can slip in between. - Pessimistic locking:
SELECT stock FROM products WHERE id = 1 FOR UPDATE(PostgreSQL, MySQL) locks the row until your transaction ends, so the second client waits. - Optimistic concurrency control: do not lock, but detect the conflict when saving, with a version column (the next lab).
- A stricter isolation level: in PostgreSQL's REPEATABLE READ or SERIALIZABLE, the second writer gets an error instead of overwriting.
With blind writes the final stock is 8: Amal's sale of 3 is lost. The atomic UPDATE lets the database subtract from the current value and ends at the correct 5. The version check refuses B's stale write (stock 7 so far), so B must read again and retry, which is exactly what the next lab builds.
// Two clients sell from the same stock, interleaved: read, read, write, write.
function run(strategy) {
const row = { stock: 10, version: 0 };
const seenByA = { ...row }; // client A reads
const seenByB = { ...row }; // client B reads
const log = [];
for (const [name, seen, qty] of [["A", seenByA, 3], ["B", seenByB, 2]]) {
if (strategy === "blind write") {
row.stock = seen.stock - qty; // UPDATE ... SET stock = <value computed earlier>
log.push(name + " saved");
} else if (strategy === "atomic") {
row.stock = row.stock - qty; // UPDATE ... SET stock = stock - qty
log.push(name + " saved");
} else if (row.version === seen.version) { // UPDATE ... WHERE id = 1 AND version = <read version>
row.stock = seen.stock - qty;
row.version += 1;
log.push(name + " saved");
} else {
log.push(name + " conflict");
}
}
console.log(strategy.padEnd(14), log.join(", ").padEnd(22), "final stock", row.stock);
}
["blind write", "atomic", "version check"].forEach(run);Lab exercise 2
PythonGraded in Code LabSeveral clients edit product 1 (stock 10, version 0). Each client reads the row, then later sells some quantity by saving stock - qty, computed from what it read. The program calls save(product_id, new_stock, read_version).
The starter does a blind write, so interleaved clients overwrite each other (a lost update). Rewrite save with optimistic concurrency control: update the row only if its version still equals read_version, increase version by 1 in the same statement, and return True when exactly one row changed, False otherwise (a conflict).
Input: a schedule of steps: read <client> or sell <client> <qty>.
Output: one line per step, then final stock=S version=V.
Example: read A, read B, sell A 3, sell B 2 saves A, reports a conflict for B and ends with final stock=7 version=1.
- One client after the other: both saved, version 2
- Interleaved clients: the stale write is a conflict
- Retry after a conflict reaches the correct stock
- Three clients: only the first writer wins
Example run
- Input
read A sell A 3 read B sell B 2
- Expected output
A reads stock=10 version=0 A sells 3: saved B reads stock=7 version=1 B sells 2: saved final stock=5 version=2
import sqlite3
import sys
db = sqlite3.connect(":memory:", isolation_level=None)
db.executescript("""
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
stock INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 0 -- bumped by every successful write
);
INSERT INTO products (id, name, stock) VALUES (1, 'Notebook', 10);More lines in Code Lab: 35
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Locks and deadlocks
To keep transactions apart, databases use locks. A shared lock lets many readers in; an exclusive lock gives one writer sole access. PostgreSQL and MySQL's InnoDB lock individual rows, and thanks to multiversion concurrency control (MVCC) a plain SELECT at the default isolation level neither blocks writers nor waits for them. Two transactions that write the same row still have to take turns: the second waits until the first commits or rolls back. SQLite is coarser: one writer for the whole database at a time (BEGIN IMMEDIATE takes the write lock at the start instead of at the first write).
A deadlock happens when transactions wait for each other in a circle:
T1: UPDATE accounts ... WHERE id = 'amal' -- T1 locks amal
T2: UPDATE accounts ... WHERE id = 'bilal' -- T2 locks bilal
T1: UPDATE accounts ... WHERE id = 'bilal' -- T1 waits for T2
T2: UPDATE accounts ... WHERE id = 'amal' -- T2 waits for T1: nobody can ever move
wait-for graph: T1 ──waits for──▶ T2
▲ │
└────waits for─────┘
Waiting longer cannot fix this, so the database steps in. It keeps a wait-for graph (an arrow from each waiting transaction to the one holding its lock), and a cycle in that graph is a deadlock. It then aborts one transaction of the cycle, the victim, which releases its locks so the others continue. PostgreSQL checks after a transaction has waited deadlock_timeout (1 second by default) and reports deadlock detected (SQLSTATE 40P01); InnoDB detects deadlocks automatically and rolls back the transaction it judges smallest. The victim's application gets an error and should retry the whole transaction.
Better still, make deadlocks rare:
- Lock in a consistent order. If every transfer updates the account with the smaller id first, the circle above cannot form.
- Keep transactions short. Never wait for user input or a slow network call while holding locks.
- Retry on deadlock and serialization errors (
40P01,40001), with a small limit on attempts. - Optionally set a lock timeout (PostgreSQL's
lock_timeout) so a stuck request fails fast instead of hanging.
Optimistic or pessimistic? Optimistic control (the version column) holds no locks, so it suits conflicts that are rare, such as two people editing the same profile. Pessimistic locking (FOR UPDATE) suits hot rows that conflict often, where retrying again and again would waste more work than waiting.
Lab exercise 3
PythonGraded in Code LabBuild the heart of a deadlock detector. Each input line T<a> waits T<b> means transaction a waits for a lock held by transaction b; in this model a blocked transaction waits for exactly one other. The program stores this as the dictionary waits_for ({a: b}, numbers only) and calls find_cycles(waits_for).
Write find_cycles so it returns every cycle of the wait-for graph as a list of transaction numbers in arrow order, each cycle starting at its smallest number, and the cycles sorted. A transaction that waits for a cycle but is not part of it (a "tail") is not in the cycle.
Output: for each cycle, deadlock: T4 -> T9 -> T6 -> T4 and the victim abort T9 (the program aborts the highest number, the youngest transaction), or no deadlock.
Example: T1 waits T2 and T2 waits T1 print deadlock: T1 -> T2 -> T1 and abort T2.
- Two transactions waiting for each other
- A waiting chain without a cycle is not a deadlock
- A three-way cycle with a waiting tail
- Two separate deadlocks, sorted
- A tail with a smaller number stays out; the cycle starts at its own smallest
- Numbers compare as numbers (T10 after T2)
Example run
- Input
T1 waits T2 T2 waits T1
- Expected output
deadlock: T1 -> T2 -> T1 abort T2
import sys
# stdin: one line per blocked transaction: "T<a> waits T<b>"
# meaning T<a> asked for a lock that T<b> holds. In this model a blocked
# transaction waits for exactly one other transaction.
waits_for = {}
for line in sys.stdin.read().splitlines():
if line.strip():
waiter, _, holder = line.split()
waits_for[int(waiter[1:])] = int(holder[1:])
More lines in Code Lab: 15
Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.
JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.
Question 1 of 5
A transfer runs BEGIN, debits Amal, and the server loses power before the credit and before COMMIT. What do you find after the restart?
Question 2 of 5
T1 reads a balance that T2 has updated but not committed. T2 then rolls back. Which anomaly did T1 suffer?
Question 3 of 5
Two clients each read stock = 10 and then save the value they computed. Which single statement avoids the lost update without locks or a version column?
Question 4 of 5
UPDATE products SET stock = 6, version = version + 1 WHERE id = 1 AND version = 4 reports 0 rows changed. What does that mean?
Question 5 of 5
Transfers sometimes deadlock: one locks account A then B, another locks B then A. What is the most effective fix in the application?
Recap and what to practice next
- A transaction (
BEGIN...COMMIT) is all or nothing;ROLLBACKundoes it, and savepoints undo part of it. WithoutBEGIN, autocommit makes every statement its own transaction. - ACID: atomicity (all or nothing), consistency (declared constraints always hold), isolation (concurrent transactions do not interfere), durability (committed data survives a crash).
- The anomalies are dirty reads, non-repeatable reads, phantoms and lost updates. Stronger isolation levels prevent more of them; check what your database actually does, and retry on serialization failures.
- Stop lost updates with an atomic UPDATE,
SELECT ... FOR UPDATE, or optimistic concurrency control with a version column and a rowcount check. - A deadlock is a cycle in the wait-for graph. The database aborts a victim; you prevent most of them by locking in a consistent order and keeping transactions short.
Practice next: extend the first lab with a SAVEPOINT so a batch of transfers keeps the good ones and skips the bad ones in a single transaction. In the second lab, add an automatic retry loop with at most three attempts. Then move on to how data is modelled outside the relational world: NoSQL databases make different promises about transactions, and knowing ACID tells you exactly what to ask.
Practice
Reading is not learning until you run something. Pick one.
Graded lab exercises
- Lab exercise 1PythonNot passed yet Try in Code Lab
- Lab exercise 2PythonNot passed yet Try in Code Lab
- Lab exercise 3PythonNot passed yet Try in Code Lab