From 221d2afa53a422ae5e59b423cc271ff4cc350f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 16:50:34 +0200 Subject: [PATCH] Add lesson 08-transactions-basics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers BEGIN/COMMIT/ROLLBACK with the canonical account-transfer example, the aborted-transaction state after a statement error, the BEGIN…ROLLBACK dry-run pattern, and SAVEPOINT/ROLLBACK TO SAVEPOINT for partial rollback. Closes with a "how long should a transaction be" rule-of-thumb section. Part of #6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../04-transactions-basics/lesson.mdx | 103 ++++++++++++++++++ .../04-transactions-basics/lesson.yaml | 27 +++++ .../04-transactions-basics/seed.sql | 12 ++ 3 files changed, 142 insertions(+) create mode 100644 lessons/02-changing-data/04-transactions-basics/lesson.mdx create mode 100644 lessons/02-changing-data/04-transactions-basics/lesson.yaml create mode 100644 lessons/02-changing-data/04-transactions-basics/seed.sql diff --git a/lessons/02-changing-data/04-transactions-basics/lesson.mdx b/lessons/02-changing-data/04-transactions-basics/lesson.mdx new file mode 100644 index 0000000..e75ebfe --- /dev/null +++ b/lessons/02-changing-data/04-transactions-basics/lesson.mdx @@ -0,0 +1,103 @@ +A transaction groups a sequence of statements so they all succeed together or none of them takes effect. It's the feature you'd miss most if Postgres took it away — the one that keeps your data consistent when something halfway through goes wrong. + +The seed has an `accounts` table with three owners, each starting at 100. We'll use it to write the textbook "transfer money" example and watch transactions actually do their job. + +## The implicit transaction + +Every statement you've run so far has been wrapped in its own transaction — Postgres calls this *autocommit*. `INSERT INTO ...` is really `BEGIN; INSERT INTO ...; COMMIT;`. You haven't had to think about it because each statement either succeeds or raises an error before changing anything visible. + +The moment you have **two statements that must both succeed or both fail**, autocommit isn't enough. + +## BEGIN ... COMMIT + +The shape: `BEGIN;` opens a transaction, your statements run inside it, `COMMIT;` makes them durable. + + +BEGIN; +UPDATE accounts SET balance = balance - 20 WHERE owner = 'ada'; +UPDATE accounts SET balance = balance + 20 WHERE owner = 'grace'; +COMMIT; + + + +Ada had 100 and just sent 20 to Grace inside a committed transaction. Her balance is now 80. + + + +The transfer didn't create or destroy money — the three accounts still sum to 300. + + +Inside the transaction, the two UPDATEs are *not yet visible to other connections*. Only at `COMMIT;` does the world see both changes simultaneously. If something failed between them — a constraint violation, a network drop, the database crashing — neither change would have landed. + +## ROLLBACK: undo, on purpose + +`ROLLBACK;` discards every change made since the matching `BEGIN`. Useful as a tool — and essential when you realize an UPDATE was about to be too broad. + + +BEGIN; +UPDATE accounts SET balance = 0; +SELECT owner, balance FROM accounts; +ROLLBACK; + + +The `SELECT` *inside* the transaction sees the zeros — that's the state you'd be committing. The `ROLLBACK` throws it away. After running this block, querying outside the transaction shows the original balances. + + +SELECT owner, balance FROM accounts ORDER BY owner; + + +Use `BEGIN; ... ROLLBACK;` as a dry-run for destructive statements: write the UPDATE or DELETE, run it inside a transaction, inspect what it touched, and roll back if it's wrong. Cheaper than a backup. + +## Errors auto-rollback + +If a statement inside a transaction *errors*, Postgres marks the transaction as **aborted**: every subsequent statement fails with `current transaction is aborted, commands ignored until end of transaction block` until you issue `COMMIT` or `ROLLBACK`. Both end the transaction; the difference at that point is purely semantic — the changes are gone either way. + + +BEGIN; +UPDATE accounts SET balance = balance - 200 WHERE owner = 'ada'; +ROLLBACK; + + +That UPDATE violates the `balance >= 0` check constraint and raises an error mid-statement. The transaction goes into the aborted state — you `ROLLBACK` to leave cleanly. None of Ada's balance changed. + +## Savepoints: partial rollback + +A `SAVEPOINT` is a named mark inside a transaction. `ROLLBACK TO SAVEPOINT x` undoes everything after that mark, but **leaves the transaction itself open** — you can keep going. This is how you implement "try this; if it fails, fall back". + + +BEGIN; +UPDATE accounts SET balance = balance + 5 WHERE owner = 'linus'; +SAVEPOINT after_bonus; +UPDATE accounts SET balance = balance - 999 WHERE owner = 'linus'; +ROLLBACK TO SAVEPOINT after_bonus; +UPDATE accounts SET balance = balance + 1 WHERE owner = 'linus'; +COMMIT; + + +What happened: Linus got +5, then a doomed -999 which would've blown the check constraint, then we rewound to `after_bonus` (un-doing the bad UPDATE but keeping the bonus), then nudged +1. Final: Linus is at 100 + 5 + 1 = 106. + + +SELECT owner, balance FROM accounts ORDER BY owner; + + +Savepoints are how ORMs and connection-pool middleware implement "try block" semantics inside a longer transaction — and how you can recover from an aborted statement without throwing away the whole transaction. + +## How long should a transaction be? + +As short as it can be. While a transaction is open, Postgres has to keep around the rows your statements have modified *and* the rows other transactions are modifying, in case you query them. Open transactions block VACUUM from cleaning up dead rows and, in extreme cases, can bloat the database. + +Rules of thumb: + +- **Don't `BEGIN` and then go do non-database work** (network calls, file I/O, user input). Do that work first, then open the transaction just long enough to write. +- **Keep the statement count small.** Three or four related writes is normal; thirty is a smell. +- **Commit on success, rollback on error, never leave a transaction hanging.** This is what `try { ... } finally { rollback if not committed }` patterns in app code are for. + +## What you learned + +- `BEGIN; ... COMMIT;` groups statements so they apply atomically; other connections see all changes at once or none. +- `ROLLBACK;` discards everything since `BEGIN`. A statement error puts the transaction in an aborted state until you `ROLLBACK` or `COMMIT` to exit. +- `BEGIN; UPDATE ...; ROLLBACK;` is a dry-run pattern for destructive statements. +- `SAVEPOINT name` / `ROLLBACK TO SAVEPOINT name` undo a piece of the transaction without ending it. +- Keep transactions short — they hold resources and block cleanup until they end. + +Up next: combining tables, starting with module 3 and the family of `JOIN`s. diff --git a/lessons/02-changing-data/04-transactions-basics/lesson.yaml b/lessons/02-changing-data/04-transactions-basics/lesson.yaml new file mode 100644 index 0000000..191742f --- /dev/null +++ b/lessons/02-changing-data/04-transactions-basics/lesson.yaml @@ -0,0 +1,27 @@ +title: Transactions basics +summary: BEGIN, COMMIT, ROLLBACK — make a group of statements all-or-nothing — plus savepoints for partial rollback. +estimatedMinutes: 14 +tags: + - transactions + - begin + - commit + - rollback + - savepoint +authors: + - exekias +seed: seed.sql +checks: + - id: transfer-balances-preserved + type: query-returns + description: After a transferred amount, the two accounts still sum to the original total. + sql: SELECT sum(balance)::int FROM accounts + expect: + rowCount: 1 + rows: [[300]] + - id: ada-balance-after-transfer + type: query-returns + description: Ada's balance is 80 after sending 20 to Grace inside a committed transaction. + sql: SELECT balance FROM accounts WHERE owner = 'ada' + expect: + rowCount: 1 + rows: [[80]] diff --git a/lessons/02-changing-data/04-transactions-basics/seed.sql b/lessons/02-changing-data/04-transactions-basics/seed.sql new file mode 100644 index 0000000..155e6a7 --- /dev/null +++ b/lessons/02-changing-data/04-transactions-basics/seed.sql @@ -0,0 +1,12 @@ +-- Seed for "04-transactions-basics": the canonical bank-accounts table so +-- "transfer money atomically" is the worked example. + +CREATE TABLE accounts ( + owner text PRIMARY KEY, + balance int NOT NULL CHECK (balance >= 0) +); + +INSERT INTO accounts (owner, balance) VALUES + ('ada', 100), + ('grace', 100), + ('linus', 100);