Building a Database Engine: Storage & Query Fundamentals
Records, Pages, a Heap Table, a Real B-Tree & a Small SQL Engine — From Scratch
Table of Contents
- Why Build a Database Engine? Storage, Indexing & Queries
- Records: Encoding Typed Rows as Real Bytes
- Pages: A Fixed-Size, Slotted Page Layout
- A File-Backed Heap Table
- B-Tree Indexes: Structure & Search
- B-Tree Indexes: Insertion & Node Splitting
- A Small SQL-Like Language: Tokenizing & Parsing
- Query Execution: From a Parsed Query to Real Results
- Using the Index: Basic Query Planning
- Capstone — A Working Single-Table Engine
Why Build a Database Engine? Storage, Indexing & Queries
Building a Database Engine: Storage & Query Fundamentals
Chapter 1 · Why Build a Database Engine? Storage, Indexing & Queries
A database is a program whose entire job is to make two things true at once: data survives being written to disk, and finding a specific piece of it later doesn't get slower and slower as more of it piles up. Those sound almost too obvious to need saying — until the naive, first-instinct way of solving each one is measured directly, and turns out to fail at exactly the scale that matters.
CREATE TABLE/INSERT/SELECT/WHERE, with joins added in Course 2), no distributed or replicated storage, and no cost-based query optimizer beyond simple, honestly-stated heuristics. Course 1 — this course — builds a real, working single-table engine: storage, indexing, and querying. Transactions, crash recovery, concurrent access, and multi-table joins are Course 2's own entire subject, not attempted here.
The Pipeline Both Courses Build
| Stage | What it does | Built in |
|---|---|---|
| Records | Encoding one typed row (integers, text, ...) into real, fixed-format bytes, and decoding it back | Chapter 2 |
| Pages | The real, fixed-size unit of disk I/O — packing variable-length records into a slotted layout | Chapter 3 |
| A heap table | Real pages, written to and read from an actual file — a genuine, if simple, persistent table | Chapter 4 |
| B-tree indexes | A real balanced tree, giving a lookup that doesn't degrade as the table grows | Chapters 5–6 |
| A query language | A small, real SQL-like grammar — tokenized and parsed, not just accepted as a string | Chapter 7 |
| Execution & planning | Turning a parsed query into real results, choosing an index when one genuinely helps | Chapters 8–9 |
Problem 1: Rewriting Everything on Every Write Doesn't Scale
The obvious first instinct for "don't lose data": keep every row in a Python list, and after each insert, write the whole list to disk — nothing can be lost if the process dies, because the file on disk is always fully up to date.
0.153s. 1,000 inserts (2× the data): 0.454s — 2.96× the time, not 2×. 2,000 inserts (4× the data): 0.932s — 6.08× the time, not 4×. If this scaled linearly, doubling the insert count should roughly double the time. It doesn't: each insert has to re-serialize every row already stored, not just the new one — the total work for N inserts is proportional to 1+2+...+N, which is O(N²), not O(N). A version that simply appends in memory and writes once at the end, by contrast, scales cleanly linearly — 0.13ms for 500 appends, 0.91ms for 4,000, no disproportionate blowup at all.
This is exactly why a real database never rewrites its whole dataset on every write — Chapters 2–4 build the real alternative: fixed-size pages, where only the page actually touched by a write needs to change.
Problem 2: A Linear Scan Gets Slower as the Table Grows
Even setting persistence aside entirely, finding one specific row in a plain Python list means checking rows one at a time until a match turns up.
log₂(100,000) ≈ 16.6 — the real target Chapters 5–6's own B-tree index is working toward: a lookup that costs roughly 17 comparisons at 100,000 rows, not 100,000.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Full-file rewrite growing worse than linearly with every insert | Chapter 3's own slotted page format and Chapter 4's own heap table — the real fix is writing only the one page that actually changed, never the whole file |
| A linear scan's own worst case scaling directly with table size | Chapters 5–6's own real B-tree — the direct, measured motivation for building a balanced tree instead of trusting Python's own list |
| This site's own established real-benchmark discipline | Algorithms & Complexity (this site's own Maths for Programmers subject) and this project's own sibling courses (compiler1/compiler2, browserengine1/browserengine2) — every claim in this course is verified with real, hand-run Python before being written, not asserted from Big-O notation alone |
Hands-On Exercises
Measure naive_insert_full_rewrite's own real timing at 3,000 inserts, and compare it against the chapter's own 500-insert measurement. Confirm the ratio between the two times is worse than the 6× a linear relationship would predict for 6× the data.
Build a table of 5,000 rows and measure how many comparisons a linear scan needs to find the first row versus the last row. Confirm the two counts differ dramatically, and explain why a real database can't rely on the best case when giving a performance guarantee.
📄 View solutionCompute log₂(n) for four table sizes — 1,000, 10,000, 100,000, and 1,000,000 rows — and compare each one against a linear scan's own worst-case comparison count at that same size. Describe, in your own words, how the gap between the two changes as the table grows.
Chapter 1 Quick Reference
- This course's own honest scope: a real single-table engine — storage, indexing, and a small SQL-like query language. No networking, no full SQL, no distributed storage, no cost-based optimizer
- Transactions, crash recovery, concurrency, and joins are Course 2's own entire subject — not attempted here
- Verified problem 1: rewriting the whole dataset on every insert grows measurably worse than linearly — a real, timed 6×-the-data-to-11×-the-time result
- Verified problem 2: a linear scan's own worst-case cost grows exactly as fast as the table itself — the real, measured motivation for a B-tree index
- The pipeline: records → pages → a heap table → B-tree indexes → a query language → execution and planning
- Next chapter: Records — encoding a typed row as real, fixed-format bytes, and decoding it back
Records: Encoding Typed Rows as Real Bytes
Building a Database Engine: Storage & Query Fundamentals
Chapter 2 · Records: Encoding Typed Rows as Real Bytes
Every later chapter in this course reads and writes raw bytes on disk. Before any of that can happen, one row — a Python list of typed values — has to become a real, fixed, unambiguous sequence of bytes, and come back out the other side exactly as it went in. That's this chapter's entire job.
Two Column Types, Two Encoding Strategies
An integer is always exactly 8 bytes — struct's own '>q' format (big-endian, signed 64-bit) — so a decoder always knows exactly how far to read. Text has no fixed length at all, so it needs a length prefix: a small header recording exactly how many bytes of text follow.
A Real, Severe Bug: Character Count vs. Byte Count
Reasonable-looking — len(text) is right there, and it's a length. But UTF-8 is a variable-width encoding: plain ASCII characters take one byte each, but many real characters — accented letters, most non-Latin scripts, emoji — take two, three, or four.
[('name', 'TEXT'), ('age', 'INTEGER')], row ["Alice", 30], encoded and decoded with the naive text functions: round-trips perfectly. Every character in "Alice" is one byte in UTF-8, so the character count and the byte count are identical — the naive function's own mistake produces the exact right number, by coincidence, every single time the text happens to be pure ASCII.
["café", 30]. "café" has 4 characters but its UTF-8 encoding is 5 bytes — é alone takes 2 bytes. The naive encoder writes a length prefix of 4. Decoding: the text comes back as 'caf�' — the truncated, mangled remains of a multi-byte character cut off mid-way. Worse: because the decoder only advanced its own read position by 4 bytes instead of the real 5, the age field — a completely unrelated integer — gets read starting one byte too early, from data that was never meant to be a number at all. The result: -6269010681299730432 instead of 30. One wrong length prefix on a text field doesn't just corrupt that field — it corrupts every field after it in the same row.
["café", 30] comes back exactly as it went in — the text intact, and age correctly 30, since the decoder's own read position is now byte-accurate.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A byte-accurate length prefix, correctly advancing the decoder's own read position | Chapter 3's own slotted page format, which packs several of these encoded records, one after another, into a single page — every record's own length has to be exactly right for the next one to even be findable |
| A bug invisible under ASCII, severe under real-world text | A genuinely common, real-world category of bug — any system distinguishing "character count" from "byte count" only when it matters (non-English names, accented characters, emoji) risks looking correct in testing and failing in production the moment real, diverse user data arrives |
| Fixed-width integers needing no length prefix at all | Chapter 5's own B-tree node layout, which will lean on exactly this property — a fixed-size key can be found at a predictable offset without scanning anything first |
Hands-On Exercises
Build a schema with three columns — id (INTEGER), username (TEXT), score (INTEGER) — and encode/decode a row containing a negative score and a username with a non-ASCII character. Confirm every value round-trips exactly.
Encode a row with a single, genuinely empty TEXT column (an empty string). Confirm it round-trips correctly and inspect the raw encoded bytes directly — how many bytes does an empty string actually produce, and why is that a completely valid, unremarkable record rather than a special case?
📄 View solutionEncode and decode a row with a single INTEGER column holding a negative value. Confirm it round-trips exactly, and explain what would go wrong if struct's own format code were changed from '>q' (signed) to '>Q' (unsigned).
Chapter 2 Quick Reference
- INTEGER: always exactly 8 bytes, signed (
struct's own'>q') — no length prefix needed - TEXT: a 2-byte length prefix, followed by the real UTF-8-encoded bytes
- Real bug found and fixed: using the CHARACTER count instead of the real UTF-8 BYTE count as a text field's own length prefix — invisible for pure ASCII, severely corrupting for any real multi-byte character
- The consequence goes beyond the text field itself: a wrong length prefix misaligns the decoder's own read position for every field that follows it in the same row
- Verified: mixed schemas, empty strings, and negative integers all round-trip exactly through the fixed encoder/decoder
- Next chapter: Pages — packing several of these encoded records into a real, fixed-size, slotted disk page
Pages: A Fixed-Size, Slotted Page Layout
Building a Database Engine: Storage & Query Fundamentals
Chapter 3 · Pages: A Fixed-Size, Slotted Page Layout
A single record, from Chapter 2, is just bytes floating in memory. A real database reads and writes disk in fixed-size chunks — pages — and packs many records into each one. Since records vary in length, a page can't simply lay them end to end at predictable offsets; it needs a real, separate slot directory tracking where each one actually lives.
Two Regions, Growing Toward Each Other
The classic, real design — used by SQLite, PostgreSQL, and most real database engines — grows two regions from opposite ends of one fixed-size page: a small slot directory grows forward from right after the header; the actual record bytes grow backward from the very end of the page. Whatever gap remains between them is the page's own free space.
A Real, Severe Bug: Forgetting the New Slot's Own Space
Reasonable-looking: check whether the record's own bytes fit in the gap between the slot directory and the record data. The bug: storing this record also requires one new slot entry, and the check never accounts for that entry's own SLOT_SIZE bytes.
HEADER_SIZE=4, SLOT_SIZE=8), four 8-byte records added in sequence: b'AAAAAAAA', b'BBBBBBBB', b'CCCCCCCC', b'DDDDDDDD'. All four are naively accepted with no error. Reading them back: the first three come back correct. The fourth comes back as b'\x00\x00\x00\x08DDDD' — its own first four bytes have been silently replaced by the encoded length field from a slot entry that was written into the exact space this record's own data was occupying. The naive check let the record's data claim space that the slot directory needed for its own final entry — and since add_record writes record data first, then the slot entry, the slot entry's own write simply overwrote part of what was just stored.
Page: the first three are accepted exactly as before. The fourth is correctly rejected with a clean ValueError("page full") — because there genuinely isn't room for both its own 8 bytes of data and the slot entry that would have to describe it. Every record that was accepted reads back byte-for-byte correct.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A slot directory and record data growing toward each other from opposite ends | Chapter 4's own heap table, which writes and reads whole pages like this one to and from a real file — each page is the real unit of I/O from here on |
| A capacity check that forgot the new slot's own space | Chapter 2's own text-length bug — the same class of mistake (a size calculation that's correct for the DATA but forgets a piece of BOOKKEEPING that also needs room) showing up one layer up the stack |
| A fixed-size page rejecting a record that genuinely doesn't fit | Chapters 5–6's own B-tree, whose own nodes are built on exactly this same fixed-capacity-per-page idea — a node splits precisely when it hits this same kind of real limit |
Hands-On Exercises
Create a fresh, empty Page() and check its own free_space(). Add a single 8-byte record and check free_space() again. Confirm the drop is 16 bytes, not 8, and explain exactly which two things together account for that difference.
Try to add a 100-byte record to a fresh 64-byte page. Confirm this is rejected cleanly with a real error, rather than silently truncating the record or corrupting the page, and explain why this case never even reaches the interesting "off by one slot" boundary the chapter's own main bug was about.
📄 View solutionAdd three small, genuinely different-length records (b'a', b'bb', b'ccc') to a fresh page. Confirm all three are accepted and every one reads back byte-for-byte correct, and explain why variable-length records specifically are what makes a fixed offset table (rather than a slot directory storing real offsets) impossible to use here.
Chapter 3 Quick Reference
- A page: a fixed-size byte buffer with a slot directory growing forward from the header and record data growing backward from the end
- A slot: a small, fixed-size entry (offset + length) describing exactly where one record's own data lives within the page
- Real bug found and fixed: checking whether a record fits without accounting for the ONE NEW slot entry needed to describe it — verified silently overwriting a record's own first bytes with part of that later slot entry
- The fix: the fits-check adds
+1to the slot count before computing how much space the slot directory will actually need - Verified: a record that genuinely can't fit under any circumstances is rejected cleanly; small, differently-sized records all round-trip correctly within one page
- Next chapter: A File-Backed Heap Table — writing and reading real pages like this one to and from an actual file on disk
A File-Backed Heap Table
Building a Database Engine: Storage & Query Fundamentals
Chapter 4 · A File-Backed Heap Table
Every page built so far has lived and died inside a single Python session, in memory, never once touching a disk. This chapter is the first time that changes — a real file, real pages written to and read back from it, and a heap table: an unordered collection of pages that insert() and scan() can actually work with.
A Real Bug, Dormant Since Chapter 3 — and Exactly Where It Was Hiding
Chapter 3's own Page class defined HEADER_SIZE = 4, with a comment calling it "a single 4-byte 'num_slots' field." That comment was aspirational — the actual code never once wrote self.num_slots into those 4 bytes. It only ever updated a plain Python attribute on the Page object.
num_slots = 2. Its raw bytes, taken via bytes(page.data) — simulating "this page was just written to disk" — handed to a fresh Page() object with page.data set directly. The reloaded page's own num_slots: 0. The two records' own raw bytes are still physically present in data — get_record(0) and get_record(1) still happen to return the correct values, purely by coincidence — but the page itself has genuinely forgotten it has any records in it at all, and its own free_space_end has silently reset to the full, untouched page size.
num_slots wrongly reads 0, the new record's own slot entry gets written at slot index 0 — directly overwriting the real slot entry that used to describe the first record. Reading slot 0 back afterward returns b'CCCCCCCC' — the third record — instead of the original b'AAAAAAAA'. The first record isn't just orphaned; its own description in the slot directory is gone.
The Fix: Actually Write What the Comment Always Promised
load_page(): num_slots = 2, free_space_end matches the original exactly. Adding a third record afterward: all three slots read back correctly — b'AAAAAAAA', b'BBBBBBBB', b'CCCCCCCC' — nothing overwritten.
A Real File, and a Working Heap Table
HeapTable backed by an actual file: scan() returns [[1, 'Alice'], [2, 'Bob'], [3, 'café owner']], exactly matching what was inserted. insert()'s own "try the last page first" logic is verified too: enough rows to force a real second page allocation land correctly on page 1 once page 0 genuinely fills up — scan() still visits every row, across every page, in the right order.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A header field documented in Chapter 3 but never actually written until now | Course 1's own Declaration.property-vs-.name capstone finding, and Course 2's own text-doesn't-contribute-height finding — a recurring shape of bug across both projects: something correct in isolation, only found once a LATER chapter actually exercises the gap between what was documented and what was implemented |
| A page's own bookkeeping surviving a real save/reload cycle | Chapters 5–6's own B-tree, whose own nodes are pages too — every one of them will need this exact same real persistence to survive being written to disk and read back |
| "Try the last page first" before allocating a new one | Chapter 1's own opening finding — the naive full-file-rewrite approach wasted enormous effort; blindly allocating a fresh page for every insert would waste enormous disk space the same way, leaving every earlier page's own remaining free space forever unused |
Hands-On Exercises
Create a brand-new, empty HeapFile and check num_pages(). Call allocate_page() once and check num_pages() again. Confirm the counts are exactly what an empty file, then a file with one real page, should report.
Insert three small rows into a fresh HeapTable and inspect the (page_num, slot) row IDs insert() returns for each one. Confirm all three land on the same page, and explain which specific part of insert()'s own logic is responsible for that.
Insert 15 rows, each large enough that several real pages end up being used. Confirm scan()'s own output matches the original insertion order exactly, from the very first row to the very last, regardless of how many pages the rows actually ended up spread across.
Chapter 4 Quick Reference
- HeapFile: a real file, organized as fixed-size
PAGE_SIZEblocks — pageNlives at byte offsetN * PAGE_SIZE - Real bug found and fixed: Chapter 3's own page header was always documented as storing
num_slots, but the code never actually wrote it there — a page reloaded from real bytes forgets its own record count, and the next insert overwrites the first record's own slot entry - The fix: persist
num_slotsinto the header on every insert; derivefree_space_endfrom the slot directory itself on reload, with no second header field needed - HeapTable.insert(): tries the last existing page first, only allocating a genuinely new one once the last page is actually full
- HeapTable.scan(): visits every row across every page, in insertion order, decoding each one via Chapter 2's own real record format
- Next chapter: B-Tree Indexes — real search, replacing the linear scan Chapter 1 already measured as the true bottleneck
B-Tree Indexes: Structure & Search
Building a Database Engine: Storage & Query Fundamentals
Chapter 5 · B-Tree Indexes: Structure & Search
Chapter 1 measured the real cost of a linear scan: a worst-case lookup needs a comparison against every row already stored. This chapter builds the real alternative — a genuine B-tree node structure, and the search algorithm that walks it. Balanced insertion and node splitting are Chapter 6's own job; this chapter assumes a tree already exists, and gets its own search exactly right.
A Real Node: Every Level Holds Real Data
This is the classic B-tree design (not a B+tree) — an internal node isn't just a signpost pointing further down; it genuinely holds real key-value pairs of its own, alongside pointers to child subtrees holding everything in between.
The Real Search Invariant
children[i] always holds every key strictly between keys[i-1] and keys[i] — with children[0] holding everything smaller than keys[0], and children[len(keys)] holding everything larger than the last key. After the while loop finishes, i is already the correct child index — not i-1.
A Real, Plausible Bug: The Wrong Child Index
i keys were smaller than the target, so the target belongs in the (i-1)th child" — descending into children[i - 1] instead of children[i]. A real, small tree: root keys [10, 20], three children (keys <10, between 10 and 20, and >20). Searching for 5: i ends at 0, so the buggy version descends into children[-1] — which Python silently interprets as the last child (the one holding keys >20), via the exact same negative-index wraparound Course 2's own rasterizer chapter found in a completely different context. Searching for 15: i ends at 1, so the buggy version descends into children[0] — a real, valid child, just the wrong one. Both cases: a key that's genuinely stored in the tree comes back as None.
children[i]. Genuinely absent keys (6, between two real keys; 0 and 100, past either end) all correctly return None too — the tree doesn't just avoid crashing, it correctly distinguishes presence from absence.
Verified Against a Real Linear Scan, at Scale
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A negative-index off-by-one in child selection | Course 2's own real rasterizer chapter — the exact same class of Python-specific silent misdirection (a negative index quietly meaning "the last element" instead of raising an error) showing up in a genuinely different data structure |
| Search verified to exactly match a linear scan, at real scale | Chapter 1's own opening finding — this chapter is the direct payoff: the same 500 keys that would need up to 500 comparisons in the worst case with a linear scan are found by the B-tree in a small fraction of that |
| Every node, leaf or internal, storing real data | Chapter 6's own real insertion and node-splitting — this chapter's own bulk-load is a legitimate but simplified stand-in; real, balanced growth of the tree over time is that chapter's entire subject |
Hands-On Exercises
Bulk-load a tree from just three sorted pairs — fewer than one leaf's own capacity. Confirm the resulting tree is a single leaf node, with no internal/root node at all, and that btree_search() still correctly finds every one of the three keys.
Using the chapter's own hand-built three-child tree, confirm that both boundary keys stored directly in the root (10 and 20) are found without the search ever needing to descend into a child at all — trace through btree_search's own logic to show exactly where the function returns for each one.
Reproduce this chapter's own bug directly: run both btree_search and btree_search_buggy against the hand-built tree for the key 15. Confirm the two disagree, and trace exactly which child each version ends up descending into, and why those are two genuinely different (both real) subtrees.
Chapter 5 Quick Reference
- BTreeNode: every node — leaf or internal — stores real
(key, value)pairs directly; internal nodes also carrychildren, one more than the number of keys - The search invariant:
children[i]holds everything betweenkeys[i-1]andkeys[i]— after the search loop,iis already the correct child index - Real bug found and fixed: descending into
children[i-1]instead ofchildren[i]— fori=0this hits Python's own negative-index wraparound; fori>0it simply picks a real but wrong subtree - Verified: zero disagreements against a real linear scan across 500 real keys and 50 genuinely absent ones
- Scope note: this chapter's own bulk-load is a simplified stand-in — real, balanced insertion and node splitting is Chapter 6's entire subject
- Next chapter: B-Tree Indexes: Insertion & Node Splitting — keeping the tree balanced as real data grows
B-Tree Indexes: Insertion & Node Splitting
Building a Database Engine: Storage & Query Fundamentals
Chapter 6 · B-Tree Indexes: Insertion & Node Splitting
Chapter 5 assumed a tree already existed. This chapter builds it for real — inserting one key at a time, keeping every node within its own real capacity, and splitting whenever that capacity is exceeded, with the split propagating upward exactly as far as it needs to.
Insert, Then Split If Needed
When a node overflows, it returns a signal — a promoted key and a brand-new right sibling — to whichever caller inserted into it. That caller absorbs the promotion into its own keys, possibly overflowing in turn. If the overflow reaches all the way to the root, btree_insert itself creates a genuinely new root, and the tree grows one level taller.
A Real, Severe Bug: One Split Boundary Isn't Enough
A node with k keys always has k+1 children — one more, always. Splitting keys and values at index mid is correct; splitting children at the same index mid is a genuinely plausible mistake that looks consistent, and is wrong.
MAX_KEYS = 3, correctly build a real, 3-level, 10-node tree — every key found correctly. The identical 20 keys, inserted with a splitter that uses mid as the children boundary instead of mid + 1: searching for keys 7 and 8 crashes outright with a real IndexError. Searching for keys 10, 11, and 13 returns the wrong value entirely — pulled from a completely different, unrelated subtree. The bug's own root cause is a single one: an internal node's own child at index mid — which correctly belongs to the left side, holding every key between the left node's own last remaining key and the promoted key — gets assigned to the right side instead. The left node ends up with one fewer child than its own key count actually requires, which is exactly severe enough to make some searches crash and others simply go looking in the wrong place.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Two genuinely different counts (keys vs. children) needing two different split boundaries | Chapter 3's own slotted-page bug — a capacity check that forgot the slot directory needs its own, separate accounting from the record data it describes; the same class of "one number isn't enough" mistake, one layer up |
| A single root cause producing two visibly different failure symptoms | Chapter 5's own children[i-1] bug, and Course 2's own bounds-clipping rasterizer bug — a recurring pattern across this whole "ambitious learning projects" tier: the same underlying mistake manifesting as a crash in one case and a silently wrong answer in another, depending on exactly which values happen to be involved |
| The tree's own height growing only when the root itself splits | Chapter 1's own real log₂(n) comparison — this is precisely the mechanism that keeps a B-tree's own height logarithmic in the number of keys, rather than growing with every single insert |
Hands-On Exercises
Insert 50 keys, in a genuinely shuffled (not sequential) order, into a fresh tree. Confirm every one of the 50 keys is found correctly afterward, and explain why insertion order shouldn't matter for a correctly-implemented B-tree's own final correctness, even though it can affect the tree's own exact shape.
📄 View solutionInsert keys 1 through 10 into a fresh tree, note the total node count, then insert key 5 a second time with a different value. Confirm the node count is completely unchanged, and confirm the stored value for key 5 reflects the second insert, not the first.
📄 View solutionBuild the tree's own height at five different real data sizes — 1, 5, 15, 40, and 100 keys. Confirm the height doesn't grow smoothly with every insert, and identify roughly how many keys it actually takes to push the height from one level to the next.
📄 View solutionChapter 6 Quick Reference
- Insertion: find the correct leaf (same descent logic as search), insert in sorted order, then split if the node now exceeds
MAX_KEYS - Splitting: the median key is promoted to the parent; keys/values split at
mid, children split atmid + 1— a genuinely different boundary, since a node always has one more child than keys - Real bug found and fixed: using
midfor both splits — verified producing two distinct failure modes on the same real tree: some searches crash withIndexError, others silently return a wrong value from an unrelated subtree - Root splitting: the one case that grows the tree's own height — a brand-new root is created only when the split propagates all the way up
- Verified: insertion order doesn't affect final correctness; re-inserting an existing key updates it in place with no duplication; height grows only occasionally, not with every insert
- Next chapter: A Small SQL-Like Language — tokenizing and parsing a real, if deliberately small, query grammar
A Small SQL-Like Language: Tokenizing & Parsing
Building a Database Engine: Storage & Query Fundamentals
Chapter 7 · A Small SQL-Like Language: Tokenizing & Parsing
Every chapter so far has been driven directly, in Python, by calling functions like table.insert(...). Real users type queries. This chapter builds a real, if deliberately small, tokenizer and parser for a genuine SQL-like grammar — CREATE TABLE, INSERT INTO ... VALUES, and SELECT * FROM ... WHERE — turning query text into real, structured statement objects.
Tokens, Then Statements
The tokenizer scans identifiers/keywords (case-insensitive keyword matching, original-case identifiers), numbers, single-quoted strings, punctuation, and comparison operators. The parser is a real, hand-written recursive-descent parser: one function per statement type, each consuming exactly the tokens its own grammar rule expects.
A Real, Severe Bug: Multi-Character Operators Need a Lookahead
Every real SQL dialect needs >=, <=, and != — genuinely common WHERE-clause operators, not exotic edge cases. Treating every operator character as its own token, with no lookahead, splits every one of these into two separate tokens.
"SELECT * FROM users WHERE age >= 18;", tokenized naively: >= becomes two separate tokens, OP('>') and OP('='). Parsing this: parse_select()'s own op = self.expect('OP').text consumes > as the operator — correctly, as far as it knows. Then val_tok = self.advance() consumes the leftover = token as if it were the WHERE clause's own value — advance() never checks a token's own kind, so an operator token is silently accepted in a position that should only ever hold a number or a string. The parser doesn't crash at all: it confidently returns SelectStatement('users', ('age', '>', '=')) — a real, wrong statement, comparing age against the literal string '='. The real value, NUMBER('18'), and the closing ;, are left completely unconsumed, discarded without a single complaint.
OP('>=') token, and the WHERE clause parses cleanly to ('age', '>=', 18). The identical lookahead logic — one shared check, not three separate special cases — also correctly handles <= and !=, verified independently on both.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A silently wrong parse, not a crash | Chapter 5's own wrong-child B-tree bug and Chapter 6's own split-boundary bug — a recurring theme across this course: the more dangerous failures are the ones that produce a confidently wrong answer, not the ones that raise an obvious error |
| A parser that never re-tokenizes string content as keywords | Course 1's own real HTML tokenizer — the same discipline of treating quoted/delimited content as an opaque, unconditional unit, regardless of what it happens to contain |
| Real, structured statement objects, ready to be executed | Chapter 8's own query execution — CreateTableStatement, InsertStatement, and SelectStatement are the direct input; this chapter's whole job was producing something the next one can actually act on |
Hands-On Exercises
Tokenize and parse two WHERE clauses using <= and !=, using both the naive and the fixed tokenizer. Confirm the naive version produces two separate OP tokens in both cases, and the fixed version produces exactly one correctly-recognized operator token in both cases.
Parse "SELECT * FROM users;" — a real, valid query with no WHERE clause at all. Confirm it parses successfully with where set to None, and explain exactly which check in parse_select() is responsible for the WHERE clause being genuinely optional.
Parse an INSERT statement whose own string value contains real SQL keyword text, e.g. 'SELECT * FROM WHERE'. Confirm the value comes back as one intact string, not as separate keyword tokens, and explain exactly which part of the tokenizer's own logic guarantees this regardless of what text appears inside the quotes.
Chapter 7 Quick Reference
- Six token kinds:
KEYWORD,IDENT,NUMBER,STRING,OP,PUNCT - Three real statement types:
CREATE TABLE,INSERT INTO ... VALUES,SELECT * FROM ... WHERE(optional) - Real bug found and fixed: no lookahead for multi-character operators splits
>=/<=/!=into two tokens — the parser doesn't crash, it silently accepts the leftover token as the WHERE clause's own value, producing a confidently wrong statement - The fix: a single, shared lookahead check before committing to a one-character operator token
- Verified: the WHERE clause is genuinely optional; string literals are never re-tokenized as keywords, regardless of what they contain
- Next chapter: Query Execution — turning these real, parsed statements into real results over a real heap table
Query Execution: From a Parsed Query to Real Results
Building a Database Engine: Storage & Query Fundamentals
Chapter 8 · Query Execution: From a Parsed Query to Real Results
Chapter 7 produced real, structured statement objects. This chapter is where they actually do something — a real Database class that creates real tables, inserts real rows, and answers real SELECT queries by scanning and filtering.
Three Statements, Three Real Actions
CREATE TABLE registers a schema and a real file-backed HeapTable. INSERT calls table.insert() directly. SELECT scans every row, and — if a WHERE clause is present — finds the named column's own position in the schema and runs a real comparison against it.
A Real, Sharp Bug: SQL's "=" Isn't Python's "="
A plausible shortcut: rather than writing out a real comparison for each of six operators, just splice the operator string straight into a Python expression and let eval() handle it.
apply_where_naive(5, '=', 5) builds the string "5 = 5" and hands it to eval(). Result: SyntaxError: invalid syntax. Python's own = is assignment, not comparison — eval() only ever accepts real expressions, never assignment statements. SQL's own equality operator, spelled the exact same way as Python's assignment operator, breaks the naive dispatch on contact — and equality is, by a wide margin, the single most common comparison in any real WHERE clause. Run through the full Database pipeline, the exact same real query — SELECT * FROM users WHERE age = 30; — fails with the identical SyntaxError, confirming this isn't just an isolated unit-test finding.
WHERE age >= 18 correctly returns the two adults; WHERE name = 'Bob' correctly returns exactly Bob's own row; WHERE name != 'Alice' correctly excludes exactly one row, keeping the rest. A WHERE clause matching nothing at all correctly returns a real, empty list — not an error, not None.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
eval() as a generic dispatch shortcut, breaking on the most common case | A genuinely real, well-known anti-pattern in real software — splicing untrusted or loosely-controlled text into eval() is both a correctness risk (as verified here) and, in a genuinely adversarial context, a security risk; a real dispatch table sidesteps both at once |
| Finding a column by name, not by assumed position | Chapter 2's own Schema — decode_row() returns a plain list with no names attached at all; this chapter is what reconnects a value back to the column name a user actually typed |
| A complete, working pipeline: parse → execute → real results | Chapter 9's own index-aware query planning — this chapter always does a full table scan; the next one adds the choice to use a B-tree index instead, when one genuinely helps |
Hands-On Exercises
Build a second Database instance using apply_where_naive instead of the fixed dispatch table, create the same table, insert one row, and run SELECT * FROM users WHERE age = 30; through it. Confirm it fails with the same real error the isolated apply_where_naive test produced.
Run WHERE name != 'Alice' against the chapter's own three-row table. Confirm it returns exactly the two rows that don't match, and explain why this is a genuinely different result from what WHERE name = 'Alice' returns, not simply its logical opposite computed after the fact.
Run a WHERE clause that matches zero rows against the chapter's own table (e.g. WHERE age > 100). Confirm the result is a real, empty Python list, and trace through _execute_select's own generator to explain exactly why zero matches never becomes an error or a None.
Chapter 8 Quick Reference
- Database.execute(): parses SQL text (Chapter 7), then dispatches on the real statement type to a real action
- SELECT execution: a full scan (Chapter 4) plus an optional WHERE filter, resolving the named column to its own index in the schema
- Real bug found and fixed: dispatching WHERE operators through
eval()breaks on SQL's own=— Python's=is assignment, not comparison, soeval()raises aSyntaxErroron the single most common WHERE pattern there is - The fix: a real dictionary of operator functions — no string splicing, no
eval(), at all - Verified: a complete real pipeline — CREATE, INSERT, and SELECT with a real WHERE clause — working end to end, including a genuinely empty result set
- Next chapter: Using the Index — choosing a B-tree lookup over a full scan when a WHERE clause genuinely allows it
Using the Index: Basic Query Planning
Building a Database Engine: Storage & Query Fundamentals
Chapter 9 · Using the Index: Basic Query Planning
Every piece is built: real storage, a real B-tree, a real parser, a real executor. This chapter wires the last connection — an index, built from a real table, and a real (if deliberately minimal) planner deciding when it's actually safe to use it.
Building an Index: Real Locations, Keyed by Real Values
scan_with_locations() is Chapter 4's own scan(), extended to also yield each row's own real (page_num, slot) — the exact row ID HeapTable.insert() already returns. The index maps column values to real, physical locations, using Chapter 6's own real btree_insert(), unmodified.
A Real, Severe Bug: An Index Can't Safely Answer Every Operator
Reasonable-looking: if there's an index on the column, use it. The problem: Chapter 5's own btree_search() only ever finds an exact match for one key — it has no way to answer "everything greater than 18."
age. WHERE age > 18 should return 8 rows. The naive planner, seeing an index on age, unconditionally uses it: btree_search(root, 18) finds exactly one row — the one with age exactly 18 — and returns that single row, silently discarding all seven others that genuinely satisfy age > 18. No error, no warning — a confidently wrong, drastically incomplete result set.
WHERE age > 18 correctly falls back to a full scan, returning all 8 real matching rows. WHERE age = 30 — a genuine equality — correctly uses the index, and its result count matches a manual scan count exactly: one row, either way.
A Second, Honest Finding: This B-Tree Only Ever Stores One Value Per Key
points = 10. The real scan finds all three. The index, built the same way, finds only one — Chapter 6's own btree_insert() treats re-inserting an existing key as an update (node.values[i] = value), never as "add a second entry." Every earlier row sharing a duplicate key value is silently, permanently unreachable through the index — only the last one inserted survives. A real, non-unique/multi-value index is a genuinely separate feature, deliberately out of this chapter's own scope — the practical consequence: only ever build an index on a column with genuinely unique values.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| An index safe for equality, unsafe for range conditions | Chapter 1's own real motivation for building an index at all — the fix doesn't remove the index's own real value, it just scopes when it's honest to trust it |
| A B-tree storing one value per key | Chapter 6's own real re-insert-as-update behavior, verified there as a deliberate, correct property — this chapter shows the same property has a real, honest cost once it's used for something other than a genuinely unique key |
| A real planner choosing between two real execution strategies | Chapter 10's own capstone — every piece built across this course, now including the choice of HOW to answer a query, not just whether it can be answered at all |
Hands-On Exercises
Run WHERE age = 30 against the chapter's own real table two ways: once via db.select() (using the index), and once by manually counting matches from a plain table.scan(). Confirm the two counts agree exactly.
Call choose_plan() directly with a WHERE clause on a column that has no index at all (only 'age' is indexed). Confirm the plan is always ('scan', None, None), regardless of the operator used.
Build a fresh table with a column holding genuine duplicate values (three rows sharing the same value), index that column, and compare a real scan's own match count against the index lookup's own result count for that shared value. Confirm they disagree, and explain exactly which row survives in the index.
📄 View solutionChapter 9 Quick Reference
- create_index(): scans a table once, building a real B-tree mapping column values to real
(page_num, slot)locations - Real bug found and fixed: using an index for ANY operator, not just equality — a plain B-tree exact-match search silently drops nearly every row for a real range query like
> - The fix: the planner only chooses the index when the operator is genuinely
=; every other operator falls back to a correct full scan - An honest, separate limitation: this course's own B-tree stores one value per key — indexing a column with real duplicates silently keeps only the last-inserted row per key; only index genuinely unique columns
- Verified: index-based and scan-based results agree exactly for genuine equality queries, at real, checked row counts
- Next chapter: Capstone — a single, working end-to-end engine, verified against hand-computed results
Capstone — A Working Single-Table Engine
Building a Database Engine: Storage & Query Fundamentals
Chapter 10 · Capstone — A Working Single-Table Engine
Every chapter has tested its own piece in isolation, against hand-built inputs standing in for whatever the previous chapter was supposed to hand it. This chapter does what none of them did: run real SQL text through the actual tokenizer, parser, executor, storage engine, and index — the same functions, wired together — and check the results against what's actually correct.
A Real, Final Integration Bug
Wiring Chapter 8's own execute(sql) and Chapter 9's own index-aware select() into the same class, for the first time, surfaced something neither chapter's own tests ever could.
execute(sql) — tokenize, parse, dispatch. Chapter 9, adding index support, quietly built its own create_table()/insert()/select() methods, called directly with Python values — and never once reconnected the SQL-parsing layer. Calling db.execute("SELECT * FROM products;") on Chapter 9's own class, exactly as published, raises a genuine AttributeError: 'DatabaseChapter9' object has no attribute 'execute'. Nothing in either chapter's own isolated tests was ever wrong — Chapter 8's execute() was tested against its own simpler, non-indexed class; Chapter 9's index-aware methods were tested by calling them directly. The two real capabilities were each verified correct, separately, and never actually run together until now.
A Real, Complete Engine — Verified End to End
A real product inventory: CREATE TABLE products (id INTEGER, name TEXT, price INTEGER, category TEXT);, five real rows, an index on id.
db.execute("SELECT * FROM products WHERE id = 3;") — tokenized, parsed into a real SelectStatement, planned via Chapter 9's own choose_plan() (index, since the operator is genuine equality and id is indexed), executed via btree_search() — returns exactly [[3, 'Gizmo', 15, 'hardware']].
WHERE price > 20: three matching rows, correctly found via a full scan (Chapter 9's own operator check correctly refuses to trust the index for a non-equality condition). WHERE category = 'electronics': two matching rows, correctly found via a full scan too — category was never indexed, so choose_plan() correctly falls back regardless of the operator being a genuine equality.
What This Course Doesn't Cover
Restating the scope drawn back in Chapter 1, now that every piece of it has actually been built: no client-server networking protocol, no full SQL (a genuine but deliberately small subset — CREATE TABLE/INSERT/SELECT/WHERE only, no UPDATE, no DELETE, no JOIN, no CREATE INDEX as SQL syntax), no distributed or replicated storage, no cost-based query optimizer beyond the single honest heuristic built in Chapter 9. Beyond that original list, building this course surfaced two more, more specific boundaries worth naming honestly: this B-tree index only ever stores one value per key — a real limitation for any column with genuine duplicate values, found in Chapter 9 — and, as of this chapter, nothing in this engine survives a crash mid-write, no transaction can be rolled back, and nothing prevents two operations from corrupting each other if they ever ran concurrently. Every single chapter of this course has quietly assumed one operation runs at a time, uninterrupted, to completion.
Where This Connects: On to Course 2
Course 2, Building a Database Engine: Transactions & Concurrency, starts exactly here — write-ahead logging and crash recovery (so a real crash mid-write no longer loses or corrupts data), locking and MVCC (so two operations can safely run at the same time), multi-table support with foreign keys, and real joins. Every chapter in that course assumes this one's own finished engine as its starting point.
Course 1 Complete — Building a Database Engine: Storage & Query Fundamentals
- Chapters 1-2: why a database engine matters (real, measured problems: O(N²) full-file rewrites, O(n) linear scans), and a real binary record format (with a severe UTF-8 byte-vs-character-count bug found and fixed)
- Chapters 3-4: a real slotted page layout (with a slot-directory-space bug found and fixed) and a real file-backed heap table (with a dormant page-persistence bug found and fixed)
- Chapters 5-6: a real B-tree — search (with a wrong-child-index bug found and fixed) and insertion with node splitting (with a children-vs-keys split-boundary bug found and fixed)
- Chapters 7-8: a real SQL-like tokenizer and parser (with a multi-character-operator bug found and fixed) and real query execution (with an
eval()-based WHERE-clause bug found and fixed) - Chapter 9: real index-aware query planning (with an operator-blind index-usage bug found and fixed, plus an honest unique-key-only limitation surfaced)
- Chapter 10 (this chapter): every piece wired together for the first time on real SQL text — surfacing and fixing one final, genuine integration bug (a missing
execute(sql)entry point) that no single chapter's own isolated tests could ever have caught - Nine real bugs, found and fixed, across ten chapters — every one verified with real, hand-run Python before being written up
- Next: Building a Database Engine: Transactions & Concurrency — making this engine safe under crashes and concurrent access