A transaction is a unit of work, not a speed trick. Name the business fact that must not exist halfway—an order with its lines, a debit with its credit—then BEGIN, write, and either COMMIT or ROLLBACK. A savepoint is a bookmark inside that unit, for when only the later steps should be undone.
Without BEGIN, each statement commits itself
SQLite (and most engines) autocommit successful statements when no transaction is open. The two inserts below are two units of work. If the process died after the first, the first row would already be durable. That is why “insert the order, then insert the lines” without BEGIN can leave a header with no items.
SQL BROWSER RUNNER
See two autocommit inserts persist
Insert two notes with no BEGIN. Both statements commit on their own.
CREATETABLE notes (
note_id INTEGERPRIMARYKEY,
body TEXTNOTNULL);INSERTINTO notes (body)VALUES('first autocommit');INSERTINTO notes (body)VALUES('second autocommit');SELECT note_id, body
FROM notes
ORDERBY note_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
BEGIN then COMMIT publishes the whole unit
BEGIN opens a transaction. Inserts inside it are not finished business until COMMIT. After commit, a SELECT sees both notes. The unit of work was “store these two lines together,” and the database kept that promise.
SQL BROWSER RUNNER
Commit two inserts as one unit
Open a transaction, insert twice, commit, then read.
CREATETABLE notes (
note_id INTEGERPRIMARYKEY,
body TEXTNOTNULL);BEGIN;INSERTINTO notes (body)VALUES('inside the unit');INSERTINTO notes (body)VALUES('still inside the unit');COMMIT;SELECT note_id, body
FROM notes
ORDERBY note_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
ROLLBACK forgets the open unit, not earlier commits
The first insert sits outside the transaction, so it is already committed. The next two sit inside BEGIN and are discarded by ROLLBACK. Read the table: one row remains. Rollback is not “undo the database.” It is “undo this unit.”
SQL BROWSER RUNNER
Roll back uncommitted notes
Commit one note, begin a unit, insert two more, roll back, then select.
CREATETABLE notes (
note_id INTEGERPRIMARYKEY,
body TEXTNOTNULL);INSERTINTO notes (body)VALUES('already committed');BEGIN;INSERTINTO notes (body)VALUES('should vanish');INSERTINTO notes (body)VALUES('also vanish');ROLLBACK;SELECT note_id, body
FROM notes
ORDERBY note_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
A savepoint undoes part of an open transaction
SAVEPOINT names a bookmark. ROLLBACK TO that name undoes work after the bookmark and keeps work before it. RELEASE drops the bookmark. The transaction is still open until COMMIT or a full ROLLBACK.
Use this during a bulk import: keep the reviewed batch, throw away the experimental batch, then commit. Full rollback would discard the reviewed rows too.
SQL BROWSER RUNNER
Keep one imported sku and discard the rest
Insert a notebook, set a savepoint, insert two more skus, roll back to the savepoint, then commit.
CREATETABLE import_rows (
sku TEXTPRIMARYKEY,
title TEXTNOTNULL);BEGIN;INSERTINTO import_rows (sku, title)VALUES('NB-1','Notebook');SAVEPOINT after_good_batch;INSERTINTO import_rows (sku, title)VALUES('ST-1','Sticker');INSERTINTO import_rows (sku, title)VALUES('PS-1','Poster');ROLLBACKTO after_good_batch;RELEASE after_good_batch;COMMIT;SELECT sku, title
FROM import_rows
ORDERBY sku;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
A transfer is one fact: money leaves and arrives
Debiting Ada 400 cents and crediting Grace 400 cents is not two independent updates. It is one transfer. Wrap both in a transaction so a crash cannot credit without debiting. Starting balances are 500 and 100. After a successful 400-cent transfer they should be 100 and 500.
SQL BROWSER RUNNER
Commit a debit and credit together
Move 400 cents from Ada to Grace inside one transaction.
Edit the query, predict the rows it will return, then run it.
A transaction will happily commit a business bug
SQL success is not business success. UPDATE ... WHERE cents >= 900 matches zero rows when Ada has 500. That statement still “succeeds.” The following credit still runs. After COMMIT, Grace has 1000 cents and Ada still has 500. You invented money. The transaction did exactly what you asked.
SQL BROWSER RUNNER
Commit a credit after a zero-row debit
Try to move 900 cents Ada does not have, then still credit Grace.
Edit the query, predict the rows it will return, then run it.
Capture changes() after the debit. Only credit when that count is 1. Zero-row debit, zero-row credit, balances unchanged. No error required—just a guard that matches the transfer rule.
SQL BROWSER RUNNER
Credit only if the debit applied
Store changes() after the debit, and gate Grace's credit on applied = 1.
Edit the query, predict the rows it will return, then run it.
Parent and child rows are one unit too
An order without items is not an order. Turn foreign keys on for this connection, then insert the header and the lines before COMMIT. If you skipped BEGIN and the second item insert failed, SQLite would already have committed the header. That is the autocommit trap from the first example, now with a real schema.
SQL BROWSER RUNNER
Commit an order with its line items
Enable foreign keys, insert one order and two items, then join them back.
PRAGMA foreign_keys =ON;CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer TEXTNOTNULL);CREATETABLE order_items (
item_id INTEGERPRIMARYKEY,
order_id INTEGERNOTNULLREFERENCES orders (order_id),
sku TEXTNOTNULL,
qty INTEGERNOTNULLCHECK(qty >0));BEGIN;INSERTINTO orders (order_id, customer)VALUES(10,'Ada');INSERTINTO order_items (order_id, sku, qty)VALUES(10,'NB-1',1),(10,'ST-1',2);COMMIT;SELECT o.order_id, o.customer, i.sku, i.qty
FROM orders AS o
JOIN order_items AS i ON i.order_id = o.order_id
ORDERBY i.item_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
A transaction checklist
Write the business sentence: which facts must appear together or not at all.
BEGIN (or start the transaction in your client library).
Perform the writes. Keep the WHERE clauses from the last two lessons.
Check results that SQL treats as success but the business treats as failure: changes() = 0, missing parent, insufficient seats.
On failure, ROLLBACK. On a recoverable inner failure, ROLLBACK TO a savepoint.
On success, COMMIT, then SELECT the unit you intended to publish.
Remember the engine: PostgreSQL needs rollback after an error; SQLite may still have earlier statements in the transaction.
Independent lab: one seat, two booking attempts
A workshop has one seat. Ada's booking must decrement seats and insert a registration in one transaction, gated on changes(). Grace tries the same afterward. Predict: Ada is registered, seats_left is 0, Grace is absent. Run it. Then change the first COMMIT to ROLLBACK and run again: both learners should be absent and the seat should remain 1.
SQL BROWSER RUNNER
Book a last seat without overselling
Gate each registration on a successful seat decrement, then attempt a second booking.
PRAGMA foreign_keys =ON;CREATETABLE workshops (
workshop_id INTEGERPRIMARYKEY,
title TEXTNOTNULL,
seats_left INTEGERNOTNULLCHECK(seats_left >=0));CREATETABLE registrations (
registration_id INTEGERPRIMARYKEY,
workshop_id INTEGERNOTNULLREFERENCES workshops (workshop_id),
learner TEXTNOTNULL);INSERTINTO workshops (workshop_id, title, seats_left)VALUES(10,'Transactions in practice',1);BEGIN;UPDATE workshops
SET seats_left = seats_left -1WHERE workshop_id =10AND seats_left >=1;CREATETEMPTABLE seat_ok ASSELECT changes()AS applied;INSERTINTO registrations (workshop_id, learner)SELECT10,'Ada'WHERE(SELECT applied FROM seat_ok)=1;COMMIT;BEGIN;UPDATE workshops
SET seats_left = seats_left -1WHERE workshop_id =10AND seats_left >=1;CREATETEMPTABLE seat_ok_2 ASSELECT changes()AS applied;INSERTINTO registrations (workshop_id, learner)SELECT10,'Grace'WHERE(SELECT applied FROM seat_ok_2)=1;COMMIT;SELECT w.workshop_id, w.title, w.seats_left, r.learner
FROM workshops AS w
LEFTJOIN registrations AS r ON r.workshop_id = w.workshop_id
ORDERBY r.registration_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Common mistakes to avoid
Inserting a parent, then children, with autocommit, and calling that “atomic.”
Assuming a zero-row UPDATE aborts the transaction.
Crediting a wallet without checking that the debit applied.
Catching an error in PostgreSQL and continuing to write on the same transaction.
Using ROLLBACK TO when you meant a full ROLLBACK, or the reverse.
Forgetting PRAGMA foreign_keys = ON in SQLite and believing the FK protected you.
Leaving a transaction open while waiting on a user or an HTTP call.
Skipping the verification SELECT after COMMIT.
Lesson review
I can explain autocommit versus an explicit BEGIN.
I can COMMIT a unit of work and ROLLBACK an uncommitted one.
I can use a savepoint to undo only the later part of a transaction.
I can wrap a debit and credit so money cannot be created by a zero-row debit.
I can keep parent and child rows in one transaction with foreign keys on.
I can say how PostgreSQL and SQLite differ after an error inside a transaction.
KNOWLEDGE CHECK
Check your transaction reasoning
Answer all ten questions, then revisit the runner whose COMMIT, ROLLBACK, or guard still feels unclear.