Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Serialisation & Storage  ›  Capstone Project

Capstone — The Vault

40 minA CSV export through typed records into SQLite and back, with proof that nothing changed
You're building a piece ofQuarry — the vault
This piece — vault_roundtrip(): CSV in, typed records through SQLite, and proof that nothing changed on the way.
Scenario The weekly incident export arrives as a CSV. Quarry has to vault every record and prove, before anyone reads a report built on it, that the vault holds exactly what the export said.
Your task
Build vault_roundtrip(csv_text): read_csv, to_record (id and ms to int, empty ms to None, tags "a|b" to a sorted unique list), store in one SQLite transaction with tags as JSON text, read back ORDER BY id and decode the tags, then return {"stored", "unchanged" (read-back == records sorted by id), "levels" (GROUP BY counts), "slowest" (id of the largest ms, lowest id on a tie, else None)}. A refused batch returns {"stored": 0, "unchanged": False, "levels": {}, "slowest": None}.

Capstone — The Vault

In short: four lessons, four formats, each answering a different question — CSV for tables people and spreadsheets exchange (every value a string), JSON for documents programs exchange (tuples, sets and datetimes don't survive unaided), pickle for Python talking to itself (loading it runs code, so never across a trust boundary), SQLite for data you want to query and trust (a schema, parameters and transactions to get right). A real system rarely picks just one. Quarry's vault takes a CSV export in, turns each row into a typed record, stores the records in SQLite with the one list-valued field packed as JSON, reads everything back, and checks the claim this whole section has been working towards: what came out is exactly what went in.

A typed record sent in, a read-back record matching exactly — equal; beside it a read-back record with ms turned to 0, tags still a JSON string, id turned to a string — nothing crashed, but == catches every one

Type a raw CSV row before it reaches the vault

The CSV has columns id, source, level, ms, tags, where tags is a |-separated list like web|disk. Typing a row is where two decisions get made that matter later:

python

An empty ms becomes None, not 0: "we don't know" and "it took zero milliseconds" are different facts, and SQLite stores None as NULL and gives it back as None. Tags become a sorted, de-duplicated list, so the same tags always produce the same value — without that, the final comparison could fail on ordering alone:

What does this print?
row = {"id": "7", "source": "web-01", "level": "WARN", "ms": "", "tags": ""}
tags = sorted({t for t in row["tags"].split("|") if t})
print(tags, "".split("|"))
Drawing diagram…

Store a record whose tags are a list

SQLite binds only None, int, float, str and bytes, so the list of tags has to cross as JSON text — json.dumps(["disk", "web"]) is the string '["disk", "web"]'. Everything else goes in as itself, inside one transaction with :name placeholders. Here's what happens if you forget the JSON step:

broken — fix it

Run it and read which type the driver refused to bind. Then pack the list as JSON before inserting.

Reading back means the reverse: select every row in id order, turn each into a dict, and json.loads the tags column back into a list. Watch a single row's tags column change type on the way out:

step through it
1import json
2import sqlite3
3 
4conn = sqlite3.connect(":memory:")
5conn.row_factory = sqlite3.Row
6conn.execute("CREATE TABLE records (id INTEGER PRIMARY KEY, ms INTEGER, tags TEXT)")
7conn.execute("INSERT INTO records VALUES (?, ?, ?)", (1, None, json.dumps(["disk", "web"])))
8row = conn.execute("SELECT id, ms, tags FROM records").fetchone()
9stored = dict(row)
10back = {**stored, "tags": json.loads(stored["tags"])}
11print(stored["tags"], back["tags"], back["ms"])

Prove the round trip lost nothing — and catch it when it does

Python's == on two lists of dicts compares every field of every record, including the difference between None and 0, "7" and 7, and ["web"] and '["web"]'. That comparison is only worth something if it would fail when something goes wrong — and each of these bugs is easy to write and invisible at a glance: forgetting json.loads on the way out, storing ms as text, turning an empty ms into 0, leaving tags unsorted, a half-stored batch after a rollback.

What does this print?
back = [{"id": 1, "ms": None, "tags": ["web"]}]
sent = [{"id": 1, "ms": 0, "tags": ["web"]}]
print(back == sent)

When a batch breaks a constraint — a duplicate id — the transaction rolls back and nothing is stored. The capstone reports that honestly: stored is 0 and unchanged is False, because the vault doesn't hold what it was given.

Measure what each format actually costs

Which format is smallest, fastest? Measure it — here's a harness that builds synthetic records and writes and reads them through all four, timing each with time.perf_counter:

python

CSV is compact because the header is written once, but gives you back strings. JSON pays for repeating every key on every record, yet it's the only one of the four a browser or another language reads without help. pickle is usually fastest and gives back real Python objects, but only Python can read it, and never from a stranger. SQLite is rarely the smallest or fastest to write — what you pay for is what the others can't do: a WHERE clause, an update of one record, a crash that leaves the previous state intact. That's why the vault is a database.

Quick practice

What does this print?
sent = {"id": 2, "ms": 120, "tags": ["disk", "web"]}
back = {"id": "2", "ms": 120, "tags": ["disk", "web"]}
print(sent == back)
broken — fix it

Run it, see the mismatch, then add the missing json.loads so tags_back is a list again.

What does this print?
rows = [{"id": 3, "tags": ["web"]}, {"id": 1, "tags": ["disk"]}]
sent = sorted(rows, key=lambda r: r["id"])
print([r["id"] for r in sent])

🎯 Your turn

Write vault_roundtrip(csv_text). The starter includes read_csv from the first lesson and the SCHEMA. You write to_record and the round trip:

  1. records = [to_record(row) for row in read_csv(csv_text)]
  2. store them in one transaction, with tags as json.dumps(...) text
  3. if the batch raises sqlite3.IntegrityError, return {"stored": 0, "unchanged": False, "levels": {}, "slowest": None}
  4. read everything back ORDER BY id, decoding tags
  5. return:
    • "stored": how many records came back
    • "unchanged": whether they equal the records sorted by id
    • "levels": {level: count}, from SELECT level, COUNT(*) ... GROUP BY level
    • "slowest": the id with the largest ms (the lowest id on a tie), or None if no record has an ms

Example: three incidents where id 2 took 950 ms → {"stored": 3, "unchanged": True, "levels": {"INFO": 1, "WARN": 2}, "slowest": 2}.

Hint: max(timed, key=lambda r: r["ms"]), where timed is the records read back in id order that have an ms, returns the first of any tie, which is the lowest id.

Press ▶ Run and hit ✓ Check. When it's green, the vault is done: data goes out of memory and comes back unchanged. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA list that fits in a TEXT column

Write pack_tags(tags) returning the JSON text Quarry stores in the tags column: the tags de-duplicated and sorted, encoded with json.dumps.

jsonsqlite
DrillFrom CSV strings to a typed record

Write to_record(row). row is a dict of strings from read_csv. Return id as an int, source and level unchanged, ms as an int or None if the cell is empty, and tags (a |-separated string) as a sorted list without duplicates or blanks.

tabular-filestype-casting
BuildBytes on the wire: CSV versus JSON

Write format_sizes(records). records is a list of flat dicts sharing the same keys. Return {"csv": bytes, "json": bytes}: the UTF-8 byte length of the CSV text (header from the first record's keys, lineterminator="\n") and of json.dumps(records, separators=(",", ":"), ensure_ascii=False). For an empty list the CSV is 0 bytes and the JSON is [].

tabular-filesjson
BossQuery the vault

Write slow_ids(csv_text, level). Read the CSV with the starter's read_csv, store id, level and ms (as ints, empty ms as NULL) in a :memory: table, then return the ids of records at level that have an ms, slowest first, with ties broken by lower id first. Use a ? parameter for the level.

sqliteparameterized-querytabular-files
CapstoneAccept the good rows, report the bad ones

Write vault_report(csv_text). The CSV has id, level and ms. A data row is rejected if its id or ms isn't an integer (an empty ms is also a reject here). Store the accepted rows in a :memory: SQLite table in one transaction. Return {"stored": count, "rejected": [data-row numbers, first data row = 1], "levels": {level: {"count": n, "max_ms": m}}}, with the level figures computed by SQL (GROUP BY level).

sqlitetabular-filesjsonexceptions
ExplainWhy the vault is a database and the cache is a pickle

Across this section Quarry used four formats. Explain why the vault stores records in SQLite rather than as one JSON file or one pickle file per batch, and name one situation where each of CSV, JSON and pickle is still the right choice. Use at least one concrete property (queryability, transactions, trust, types, who reads it) in each answer.

vault_roundtrip.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
vault_roundtrip(csv_text) → dictCSV in, SQLite round trip, and a report proving the records came back unchanged.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.