Why transactions: the bank transfer

Sanne transfers €200 to Kwame. In the database that is two changes: subtract 200 from Sanne's balance, add 200 to Kwame's. If the server crashes, the network drops or the second statement fails between those two changes, €200 has vanished. The two updates must happen together or not at all.

A database transaction is a sequence of statements that the database treats as one indivisible unit of work: it either completes entirely or leaves no trace, and other users never see it half done. Transactions are what let a database be trusted with money, stock levels and bookings.

BEGIN, COMMIT and ROLLBACK

Three statements control a transaction. BEGIN starts it. The COMMIT statement ends it and makes all its changes permanent and visible to others. ROLLBACK ends it by undoing every change made since BEGIN. Here the transfer succeeds:

BEGIN;
CREATE TABLE accounts (
  account_id  INTEGER PRIMARY KEY,
  owner       TEXT    NOT NULL,
  balance     INTEGER NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES (1, 'Sanne', 500), (2, 'Kwame', 100);
COMMIT;

BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 200 WHERE account_id = 2;
COMMIT;

SELECT * FROM accounts ORDER BY account_id;
account_id  owner  balance
----------  -----  -------
1           Sanne  300
2           Kwame  300

The first transaction sets up the table; the second is the transfer. Now the same transfer, but the program decides to cancel it after both updates — for example because a fraud check failed. Inside the transaction the new balances are visible; after ROLLBACK, they are gone:

BEGIN;
CREATE TABLE accounts (
  account_id  INTEGER PRIMARY KEY,
  owner       TEXT    NOT NULL,
  balance     INTEGER NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES (1, 'Sanne', 500), (2, 'Kwame', 100);
COMMIT;

BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 200 WHERE account_id = 2;
SELECT * FROM accounts ORDER BY account_id;
ROLLBACK;

SELECT * FROM accounts ORDER BY account_id;
account_id  owner  balance
----------  -----  -------
1           Sanne  300
2           Kwame  300

account_id  owner  balance
----------  -----  -------
1           Sanne  500
2           Kwame  100

The syntax varies slightly: PostgreSQL and MySQL also accept START TRANSACTION, and SQL Server writes BEGIN TRANSACTION, COMMIT TRANSACTION and ROLLBACK TRANSACTION.

When a statement fails

What if the database itself refuses one of the statements? Below, Sanne tries to send €700 while she has only €500. The program adds the money to Kwame first; the second UPDATE then breaks the CHECK constraint:

BEGIN;
CREATE TABLE accounts (
  account_id  INTEGER PRIMARY KEY,
  owner       TEXT    NOT NULL,
  balance     INTEGER NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES (1, 'Sanne', 500), (2, 'Kwame', 100);
COMMIT;

BEGIN;
UPDATE accounts SET balance = balance + 700 WHERE account_id = 2;
UPDATE accounts SET balance = balance - 700 WHERE account_id = 1;
Error: CHECK constraint failed: balance >= 0

SAVEPOINT: partial rollback

A savepoint marks a point inside a transaction that you can roll back to without abandoning the whole transaction. Here a transfer of €100 stays, but an optional €5 fee is undone:

BEGIN;
CREATE TABLE accounts (
  account_id  INTEGER PRIMARY KEY,
  owner       TEXT    NOT NULL,
  balance     INTEGER NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES (1, 'Sanne', 500), (2, 'Kwame', 100), (3, 'Mei', 0);
COMMIT;

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
SAVEPOINT before_fee;
UPDATE accounts SET balance = balance - 5 WHERE account_id = 2;
UPDATE accounts SET balance = balance + 5 WHERE account_id = 3;
ROLLBACK TO before_fee;
COMMIT;

SELECT * FROM accounts ORDER BY account_id;
account_id  owner  balance
----------  -----  -------
1           Sanne  400
2           Kwame  200
3           Mei    0

SAVEPOINT, ROLLBACK TO and RELEASE SAVEPOINT work in SQLite, PostgreSQL and MySQL. SQL Server uses SAVE TRANSACTION before_fee; and ROLLBACK TRANSACTION before_fee;.

Autocommit

If you never type BEGIN, you are still using transactions. In autocommit mode — the default in SQLite, PostgreSQL, MySQL and SQL Server — every statement is its own transaction, committed automatically when it succeeds. That is why a single UPDATE on a thousand rows never stops halfway: it is atomic on its own. Explicit transactions are needed only to group several statements. Not every tool behaves the same: Oracle's tools, and Python's sqlite3 module in its default mode, open a transaction for you and expect an explicit commit (see Working with Databases from Code).

ACID, one property at a time

ACID is the name for the four guarantees a transactional database gives: atomicity, consistency, isolation and durability. The term was coined by Theo Härder and Andreas Reuter in 1983.

Property Guarantee In the transfer
Atomicity All or nothing Never only the debit without the credit
Consistency From one valid state to another No balance below zero; no account that does not exist
Isolation Concurrent transactions do not see each other's unfinished work A report never sees the €200 "in flight"
Durability Committed means permanent After COMMIT, a power failure does not undo the transfer

Atomicity means a transaction is indivisible: if any part fails or is rolled back, the database returns to the state before it began. The examples above showed exactly this.

Consistency means a transaction can only move the database from one valid state to another, where "valid" is defined by its constraints — primary and foreign keys, CHECK, NOT NULL (see Designing Tables). The CHECK on balance made the overdraft impossible. Business rules that are not expressed as constraints remain the application's responsibility. (This is not the same "consistency" as in the CAP theorem; see NoSQL Databases.)

Isolation means concurrent transactions do not interfere: ideally, the result is as if they ran one after another. Full isolation is expensive, so databases offer weaker levels, covered below.

Durability means that once COMMIT returns, the changes survive crashes and power loss. Databases achieve this by writing every change to a log (the write-ahead log in PostgreSQL, the redo log in MySQL and Oracle, the transaction log in SQL Server; SQLite uses a rollback journal or optional WAL mode) and forcing it to disk before confirming the commit; after a crash, the log is used to recover a consistent state. The same log is the basis of point-in-time recovery and replication (see Backup, Recovery and Replication).

What goes wrong when transactions overlap

A busy database runs many transactions at the same time. Without isolation, several well-known problems can occur. A dirty read is the worst of them: one transaction reads data that another has changed but not yet committed — data that may be rolled back a moment later and so never officially existed.

Problem What happens Example
Dirty read Reading another transaction's uncommitted change A report counts Kwame's €200 from a transfer that is then rolled back
Non-repeatable read Reading the same row twice gives different values, because another transaction committed a change in between An invoice job reads a price, someone updates it, the job reads it again for the total and gets another number
Phantom read Running the same query twice returns a different set of rows, because another transaction inserted or deleted matching rows "Count pending orders" gives 2, a new order arrives, the detail list shows 3
Lost update Two transactions read the same value and both write back a changed version; one change is overwritten Two clerks each read stock 12 and sell one; both write 11 instead of 10

The lost update is a race condition (see Concurrency Basics). The simplest cure is to let the database do the arithmetic in one statement — UPDATE products SET stock = stock - 1 WHERE product_id = 4 — instead of reading the value into the program and writing it back.

Isolation levels

An isolation level is a setting that decides which of these problems a transaction is protected from. Stronger isolation means more waiting or more retried transactions; weaker isolation means more concurrency. The SQL standard defines four levels:

Level Dirty read Non-repeatable read Phantom read
READ UNCOMMITTED Possible Possible Possible
READ COMMITTED Prevented Possible Possible
REPEATABLE READ Prevented Prevented Possible
SERIALIZABLE Prevented Prevented Prevented

The standard sets minimums; real systems often protect more. Their defaults differ, which matters when code moves from one database to another:

System Default level Worth knowing
PostgreSQL READ COMMITTED READ UNCOMMITTED behaves as READ COMMITTED; REPEATABLE READ also prevents phantoms
MySQL (InnoDB) REPEATABLE READ Plain SELECTs read a consistent snapshot taken at the first read
SQL Server READ COMMITTED Uses locks by default; with the database option READ_COMMITTED_SNAPSHOT on (the default in Azure SQL Database), readers use row versions instead
Oracle READ COMMITTED Offers READ COMMITTED and SERIALIZABLE (plus a READ ONLY mode)
SQLite Serializable Only one writer at a time, so transactions are serializable by design

The level is chosen per transaction or per session. In PostgreSQL, for example (not run here):

BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- statements
COMMIT;

At SERIALIZABLE, PostgreSQL may abort a transaction with a serialization failure rather than allow an anomaly; the application is expected to retry it, just like a deadlock victim.

Locking and MVCC

Databases enforce isolation with two main techniques, usually combined.

A database lock is a marker a transaction places on a row, page or table to control what other transactions may do with it until the lock is released, usually at commit or rollback. A shared (read) lock lets others read but not change the data; an exclusive (write) lock keeps others from changing it, and in purely lock-based systems from reading it too. Most systems lock individual rows, so two transactions that update different customers do not wait for each other. SQLite is the exception: it locks the whole database file, so only one transaction can write at a time.

Multiversion concurrency control (MVCC) keeps several versions of a changed row. A reader sees the last committed version as of the moment its statement or transaction started, while a writer works on a new version. The practical result: readers do not block writers, and writers do not block readers. Only two writers on the same row still wait for each other. PostgreSQL, MySQL's InnoDB and Oracle are built on MVCC; SQL Server uses it when READ_COMMITTED_SNAPSHOT or snapshot isolation is enabled (READ_COMMITTED_SNAPSHOT is on by default in Azure SQL Database). The cost is old row versions that must be cleaned up later — the reason PostgreSQL needs VACUUM (see Database Administration in Practice).

When a program needs to read a row and then update it safely, it can lock the row while reading: SELECT … FOR UPDATE in PostgreSQL, MySQL and Oracle (SQL Server uses the UPDLOCK table hint). Other transactions that want to change that row wait until the first one commits — the classic fix for a lost update when the logic cannot be written as a single UPDATE.

Deadlocks

A database deadlock occurs when two (or more) transactions each hold a lock the other needs, so each waits for the other forever. Suppose Sanne pays Kwame while, at the same moment, Kwame pays Sanne, and each transfer updates the payer's account first:

Timeline of two transactions. T1 locks account 1 and T2 locks account 2. Then T1 asks for account 2 and waits for T2, while T2 asks for account 1 and waits for T1. The database detects the cycle, rolls back T2 as the victim, and T1 gets its lock and commits. The application retries T2 afterward. T1 Sanne pays Kwame T2 Kwame pays Sanne lock acct 1 wants acct 2: waits lock 2, COMMIT lock acct 2 wants acct 1: waits victim: ROLLBACK cycle → deadlock detected time
Each transaction holds one account and waits for the other's. The database breaks the cycle by rolling back one of them (the victim); the other then proceeds.

Neither transaction can continue on its own. Databases detect such cycles and break them by choosing a deadlock victim: one transaction is rolled back with an error, which releases its locks so the other can finish. PostgreSQL reports "deadlock detected", MySQL "Deadlock found when trying to get lock; try restarting transaction", and SQL Server error 1205 ("chosen as the deadlock victim"). SQLite, with its single writer, does not deadlock in this way; a second writer gets "database is locked" after its busy timeout instead.

A deadlock is not a bug in the database and not data corruption — the victim's work was rolled back cleanly. The right response is in the application:

  • Retry the whole transaction when the database reports a deadlock (or serialization failure), a few times with a short, growing delay.
  • Access rows in a fixed order. If every transfer locks the account with the lower id first, the cycle above cannot form — the same rule as lock ordering in Concurrency Basics.
  • Keep transactions short, so locks are held briefly and the chance of overlap is small.

Keeping transactions short

Locks and row versions are held until a transaction ends. A transaction that stays open makes others wait, keeps old versions alive (which blocks cleanup and makes tables grow), and raises the chance of conflicts and deadlocks. Practical rules:

  • Never wait for a person inside a transaction. Collect the user's input first, then begin, write and commit in one go.
  • Do not call slow external services (payment providers, email) while a transaction is open.
  • Do the reading and calculating that does not need locks before BEGIN.
  • Break huge batch updates into chunks of, say, a few thousand rows, each in its own transaction.
  • Always end a transaction: a program that crashes between BEGIN and COMMIT should leave the connection closed (the database then rolls back), not idle forever. Monitoring for long-running and "idle in transaction" sessions is a routine DBA task.