Transactions & ACID

When RecipeBox saves a new recipe, it doesn't just insert one row — it inserts the recipe, then several ingredient rows tied to it. If the app crashed halfway through, you'd want either all of those rows saved, or none of them — never a recipe with half its ingredients missing. That's exactly what a transaction guarantees, and it's the mechanism behind SQLite being fully ACID-compliant.

ACID, Briefly

PropertyMeaning
AtomicityAll statements in a transaction succeed together, or none of them take effect
ConsistencyA transaction always leaves the database satisfying its constraints (foreign keys, unique indexes, etc.)
IsolationConcurrent transactions don't see each other's uncommitted changes
DurabilityOnce a transaction is committed, it survives a crash or power loss

BEGIN, COMMIT, ROLLBACK

BEGIN (or BEGIN TRANSACTION) starts an explicit transaction. COMMIT saves every change made since BEGIN, permanently. ROLLBACK discards every change made since BEGIN, as if none of it ever happened.

-- Save a new recipe together with all of its ingredients, atomically
BEGIN TRANSACTION;

INSERT INTO recipes (title, category, prep_minutes, user_id)
VALUES ('Apple Pie', 'Dessert', 60, 7);

INSERT INTO ingredients (recipe_id, name, quantity)
VALUES (last_insert_rowid(), 'Apples', '6 cups');

INSERT INTO ingredients (recipe_id, name, quantity)
VALUES (last_insert_rowid(), 'Sugar', '3/4 cup');

INSERT INTO ingredients (recipe_id, name, quantity)
VALUES (last_insert_rowid(), 'Pie Crust', '2 crusts');

COMMIT;

If any statement fails partway through — say a NOT NULL constraint rejects one ingredient row — you can undo everything since BEGIN instead of committing a half-saved recipe:

BEGIN TRANSACTION;

INSERT INTO recipes (title, category, prep_minutes, user_id)
VALUES ('Broken Recipe', 'Dessert', 45, 7);

-- Suppose this insert fails (e.g. quantity is NOT NULL but omitted here)
-- INSERT INTO ingredients (recipe_id, name) VALUES (last_insert_rowid(), 'Flour');

-- Undo everything since BEGIN -- the recipe row is never saved either
ROLLBACK;

Implicit Transactions and Why Explicit Ones Matter for Performance

By default, every single SQL statement you run that isn't wrapped in an explicit BEGIN/COMMIT runs as its own implicit transaction — SQLite automatically begins and commits around it. That's convenient, but it has a real performance cost: each commit involves an fsync to disk to guarantee durability, and doing that once per statement is slow when you're inserting many rows in a loop.

-- Slow: 500 implicit transactions, 500 fsyncs
-- (running one INSERT per loop iteration with no BEGIN/COMMIT around it)
INSERT INTO ingredients (recipe_id, name, quantity) VALUES (1, 'Salt', '1 tsp');
-- ...499 more separate INSERT statements...

-- Fast: wrap all 500 inserts in one explicit transaction, one fsync
BEGIN TRANSACTION;
INSERT INTO ingredients (recipe_id, name, quantity) VALUES (1, 'Salt', '1 tsp');
-- ...499 more INSERT statements...
COMMIT;
Practical tip: Any time your app performs many related writes in a loop — a bulk import, seeding sample data, saving a recipe with a dozen ingredients — wrap them in one explicit transaction. It's both more correct (all-or-nothing) and dramatically faster than letting each write commit on its own.
Next up: Head to Triggers to see how SQLite can run SQL automatically in response to inserts, updates, and deletes.