databases/transactions-why-a-money-transfer-must-save-both-changes-or-neither.md

Transactions: why a money transfer must save both changes or neither

Databases · 5 min read ·

Sending £50 between two accounts takes two changes to a database. If the power fails between them, the money vanishes.

A *transaction* groups changes so that either all of them are saved or none are. Step through a transfer, cut the power at the worst moment, and compare what survives with a transaction on and off. Then run the same idea for real in Python's SQLite and meet the four ACID promises.

A dark card showing Ana's 100 pounds moving 50 to Ben's 20, above a BEGIN, two UPDATEs, COMMIT block, with the words All or nothing

You send £50 from Ana's account to Ben's. Behind the scenes that is two separate changes to a database: take £50 off Ana, then add £50 to Ben. What if the server loses power after the first change and before the second? Ana is £50 poorer and Ben got nothing. The money has simply vanished.

Databases solve this with a transaction: a group of changes that the database treats as one unit. Either every change in the group happens, or none of them do. There is no in-between state that anyone can be left holding.

Two changes, one idea

In SQL (the language most databases are asked questions in), a change to existing rows is an UPDATE. Our transfer is two of them:

UPDATE accounts SET balance = balance - 50 WHERE name = 'Ana';
UPDATE accounts SET balance = balance + 50 WHERE name = 'Ben';

On their own, each statement is saved the moment it finishes (this is often called autocommit). So after the first line, "Ana has £50 less" is already permanent. The database has no idea the second line was supposed to follow.

Wrapping them in a transaction

To group the statements, you tell the database where the group starts and how it ends:

  • BEGIN starts the group. From here on, changes are kept in a draft that only you can see.
  • COMMIT makes the whole draft permanent, all at once.
  • ROLLBACK throws the draft away, as if none of it had happened. If the database or the program crashes before a COMMIT, the draft is thrown away too.

Try it. The simulator below runs the transfer one step at a time. Press Run next step a few times, then press Crash! to cut the power in the middle. Then switch the transaction on or off and compare what survives.

With the transaction on, a crash before COMMIT leaves the saved balances exactly as they were: £100 and £20. With it off, a crash between the two updates leaves £50 and £20, and £50 has disappeared.

Running it for real

This is not a toy idea; you can run the same thing with Python's built-in sqlite3 module (this output is from SQLite 3.45.1):

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER CHECK (balance >= 0))")
db.executemany("INSERT INTO accounts VALUES (?, ?)", [("Ana", 100), ("Ben", 20)])
db.commit()

try:
    db.execute("UPDATE accounts SET balance = balance - 50 WHERE name = 'Ana'")
    raise RuntimeError("crash!")          # pretend the program dies here
    db.execute("UPDATE accounts SET balance = balance + 50 WHERE name = 'Ben'")
    db.commit()
except RuntimeError as e:
    db.rollback()
    print("error:", e)

print(db.execute("SELECT name, balance FROM accounts ORDER BY name").fetchall())
error: crash!
[('Ana', 100), ('Ben', 20)]

The first update ran, but because COMMIT never did, ROLLBACK undid it. Nothing was lost.

Errors roll back too

A transaction also protects you from mistakes the database itself catches. Our table has a rule, CHECK (balance >= 0), that forbids a negative balance. In Python, with db: commits if the block finishes and rolls back if it raises an error:

try:
    with db:
        db.execute("UPDATE accounts SET balance = balance + 80 WHERE name = 'Ben'")
        db.execute("UPDATE accounts SET balance = balance - 80 WHERE name = 'Ana'")
except sqlite3.IntegrityError as e:
    print("error:", e)

After a successful £50 transfer Ana has £50, so taking £80 breaks the rule. The output:

error: CHECK constraint failed: balance >= 0
[('Ana', 50), ('Ben', 70)]

Ben's first update (+ 80) had already run when the second one failed, yet Ben is still at £70. The whole group was undone.

What databases promise

Transactions are the heart of a famous set of guarantees called ACID:

  • Atomicity: all the changes happen, or none do. This is what you just played with.
  • Consistency: a transaction can only move the database from one valid state to another, so rules like balance >= 0 are never left broken.
  • Isolation: other people using the database at the same time don't see your half-finished draft.
  • Durability: once COMMIT succeeds, the change survives a crash or power cut.

Most relational databases, including SQLite, PostgreSQL and MySQL (with its InnoDB engine), support transactions. Each differs in the details of isolation, which is a topic of its own.

What this means for your code

Whenever one real-world action needs more than one change to your data (an order plus its stock count, a signup plus a welcome record, a transfer between two accounts), put those changes in one transaction. Keep it short: do the thinking first, then BEGIN, make the changes, and COMMIT. And don't wait on slow things such as sending an email in the middle, because other work may be waiting on your draft to finish.

$ ls databases/

see all →
databases/the-n-1-query-problem-why-a-page-that-asks-the-database-501-questions.md The N+1 query problem: why a page that asks the database 501 questions is slow A page that lists authors and their books can quietly send one query per author. With 500 authors that is 501 trips to the database, and… 4 min read · 26 Oct databases/sql-indexes-why-finding-one-row-among-200-000-takes-about-18-steps.md SQL indexes: why finding one row among 200,000 takes about 18 steps A database with 200,000 customers can answer "find this email" by reading every row, or by reading about 18 entries. The difference is an… 4 min read · 20 Oct databases/sql-group-by-how-many-rows-become-one-row-per-customer.md SQL GROUP BY: how many rows become one row per customer A shop's orders table has 8 rows, but the owner wants one line per customer. GROUP BY sorts rows into piles by a column's value, then an… 4 min read · 17 Oct databases/sql-null-why-two-people-vanish-from-both-your-queries.md SQL NULL: why two people vanish from both your queries Two people have no age in your table. You ask for everyone older than 30, then for everyone not older than 30, and the two lists add up to… 4 min read · 14 Oct databases/sql-joins-how-two-tables-become-one-answer-inner-left-right-full.md SQL JOINs: how two tables become one answer (INNER, LEFT, RIGHT, FULL) Your data lives in two tables, but your question needs both. A JOIN lines them up, and the four kinds (INNER, LEFT, RIGHT, FULL) differ… 5 min read · 11 Oct databases/database-transactions-why-moving-money-is-all-or-nothing.md Database transactions: why moving money is all or nothing Paying someone is really two changes in a database: take money from one account, add it to another. If the power dies between them, money… 6 min read · 7 Oct