Course 3 · Foundations

Inside a Database II — Queries & Transactions

Last chapter we saw how a database lays bytes on disk and finds them fast with B-tree indexes. Now we follow what happens after you press Enter: how the engine turns your SQL into a plan, why the same query can suddenly run 100× slower, and how a database keeps your money safe when two things must happen together or not at all. These are the ideas behind "why is this slow" and "can we trust this number."

A query's life: from text to result

When you send SELECT … FROM … WHERE … to Postgres or DuckDB, it doesn't just "run." Your query passes through a small assembly line of stages, each handing its work to the next:

SELECT name FROM users WHERE age > 30; │ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ PARSE │──▶│ PLAN │──▶│ OPTIMIZE │──▶│ EXECUTE │──▶│ RETURN │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ is this what are which plan actually ship rows valid SQL? the possible is cheapest? run the back to build a ways to do chosen the client syntax tree it? plan

Read that left to right. Parse checks your SQL is well-formed and turns the text into a tree the engine can reason about. Plan enumerates the ways the result could be produced — scan the whole table, or use an index? join table A to B, or B to A? Optimize estimates the cost of each candidate and picks the cheapest. Execute runs that winning plan, pulling bytes from disk (or cache) and applying the algorithms from chapter 02. Return streams the rows back to you.

The key insight: SQL is declarative

You never told the database how to get the rows — only what rows you wanted. You said "users older than 30," not "open the table, loop row by row, compare the age column." That gap is the whole point. SQL is a declarative language: you declare the goal, and the engine's planner decides the procedure. This is why the same query can speed up after you add an index without you changing a single character — the what stayed the same, but the engine found a faster how.

The query planner: a cost estimator that guesses

The planner (also called the optimizer) is the brain of the database. Its job is to look at all the plausible plans and pick the one it believes is cheapest. But "cheapest" is a guess — the planner hasn't run anything yet. So how does it guess?

It keeps statistics about your data: roughly how many rows are in each table, how many distinct values a column has, how those values are spread out, what fraction is the most common value. From those numbers it estimates things like "WHERE age > 30 will match about 12% of rows." That estimate drives the decision:

  • If a filter matches most of the table, reading the whole table top to bottom (a sequential scan) is actually cheapest — an index would just add overhead.
  • If a filter matches a tiny slice, jumping straight to those rows through an index (an index scan) wins big.

This is why the same query can get a different plan as your data grows. A table with 100 rows is faster to scan whole; the planner ignores the index. The same table at 10 million rows tips the math, and the planner switches to the index — automatically. You didn't change the query; the data changed, so the best how changed.

⚠ The "100× slower overnight" mystery

Remember the riddle from chapter 00: a query that was fast yesterday is crawling today, with no code change. The usual culprit is stale statistics. A nightly load doubled the table, but the planner is still working off old row counts. It estimates "this filter matches 50 rows" when really it now matches 5 million — so it picks a plan tuned for a tiny result and gets crushed. The fix is to refresh the stats: in Postgres, ANALYZE your_table; (autovacuum normally does this, but big bulk loads can outrun it). A planner is only as good as the numbers it's fed.

Reading EXPLAIN: seeing the plan

You don't have to guess what the planner decided — you can ask it. Put EXPLAIN in front of any query and the database prints the plan it would run, without running it. Add ANALYZE and it actually runs the query and reports real timings and row counts beside its estimates. Let's do it. (Postgres syntax shown; DuckDB is nearly identical — use EXPLAIN there too.)

psql
-- a throwaway table with a million rows
CREATE TABLE events AS
  SELECT g AS id,
         (random() * 1000)::int AS user_id,
         (random() * 100)::int  AS amount
  FROM generate_series(1, 1000000) AS g;

-- no index yet: how does the engine find one user's events?
EXPLAIN ANALYZE
SELECT * FROM events WHERE user_id = 42;

A plan is a tree, read from the most-indented node outward (leaves run first, feeding their parents). For the query above, with no index, you get a single leaf:

Gather ← collect results from workers └─ Seq Scan on events ← read ALL 1,000,000 rows, keep matches Filter: (user_id = 42) ← the condition applied to every row Rows Removed by Filter: ~999,000 ← the giveaway: huge waste
✓ You should see

A Seq Scan on events with a Filter line, and (because of ANALYZE) an actual time=… and a large Rows Removed by Filter count — the engine touched a million rows to return a handful. The total Execution Time is in the tens of milliseconds. That "removed almost everything" pattern is your signal that an index could help.

Now add an index and re-run the exact same query:

psql
CREATE INDEX idx_events_user ON events (user_id);
ANALYZE events;            -- refresh stats so the planner knows about the index

EXPLAIN ANALYZE
SELECT * FROM events WHERE user_id = 42;
✓ You should see

The leaf node flips from Seq Scan to Index Scan using idx_events_user (or a Bitmap Index Scan). There's no more "Rows Removed by Filter" — the engine jumped straight to the matching rows via the B-tree. Execution Time drops by one or two orders of magnitude. Same SQL, smarter how.

When a query joins two tables, the plan grows a join node with two children — each child a scan, the parent combining them:

Hash Join ← combine the two inputs on the join key ├─ Seq Scan on orders ← left input └─ Hash ← build a hash table from the right input └─ Seq Scan on users ← right input Reading a plan, ask three things: 1. Are big tables hit by Seq Scan when a filter is selective? (missing index?) 2. Which node has the largest cost / actual time? (that's your bottleneck) 3. Is the row ESTIMATE near the ACTUAL? (far off ⇒ stale stats)

That last check is gold. EXPLAIN ANALYZE prints both the planner's estimate and the real count for every node. When they diverge wildly — "estimated 50 rows, got 4 million" — you've found why the plan is bad: the planner was guessing from stale or skewed statistics, exactly the stale-stats trap from the section above.

Transactions: all-or-nothing units of work

So far we've read data. Changing data raises a harder question: what if a change is really several changes that must succeed together? The classic example is a bank transfer. Moving $100 from Alice to Bob is two operations:

psql
BEGIN;                                              -- start a transaction
  UPDATE accounts SET balance = balance - 100 WHERE name = 'alice';
  UPDATE accounts SET balance = balance + 100 WHERE name = 'bob';
COMMIT;                                              -- make both permanent

Imagine the server crashes between those two updates. Alice has been debited; Bob was never credited. $100 has vanished. That must never be allowed to happen. A transaction is the database's promise that the operations inside it are a single all-or-nothing unit: either every statement takes effect, or none does. The three commands that bound it:

  • BEGIN — open a transaction; nothing inside is visible to others yet.
  • COMMIT — finalize; all changes become permanent and visible at once.
  • ROLLBACK — abandon everything since BEGIN, as if it never happened.
ROLLBACK is your safety net

If the second update fails — Bob's account doesn't exist, a constraint is violated — you issue ROLLBACK (or the database does it for you on a crash) and Alice's debit is undone too. After a crash mid-transaction, the database comes back up with the transfer as if it had never started. No money lost, no partial state. That guarantee is the difference between a database and a pile of files.

ACID, plainly

The transaction guarantee has four parts, remembered by the acronym ACID. Each one maps to a worry a data engineer who handles money, ledgers, or billing actually loses sleep over:

LetterMeans (one line)Why a data engineer cares
AtomicityAll statements in a transaction happen, or none do.A billing run that debits but fails to credit can never leave half-applied money in the ledger.
ConsistencyA transaction moves the DB from one valid state to another; rules (constraints) always hold.Balances never go negative, foreign keys never dangle — invariants you can build correctness on.
IsolationConcurrent transactions don't step on each other; each sees a coherent view.Two invoices settling at once won't read each other's half-finished totals and double-charge.
DurabilityOnce committed, the change survives crashes and power loss.A confirmed payment stays confirmed even if the server dies one second later.
Why this is the heart of "can we trust this number"

Every correctness story in a ledger or billing system reduces to ACID. When finance asks "does this total reconcile," what they're really relying on is atomicity (no half-transfers), consistency (no broken invariants), isolation (no cross-contamination between concurrent runs), and durability (committed means committed). Lose any one and the number becomes a maybe.

Isolation levels: how much do transactions hide from each other?

Isolation is the trickiest letter, because perfect isolation is expensive. If every transaction had to run completely alone, your database could only do one thing at a time — useless under load. So databases offer a dial: isolation levels, trading concurrency for protection. The higher the level, the more anomalies it prevents, but the less work can happen in parallel.

First, the three classic anomalies — bad things that can happen when transactions overlap:

  • Dirty read — you read a row another transaction changed but hasn't committed; it might still ROLLBACK, so you read a value that never officially existed.
  • Non-repeatable read — you read a row twice in one transaction and get different values because someone else committed a change in between.
  • Phantom read — you run the same WHERE twice and a new row appears (or vanishes) because someone else inserted/deleted matching rows.
Isolation levelDirty readNon-repeatable readPhantom read
Read Uncommittedpossiblepossiblepossible
Read Committedpreventedpossiblepossible
Repeatable Readpreventedpreventedpossible*
Serializablepreventedpreventedprevented

* The SQL standard allows phantoms at Repeatable Read; Postgres's snapshot-based Repeatable Read actually prevents them too, and reserves true serial-equivalent behavior for Serializable.

The tradeoff, and a sane default

Read down the table: each step up prevents one more anomaly, but each step also forces the engine to hold more locks or do more conflict-checking, so fewer transactions run concurrently. Serializable behaves as if every transaction ran one at a time — the strongest guarantee, the most contention. Most databases default to Read Committed (Postgres does), which is the everyday sweet spot. Reach for Serializable when correctness truly demands it — like financial settlement where a phantom row would corrupt a total.

A taste of how: MVCC

You might assume isolation means locking every row a reader touches so writers can't change it — but that would stall the whole system. Instead, Postgres uses MVCC (multi-version concurrency control): every update writes a new version of the row rather than overwriting the old one, and each transaction sees the snapshot of versions that existed when it began. The payoff is the famous rule — readers never block writers and writers never block readers — because they're simply looking at different versions of the same row.

✓ Check yourself

  • Can you explain why SQL being declarative is what lets a query speed up after you add an index, with no change to the SQL?
  • What does the planner use to decide between a sequential scan and an index scan — and what makes a query go "100× slower overnight"?
  • In an EXPLAIN ANALYZE plan, which two numbers tell you the stats are stale?
  • Why must a bank transfer be wrapped in a single transaction? Which ACID letter is that?
  • Which isolation level prevents a dirty read, and what is a dirty read?
Exercise — Watch the plan change, and reason about isolation

Part 1. Using the events table from the EXPLAIN section, run the selective query with EXPLAIN ANALYZE before and after creating the index. Describe in one sentence how the plan's leaf node changed and what happened to the execution time.

solution
-- BEFORE the index:
EXPLAIN ANALYZE SELECT * FROM events WHERE user_id = 42;
--   -> Seq Scan on events, Filter: (user_id = 42),
--      ~1,000,000 rows read, ~999,000 Rows Removed by Filter,
--      Execution Time on the order of tens of ms.

CREATE INDEX idx_events_user ON events (user_id);
ANALYZE events;

-- AFTER the index:
EXPLAIN ANALYZE SELECT * FROM events WHERE user_id = 42;
--   -> Index Scan using idx_events_user on events,
--      no "Rows Removed by Filter", the B-tree jumps straight
--      to matches, Execution Time drops 10-100x.

The change: the leaf node went from Seq Scan (read every row, throw most away) to Index Scan (jump to the matching rows), and execution time fell by one or two orders of magnitude — the same query, a faster plan, because the planner now had a cheaper how available.

Part 2. Which isolation level prevents a dirty read, and why?

A dirty read is reading a row another transaction has modified but not yet committed — if that transaction later rolls back, you've acted on a value that never officially existed. The lowest level that prevents it is Read Committed: it guarantees you only ever see data that has been committed, so uncommitted, possibly-doomed changes are invisible to you. (Repeatable Read and Serializable prevent it too, since they're stricter.) Read Uncommitted is the only level that allows dirty reads — and Postgres doesn't even implement it as truly "uncommitted," so in practice you're protected by default.

Next

You now know how a database plans, executes, and protects your queries — time to get fluent in the language itself, from joins to window functions. → SQL in Depth