Load a file and prove the load
Staging, validation, orphans, reconciliation and the rebuild
Where you are
Part II, after Exam 1
This chapter goes with Lab 10: From Paper to Database.
By the end you can stage a file as raw text, test each value before you convert it, find the row that has no parent, count source, loaded and excluded rows each on their own, and build the database twice to prove the scripts agree.
Trail Gear rents equipment for the trail and the water, and its first inventory arrives as a CSV file of six lines: a serial, a gear type, a daily rate and a note for each item, TG-101 to TG-106. You can read the whole file in one breath, and it already has problems. TG-103 says its gear type is 99, and there is no type 99. TG-104's rate is -3. TG-105's rate is the word unknown. Two notes look empty, and one of them is three spaces.
Write the load the quick way, with a CAST on every rate, and the word unknown becomes a rate of zero that every rule on the items table accepts. Leave TG-103 in and, with foreign keys on, the whole insert is refused. The question this chapter answers is not whether the load ran. It is whether you can prove what it did: which rows went in, which stayed out and why, and that a teammate who runs your scripts into a new file gets the same database, row for row.
The browser exercises open the finished rehearsal: stg_items holds the six raw rows, items the three that loaded, and load_audit one decision for each source row. The steps only the sqlite3 shell can run, .import and .read among them, use the Trail Gear desktop kit, lab10-build-rehearsal.zip. Before the lab, open a terminal and check that sqlite3 starts, and bring your client's corrected ERD, data dictionary and CSV files with the parent-first build order written down.
Stage, check, load, prove
Real data arrives as files, and a file has no rules. Nothing in a CSV says a rate must be a number or a gear type must exist. The rules live in your CREATE TABLE statements, and the work of a load is getting the file's rows past those rules honestly: the good rows in, the bad rows out, and a sentence for every row you left out.
The pattern this course uses has four steps. Stage: copy the file into a table exactly as it is, every column as text. Check: test the text against the data dictionary before converting anything. Load: INSERT ... SELECT the rows that pass, with the cleaning rules written into the SELECT. Prove: count what came in and what stayed out, and keep the scripts so the database can be rebuilt from nothing.
Trail Gear's rehearsal keeps all four steps on view. stg_items is the staged file, items is the real table, gear_types is its parent, and load_audit holds one row per source line with the decision and the reason.
SELECT source_row, serial, decision, reason
FROM load_audit
ORDER BY source_row;Rule of thumb. Stage it raw, check the text, load with the rules written down, then count what happened.
Going deeper (optional, not on the exam)
INSERT ... SELECT is the INSERT from chapter 9 with a query where VALUES would be: it adds every row the SELECT returns, and the column lists line up by position. It is one statement, so one bad row stops all of them, which is why the checks come first.
Import everything as text first
In the sqlite3 shell, two lines stage the file: .mode csv, then .import items.csv stg_items. Neither is SQL. They start with a dot, take no semicolon, and work only in the sqlite3 tool or in a script it reads; the browser playground runs SQL and nothing else. When stg_items does not exist yet, .import creates it from the file's header row: four columns named serial, type_id, daily_rate and note, every one of them TEXT. The gear type is the text '1'. The rate is the text '8', or the text 'unknown'.
A blank cell arrives as an empty string, not as NULL. TG-106's note is '' and TG-102's note is three spaces, and typeof(note) says text for all six rows. So note IS NULL finds nothing, and a NOT NULL constraint would accept both. An empty string is a value that looks like a missing one.
SELECT serial, note, typeof(note) AS storage
FROM stg_items
ORDER BY rowid;The fix is a rule, written as SQL on the way out of staging: NULLIF(TRIM(note), ''). TRIM removes the spaces at either end. NULLIF compares what is left with the empty string and returns NULL when they match, the text otherwise. The order matters. NULLIF(note, '') catches TG-106 and leaves TG-102's three spaces in place, a blank that is still a word of spaces.
SELECT serial, NULLIF(TRIM(note), '') AS clean_note
FROM stg_items
ORDER BY rowid;Use the rule only where the data dictionary says blank means unknown. A blank note means there is no note, so NULL is honest. A blank serial is not an unknown serial; it is a row that cannot be loaded, and turning it into NULL hides the problem in the one column that identifies the item. Required columns get a check and, when they fail it, an exclusion with a reason.
Rule of thumb. Stage every column as text, and turn a blank into NULL only where the dictionary says blank means unknown.
Going deeper (optional, not on the exam)
Every command that starts with a dot belongs to the sqlite3 shell, not to SQL: .mode, .import, .headers, .read, .bail, and .timer, which prints how long each statement took and earns its place when a client file has fifty thousand rows. None of them runs in the browser.
When the target table already exists, .import treats the header row as one more row of data. Recent versions of sqlite3 take .import --skip 1 to step over it. Staging into a table that does not exist yet avoids the question, which is one reason the kit does it.
Why the staging table has no key
stg_items is evidence, not part of the application. It is the file, row for row, with nothing cleaned and nothing typed, and it stays in the database until every decision about it is written down. If a teammate asks why TG-105 is missing from items, the answer starts with the row as it arrived.
It has no primary key on purpose. A key is a rule, and the staging table's job is to accept whatever the file holds, duplicates and blanks included, so the checks can find them. Each staged row is traced by its rowid, which follows the file's order: TG-101 is row 1, TG-106 is row 6, and load_audit's source_row points at the same numbers.
Your client's application tables are the opposite. Every one of them gets the keys and constraints the ERD and the dictionary give it. Some load scripts drop the staging tables at the end; the kit keeps them so a reviewer can see every decision. Either is defensible when the report says which.
Rule of thumb. The staging copy is the evidence. Clean on the way out of it, and keep it until the decisions are written down.
Going deeper (optional, not on the exam)
The browser copy of Trail Gear is the finished rehearsal, with original fictional data. It cannot take your client's file: .import is a desktop step, and the playground opens a fresh copy of the rehearsal each time.
CAST converts; it does not certify
TG-105's rate arrived as the word unknown. CAST(daily_rate AS INTEGER) turns it into 0, with no error and no warning. CAST reads digits from the start of the text and stops at the first character that does not fit; when there are no digits at all, the answer is zero.
SELECT serial, daily_rate, CAST(daily_rate AS INTEGER) AS converted_rate
FROM stg_items
ORDER BY rowid;items has a CHECK on daily_rate: an integer, zero or more. That CHECK sees the converted 0 and accepts it, because a CHECK can only see the value it is handed, never the text in the file. An item that rents for nothing a day enters the database, and every report built on it treats the gear as free. TG-104's -3 is refused, but only because this rule happens to forbid negatives.
So test the text before you convert it. The rehearsal's rule: after TRIM, the rate is not empty and every character is a digit from 0 to 9. TRIM(daily_rate) = '' OR TRIM(daily_rate) GLOB '*[^0-9]*' finds the rows that break it. GLOB matches a pattern: * stands for any run of characters, and [^0-9] for one character that is not a digit. TG-104 breaks the rule with its minus sign, TG-105 with every letter.
SELECT serial, daily_rate
FROM stg_items
WHERE TRIM(daily_rate) = ''
OR TRIM(daily_rate) GLOB '*[^0-9]*'
ORDER BY rowid;The rule is this rehearsal's, not a universal one. It refuses decimals, which suits whole-dollar daily rates and would be wrong for a wage with cents. Your client's dictionary says what a valid value looks like, and your rule follows it. Whatever the rule, keep the rejected row and its reason: load_audit gives both TG-104 and TG-105 the reason 'Rate is not an unsigned whole number'.
Rule of thumb. Test the text, then convert it. A CHECK sees only what CAST handed it.
Going deeper (optional, not on the exam)
GLOB is case-sensitive and uses * and ? where LIKE uses % and _. LIKE has no brackets, so it cannot say one character that is not a digit.
CAST('21,50' AS REAL) is 21.0. The conversion stops at the comma, and a wage written with a decimal comma loses its cents without an error.
Do not add OR IGNORE to make a load script finish. INSERT OR IGNORE skips every row that breaks a UNIQUE, NOT NULL or CHECK constraint without saying which row or which rule, so the count comes out short and the evidence is gone. It does nothing for a foreign key, which still refuses the statement.
By default a failed constraint aborts the one statement that hit it, so an INSERT ... SELECT that meets one bad row loads none of its rows. Statements that ran earlier in the script stay done. That is SQLite's ABORT behaviour, and it is why the load filters the bad rows out with a WHERE instead of waiting for them to fail.
Keep the row that has no parent
TG-103 says its gear type is 99. gear_types has two rows, 1 for Trail gear and 2 for Water gear. A row whose foreign key points at nothing is an orphan, and an orphan in a file is easy to lose before anyone sees it.
Look for it with an INNER JOIN and it disappears. The join keeps only the staged rows that found a parent, so WHERE g.id IS NULL is asked of five rows that each have one. The query returns nothing, and nothing looks exactly like no orphans.
A LEFT JOIN from staging to the parent keeps all six staged rows. TG-103 comes back with NULL in every gear_types column, and the test is on the parent's key: WHERE g.id IS NULL. The ON clause converts the staged text, g.id = CAST(s.type_id AS INTEGER), and that CAST is safe only because every type_id in this file was checked first and is all digits.
SELECT s.serial, s.type_id, g.name AS gear_type
FROM stg_items s
LEFT JOIN gear_types g ON g.id = CAST(s.type_id AS INTEGER)
ORDER BY s.rowid;Rule of thumb. Hunt orphans from the child's side with a LEFT JOIN, and test the parent's key for NULL.
Going deeper (optional, not on the exam)
With foreign keys on, an INSERT ... SELECT that includes TG-103 is refused as a whole: FOREIGN KEY constraint failed, and none of its rows load. With them off, the orphan loads without a word. PRAGMA foreign_key_check lists every row whose foreign key points at nothing, which is how you find what an earlier load let in.
PRAGMA foreign_keys = ON belongs to the connection, not the file. Every new sqlite3 session starts with enforcement off, which is why each of the kit's scripts turns it on in its first line.
What happens to the orphan is the client's call
Finding TG-103 is SQL. Deciding what happens to it is not. There are three honest answers: exclude the row and write down why; correct it, when the client confirms what the type should have been and sends a corrected file; or let it in with no type, if the business allows an item without one and the column is declared to allow NULL. Each needs a reason the client would sign.
The dishonest fourth answer is to invent a gear type 99, call it Unknown, and watch the error go away. The foreign key is satisfied, and every report now has a category nobody defined. The rehearsal excludes TG-103 with the reason 'Unknown gear type', and the reconciliation lists it by name.
Rule of thumb. An orphan is a question for the client, not a parent to invent.
Going deeper (optional, not on the exam)
A foreign key can say what happens when its parent row is deleted: ON DELETE CASCADE removes the children too, and ON DELETE SET NULL empties their key. The kit declares neither, so deleting a gear type that items point at is refused while foreign keys are on. Read the CREATE TABLE before you assume what a delete will do.
Count every population on its own
The audit says three rows were excluded. A teammate checks it the quick way: six source rows minus three loaded is three. Same answer. Now suppose the load had silently lost TG-106. Loaded drops to two, source minus loaded says four were excluded, and the arithmetic still balances, because it was built to. A count worked out from the other two can never disagree with them.
So count each population from its own table. Source rows come from stg_items, loaded rows from items, excluded rows from the decisions in load_audit. When source equals loaded plus excluded with three independent counts, the equation means something. When it fails, the gap is a row you can go and find.
SELECT (SELECT COUNT(*) FROM stg_items) AS source_rows,
(SELECT COUNT(*) FROM items) AS loaded_rows,
(SELECT COUNT(*) FROM load_audit WHERE decision = 'Excluded') AS excluded_rows;Watch the excluded count itself. COUNT(CASE WHEN decision = 'Excluded' THEN 1 ELSE 0 END) says six, because COUNT counts every value that is not NULL, and 0 is a value. Count the rows with a WHERE, or let the CASE fall through to NULL.
The equation fits this kind of load: a first load into an empty table, one source row to one target row. An append to a table that already has rows, a file with duplicates you merge, a file that becomes two tables: each needs its own mapping, written down before the counts mean anything.
Rule of thumb. Count source, loaded and excluded separately. A count you worked out from the other two cannot catch a lost row.
Going deeper (optional, not on the exam)
Near its end the kit's load.sql prints a reconciliation block: one row per target table, stacked with UNION ALL, gear_types first with its two hand-typed rows, then items, followed by the excluded rows and their reasons. Your client's load.sql closes the same way, one row per table it fills.
Prove more than a count
Northshore Coffee copies its 204 orders into a new table, and the copy reports 204 rows. One order came in twice and another never arrived. The count is perfect, the sales total is twelve dollars short, and the count will never tell you.
A reconciliation needs evidence about the rows as well as the totals. COUNT(DISTINCT id) beside COUNT(*) shows a duplicate. A SUM of the amount shows money that moved. And then the identities themselves: the three serials load_audit marks Loaded should be exactly the three serials in items, carrying the cleaned values you meant.
SELECT a.source_row, a.serial, i.id AS loaded_id
FROM load_audit a
LEFT JOIN items i ON i.serial = a.serial
WHERE a.decision = 'Loaded'
ORDER BY a.source_row;Write a baseline down before the load and compare after it: the row count, the count of distinct keys, a total, the first and last key. Two numbers that match prove that two numbers match. Rows that match prove the load.
Rule of thumb. A count can stay right while the rows go wrong. Compare keys and totals as well.
Build it twice
The desktop kit holds five files: schema.sql, load.sql, verify.sql, items.csv and a README. In the extracted folder, open sqlite3 with a database file that does not exist yet, then run .read schema.sql, .read load.sql and .read verify.sql, and .quit. Then do it again into a second new file, rehearsal-again.db, without deleting the first.
-- In a terminal, inside the extracted kit folder
sqlite3 rehearsal.db
.read schema.sql
.read load.sql
.read verify.sql
.quitTwo databases built from the same scripts and the same file should agree row for row. That is what reproducible means on the project checklist: anyone who runs your scripts gets your database. Anything you typed only into the shell is missing from the second build, which is how the rebuild catches it.
.bail on, near the top of each script, stops the script at the first error. It is not a rollback: whatever ran before the error stays done, and the file is half built. Start again in a new file rather than rerunning into it. The scripts expect an empty database on purpose; run load.sql into one that is already loaded and its first insert is refused on the duplicate gear type, so nothing is appended twice.
Rule of thumb. A database is reproducible when two fresh builds from the same scripts agree row for row.
Going deeper (optional, not on the exam)
.read, .bail and .quit are shell commands, like .import: they belong to sqlite3 and never run in the browser. The browser exercises read a build that has already happened; the builds themselves are a desktop skill.
A matching total is the weakest check
verify.sql ends with a sanity join: each gear type with its count of items and the sum of their daily rates. Trail gear has two items and 18, Water gear one item and 12. When both builds print 18 and 12, that is reassuring, and not much more.
SELECT g.name AS gear_type, COUNT(i.id) AS items, SUM(i.daily_rate) AS rate_sum
FROM gear_types g
LEFT JOIN items i ON i.type_id = g.id
GROUP BY g.id, g.name
ORDER BY g.id;A teammate's version of the join pairs the tables ON 1 = 1. Every item meets every type, each type reports 30, and the total across both types doubles from 30 to 60. Run it in both builds and they agree perfectly on the wrong numbers. A sanity query that agrees with itself proves nothing; the ON must pair items.type_id with gear_types.id, the key and not a coincidence.
And 18 is a sum of listed daily rates, not revenue. There are no rentals in this database, so no query on it can say what Trail Gear earned. Say what a number is before you compare it.
Compare the strongest evidence first: the ordered rows of items, column by column; the count in every table; the notes that are NULL (two) against the notes that are empty strings (none); PRAGMA foreign_key_check, which should return no rows, and PRAGMA integrity_check, which should say ok; and only then the totals. Then, on a disposable copy, try the inserts that must fail, a duplicate serial, a negative rate and a type with no parent, and paste the exact errors.
Rule of thumb. A matching total is the weakest evidence. Compare the rows, then the counts, then the totals.
Going deeper (optional, not on the exam)
The multiplication trap from chapter 7 is the same mistake with a real key: a one-to-many join repeats the one side, and a sum taken after it grows. ON 1 = 1 is the extreme case, every row paired with every row.
Concepts in this chapter
Each concept has its reading and a ladder of six rungs, from watching a question answered to transferring the idea to new data. The rungs below show your saved practice: reached, in progress or not yet.
Concept 1 · .import, the staging table, NULLIF(TRIM())
A blank still becomes a word of spaces
- Say what .import stores for a blank cell, and why no staged note is NULL
- Turn a blank or whitespace-only value into NULL with NULLIF(TRIM())
- Say which columns may become NULL and which must be excluded with a reason instead
- 1Watch it answered: not yet
- 2Fill the blank: not yet
- 3Predict the result: not yet
- 4Write it yourself: not yet
- 5Repair it: not yet
- 6Transfer it: not yet
Concept 2 · CAST, GLOB, why a CHECK cannot see the source
The invalid rate hides behind zero
- Say what CAST does to text that is not a number
- Find the values that break a text rule with GLOB before converting them
- Explain why a CHECK on the target table cannot catch a value CAST invented
- 1Watch it answered: not yet
- 2Fill the blank: not yet
- 3Predict the result: not yet
- 4Write it yourself: not yet
- 5Repair it: not yet
- 6Transfer it: not yet
Concept 3 · LEFT JOIN from staging to the parent, exclusion as a decision
The orphan hunt loses the orphan
- Find the staged rows with no parent using a LEFT JOIN and a NULL test on the parent's key
- Explain why an INNER JOIN hides the orphan
- Name the three decisions a client can make about an orphan, and the one repair that is not a decision
- 1Watch it answered: not yet
- 2Fill the blank: not yet
- 3Predict the result: not yet
- 4Write it yourself: not yet
- 5Repair it: not yet
- 6Transfer it: not yet
Concept 4 · source = loaded + excluded, counted independently
The audit calls every row excluded
- Count source, loaded and excluded rows from three different tables
- Explain why excluded rows must not be worked out as source minus loaded
- Compare distinct keys and totals beside the row counts
- 1Watch it answered: not yet
- 2Fill the blank: not yet
- 3Predict the result: not yet
- 4Write it yourself: not yet
- 5Repair it: not yet
- 6Transfer it: not yet
Concept 5 · two fresh builds, verify.sql, what a matching total proves
A sanity query doubles the rate total
- Build the kit's database twice into new files and compare what verify.sql prints
- Repair a sanity join that pairs every row with every row
- Say what a matching total does not prove, and name the stronger comparisons
- 1Watch it answered: not yet
- 2Fill the blank: not yet
- 3Predict the result: not yet
- 4Write it yourself: not yet
- 5Repair it: not yet
- 6Transfer it: not yet
Chapter review
Key terms
- staging table
- A table that holds a file exactly as it arrived, every column as text, before any cleaning.
- .import
- The sqlite3 shell command that copies a CSV file into a table. A shell command, not SQL.
- NULLIF(TRIM(x), '')
- Removes the outside spaces, then turns an empty result into NULL; for columns where blank means unknown.
- CAST
- Converts a value to a type. Text with no digits at the start becomes 0, without an error.
- GLOB
- Case-sensitive pattern matching: * for any run of characters, [ ] for one character from a set, [^ ] for one character outside it.
- orphan
- A row whose foreign key points at a parent that does not exist.
- load audit
- One row per source row with the decision the load made, loaded or excluded, and the reason.
- independent counts
- Source, loaded and excluded rows each counted from their own table, so a lost row shows up as a gap.
- verify.sql
- The script that prints the evidence of a build: the schema, the counts, the ordered rows, the NULL checks, the PRAGMA checks and a sanity join.
- reproducibility
- Two fresh builds from the same scripts and the same file agree row for row.
Check your understanding
Short questions you can answer from the chapter. A choice shows its feedback at once; a sentence shows a model answer after you save it.
Opening your saved work…
Sign in to save across devices → · Download your guest work before switching accounts.
Applied to Lab 10
Build the Trail Gear rehearsal twice from the kit, into two new files, and set the two verify.sql outputs side by side: the ordered items, the counts, the missing and empty notes, the two PRAGMA checks and the sanity join. Write down the one difference you would investigate first if there were one. Then do the same for the shelter: Part 4 deletes your database and rebuilds it from schema.sql and load.sql alone, and the reconciliation your load prints should count source, loaded and excluded rows from three different tables.
Sources and further reading
- Week 10 notes: Loading a CSV (the staging pattern), Build and Load Scripts, PRAGMA foreign_keys, and the common mistakes
- Pre-Lab 10, sections 1.6 and 1.7
- The Trail Gear build rehearsal kit, lab10-build-rehearsal.zip: README, schema.sql, load.sql and verify.sql