Without notes, state yesterday’s main idea and one unresolved question.
Transactions, indexes, and migrations
SQL and PostgreSQL
Objective
Make multi-step changes atomic and evolve schemas deliberately.
A relational schema resembles a disciplined wiring and labeling plan: constraints prevent invalid connections and keys define relationships.
- BEGIN, COMMIT, ROLLBACK
- index tradeoffs
- migration ordering
Why this matters
Yesterday's walkthrough ended with two statements that had to both happen: log the maintenance visit, and set the equipment's status. Nothing so far guarantees that. If the second fails, you are left with a service record for a machine the system still believes is broken — and no error anywhere to tell you.
Today you close that gap with transactions, make one slow query fast with an index while understanding what the index costs, and stop typing schema changes by hand. These three are what separate a database you can operate from one you are merely using.
Atomicity: all or nothing
A transaction is a group of statements the database treats as one indivisible unit. Either every statement takes effect, or none does. That property is called atomicity — atomic in the original sense of "cannot be cut into parts".
Three keywords control it:
BEGINstarts a transaction. Everything after it is provisional.COMMITmakes all of it permanent, in one instant, visible to everyone at once.ROLLBACKdiscards all of it, leaving the database exactly as it was beforeBEGIN.
Here is one business operation — "record a service visit and mark the machine as under maintenance" — done as one unit:
BEGIN;
INSERT INTO maintenance_records (equipment_id, performed_on, hours_spent, notes)
VALUES (4, '2025-06-02', 2.50, 'Impeller replacement');
UPDATE equipment SET status = 'maintenance' WHERE id = 4;
COMMIT;
BEGIN
INSERT 0 1
UPDATE 1
COMMIT
Now the same thing with a fault planted in the second statement — a status value your CHECK
constraint does not allow:
BEGIN;
INSERT INTO maintenance_records (equipment_id, performed_on, hours_spent, notes)
VALUES (5, '2025-06-03', 1.00, 'Belt tension check');
UPDATE equipment SET status = 'under repair' WHERE id = 5;
COMMIT;
BEGIN
INSERT 0 1
ERROR: new row for relation "equipment" violates check constraint "equipment_status_check"
ROLLBACK
Read the last line carefully: you typed COMMIT and PostgreSQL performed a ROLLBACK. Once a
statement inside a transaction fails, the transaction is poisoned and can only be thrown away. The
INSERT reported INSERT 0 1 and still did not survive:
SELECT count(*) FROM maintenance_records WHERE equipment_id = 5;
count
-------
0
(1 row)
That zero is atomicity. Without the transaction, the maintenance record would be sitting in your table right now, attached to a machine whose status was never updated — a half-finished operation that no error message would ever mention again.
In interactive psql you can watch the state on the prompt itself. It reads maintenance=#
normally, maintenance=*# inside an open transaction, and maintenance=!# once the transaction
has failed. In that failed state every further statement is refused:
maintenance=!# SELECT count(*) FROM equipment;
ERROR: current transaction is aborted, commands ignored until end of transaction block
The fix is to end the block — type ROLLBACK; and the prompt returns to maintenance=#.
A transaction is a latched output stage
A controller computing a new set of outputs does not drive each pin as it is calculated —
downstream equipment would see a half-updated pattern and act on nonsense. It writes to a holding
register and pulses one latch line, so every output changes together on one clock edge.
COMMIT is that latch pulse. ROLLBACK is clearing the holding register before it ever
reaches the pins.
The bank transfer
Move money by subtracting from one account and adding to another. If the machine dies between the two, the money has ceased to exist. The pair must be one operation or neither, which is why transactions were invented in banking before anyone wrote a web application.
The practical rule: one transaction should wrap one business operation. Not one statement — that is already atomic on its own. Not a whole request handler that also sends email — if the email fails you do not want the database change undone. Ask "what would a user consider one thing happening?" and put the boundary there.
Indexes and what they cost
By default, finding rows means reading the whole table. An index is a separate, sorted structure the database maintains alongside a table so it can jump straight to matching rows.
EXPLAIN ANALYZE runs a query and reports how it was executed. Try it on a table with 50,000
maintenance records:
EXPLAIN ANALYZE
SELECT id, equipment_id, notes FROM maintenance_records WHERE performed_on = DATE '2024-05-19';
Seq Scan on maintenance_records (cost=0.00..1093.08 rows=25 width=27)
(actual time=0.010..4.827 rows=26.00 loops=1)
Filter: (performed_on = '2024-05-19'::date)
Rows Removed by Filter: 49980
Execution Time: 4.946 ms
Seq Scan means sequential scan: every row read, 49,980 of them discarded, to return 26. Add an index on the column being filtered:
CREATE INDEX maintenance_records_performed_on_idx ON maintenance_records (performed_on);
ANALYZE maintenance_records;
Bitmap Heap Scan on maintenance_records (cost=4.48..87.46 rows=25 width=27)
(actual time=0.051..0.101 rows=26.00 loops=1)
Recheck Cond: (performed_on = '2024-05-19'::date)
-> Bitmap Index Scan on maintenance_records_performed_on_idx
Index Cond: (performed_on = '2024-05-19'::date)
Execution Time: 0.162 ms
The scan of 50,000 rows is gone, and execution time fell from 4.9 ms to 0.16 ms. (ANALYZE
refreshes the statistics the planner uses to choose; without current statistics it may keep the
old plan.)
Now the part people skip. An index is not free:
- Every write pays. Each
INSERT,UPDATE, andDELETEmust update every index on the table. A table with eight indexes writes nine structures per row. - It occupies disk and memory, competing with your data for cache.
- It may simply not be used. The planner ignores an index when the filter matches a large
share of the table, because reading the whole thing in order is genuinely faster than jumping
around. With only five distinct
equipment_idvalues across 50,000 rows,WHERE equipment_id = 3still gets aSeq Scan— and that is the right choice.
So index deliberately: for columns you actually filter, join, or sort on in queries that matter,
and check with EXPLAIN that the index is used. Primary keys and UNIQUE constraints already
create indexes for you. Foreign key columns usually deserve one, because they are joined on
constantly.
Migrations: schema changes as files
You now know how to add a column: type ALTER TABLE at the psql prompt. Do that and you have a
database whose shape exists in exactly one place — that database — and nowhere in your repository.
Your teammate's database does not have the column. Neither does production. Nobody can review the
change, and nobody can tell what order things happened in.
A migration is a numbered file containing one schema change, committed to Git and applied in
order. db/migrations/001_create_equipment.sql, 002_create_maintenance_records.sql,
003_add_location.sql. Three rules make them work:
- Ordering is the whole point.
003may depend on002having run. Numbering makes the sequence explicit and repeatable on any machine, from empty. - Never edit a migration that has been applied anywhere. Your database ran the old version;
another already ran the new one; the two now disagree with no way to detect it. Fix a mistake
by adding
004. - Wrap each migration in
BEGINandCOMMIT. PostgreSQL can roll back schema changes, so a migration that fails halfway leaves the schema untouched rather than half-changed.
-- db/migrations/003_add_location.sql
BEGIN;
ALTER TABLE equipment ADD COLUMN location text;
UPDATE equipment SET location = 'Unassigned' WHERE location IS NULL;
ALTER TABLE equipment ALTER COLUMN location SET NOT NULL;
COMMIT;
psql -v ON_ERROR_STOP=1 maintenance -f db/migrations/003_add_location.sql
BEGIN
ALTER TABLE
UPDATE 5
ALTER TABLE
COMMIT
Three statements in one file, because adding a NOT NULL column to a table with existing rows
cannot be done in one step: add it nullable, fill it, then tighten it. All or nothing.
ON_ERROR_STOP=1 matters. Without it, psql keeps executing after an error and returns success,
which in a deploy script means a broken migration reports as fine. With it, the run stops and exits
non-zero. Running 003 twice shows exactly that:
BEGIN
psql:db/migrations/003_add_location.sql:2: ERROR: column "location" of relation "equipment" already exists
Real projects track which migrations have run in a small table so they are never applied twice. Tools like node-pg-migrate do this for you; the rules above are what they are automating.
Walkthrough: force a rollback on purpose
Do this in psql, watching the prompt change.
BEGIN;
UPDATE equipment SET status = 'retired' WHERE id = 1;
SELECT id, name, status FROM equipment WHERE id = 1;
The SELECT shows retired — inside your transaction, the change is real. Open a second
terminal, connect with psql maintenance, and run the same SELECT there: it still shows
operational. Uncommitted work is invisible to everyone else. Back in the first terminal:
ROLLBACK;
SELECT id, name, status FROM equipment WHERE id = 1;
operational again. Nothing you did survived, and no other session ever saw it.
Checkpoint
Say what each of these proved: the second terminal showing operational; the prompt reading
maintenance=*#; and count = 0 after the failed transaction earlier. (Uncommitted changes are
private; a transaction is open; a failed transaction discards work that already reported
success.)
Your turn
Produce a migration file and a transaction demo that includes a forced rollback.
- Create
db/migrations/and move your Day 44 schema into001_create_equipment.sqland002_create_maintenance_records.sql, each wrapped inBEGIN/COMMIT. - Write
003_add_location.sqladding alocationcolumn, in the three steps above. Apply it withpsql -v ON_ERROR_STOP=1 maintenance -f ...and confirm with\d equipment. - Run
003a second time. Copy the error and the exit code (echo $?) intodb/notes.md, and write one sentence on why re-running is not safe. - Write
db/transaction-demo.sql: aBEGIN, anINSERTintomaintenance_records, anUPDATEof that equipment's status, and aCOMMIT. Verify both changes landed. - Copy it to a second block, break the
UPDATEwith an invalid status, and run it. Record that psql printedROLLBACKat yourCOMMIT, and prove with aSELECT count(*)that theINSERTdid not survive. - Load 50,000 rows for the index test:
INSERT INTO maintenance_records (equipment_id, performed_on, hours_spent, notes) SELECT 1 + (g % 5), DATE '2020-01-01' + (g % 2000), 1.00, 'bulk ' || g FROM generate_series(1, 50000) AS g; - Run
EXPLAIN ANALYZEon a query filteringperformed_onfor one date. Record the scan type and execution time. Add the index in a004_migration, runANALYZE, and record both again. - Write one sentence justifying the index: which real query it serves, and what it costs on write.
Reviewer mode — after step 8
"Review whether my transaction boundary matches one business operation and whether the index
supports a real query." A useful review gives specific, actionable findings with evidence — "the
transaction also wraps an unrelated SELECT" — not general praise. Check each claim against
your own EXPLAIN output before changing anything.
You are done when
Your migrations rebuild the schema from an empty database in order, and db/notes.md shows the
forced rollback with count = 0 as evidence.
Common pitfalls
- Leaving a transaction open. A forgotten
BEGINholds locks and blocks other sessions until youCOMMITorROLLBACK. If psql shows=*#, you are still inside one. - Expecting statements after an error to run. Once aborted, everything is refused until the block ends. That is a feature, not a fault.
- Indexing every column "to be safe". Each one taxes every write and may never be used. Add
one, prove it with
EXPLAIN, keep it. - Editing an applied migration. Databases that ran the old version silently diverge. Add a new file instead.
Verify it yourself
Open today's reference, the PostgreSQL tutorial, and read its chapter on transactions.
- The tutorial introduces
SAVEPOINT, which this lesson skipped. Write down what it lets you do inside a transaction that plainROLLBACKcannot. - This lesson claimed uncommitted changes are invisible to other sessions. Find the wording the documentation uses for that guarantee and record it.
Tomorrow you connect all of this to your Express API.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Create a transaction that inserts maintenance and updates equipment status together. Add one justified index.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
A migration and transaction demo including a forced rollback.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review whether my transaction boundary matches one business operation and whether the index supports a real query.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.