Capstone
Working with JSON
Chapter 12 ยท Capstone: A JSON Data Pipeline
This chapter uses everything in the course on one real job. You download a public JSON feed, check it against a JSON Schema, clean it into JSON Lines with jq, stream through it with a script to produce a report, check that report with a second tool, change one record with a JSON Patch, and prove you can run the whole thing again and get the same files.
The data is the USGS earthquake feed for the past 30 days. Unlike the earlier chapters, the data is not mine or generated: it has real surprises in it, and the pipeline found one that I did not expect.
The Plan
| Step | What | Tool | Chapters used |
|---|---|---|---|
| 1 | Fetch one snapshot; record size, hash, clock, count | curl, jq | 4, 11 |
| 2 | Validate every feature against a schema; cross-check with a second validator; run a control | Ajv, jsonschema | 6, 7 |
| 3 | Keep earthquakes, flatten to one record per line | jq | 4, 5, 9 |
| 4 | Stream the lines, validate each record, count, write a report | Node | 2, 9 |
| 5 | Recompute the numbers a different way and compare | jq | 5 |
| 6 | Apply a JSON Patch to one record, with checks, without touching the original | jsonpatch, jsonschema | 3, 7, 8 |
| 7 | Run steps 2 to 6 twice and compare checksums | sha256 | 9, 11 |
Step 1: The Data and Its Licence
The USGS publishes earthquake summaries as GeoJSON (a JSON format for map data), refreshed every minute, at https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson. The page that documents the feed says each feature has an id, a geometry (a Point with longitude, latitude and depth) and properties such as magnitude, place, time and status. I made one request, with a descriptive User-Agent, and kept the file:
| Item | Value |
|---|---|
| Response | HTTP 200, Content-Type: application/json; charset=utf-8, X-Content-Type-Options: nosniff (Chapter 11), Cache-Control: public, max-age=60 |
| Size | 7,173,808 bytes |
| SHA-256 | 37f31144591d663c... (kept in full in the README) |
| Feed clock | generated 2026-10-12T19:05:41Z; metadata.count 10,105, and the file holds 10,105 features (checked) |
Licence. A capstone should record where data came from and what you may do with it. The USGS pages I could read through a search say that most of its information resides in the public domain and may be used without restriction, with a request for credit (the suggested form is a line such as "Source: U.S. Geological Survey"), and that some third-party material on its sites is copyrighted. A direct fetch of the main USGS copyright page failed (HTTP 403), so I did not read that page myself, and the README says so. Before you reuse the data, read the current USGS terms. The snapshot is not stored with the course; the README records how to fetch another.
Step 2: Validate Before You Trust
A feed you did not write can change without warning. The schema describes only the parts this pipeline depends on, and stays open to new fields, because a feed that adds a property should not break us. An excerpt (Chapters 6 and 7 explain each keyword):
I compiled the schema once with Ajv (strict mode on, ajv-formats for format: "uri") and checked each feature separately so every bad record can be named. Results on the snapshot:
| Check | Result |
|---|---|
| Ajv, per feature | 10,105 features: 10,104 valid, 1 invalid; the one error is "/properties/place must be string" on aka2026snusmn (a null place) |
Python jsonschema, same schema | 1 invalid, the same id: the two validators agree. (jsonschema ignores format unless given a format checker, a Chapter 6 finding) |
metadata.count equals the number of features | true; a schema cannot express this, so the script checks it |
| Control: eight copies of one real feature, six deliberately broken (mag as a string, latitude 95, two coordinates, an unknown status, a float timestamp, a non-URI url) | the untouched copy and the copy with an extra property passed; all six broken copies were rejected |
The control matters: a validator that says "valid" to everything would also have produced "10,104 valid". Seeing it reject known-bad copies is what makes the one real failure believable.
Step 3: Clean and Reshape with jq
The raw feature nests the useful fields two levels deep, stores times as milliseconds, and mixes real earthquakes with explosions and quarry blasts. The jq program (Chapter 5) turns each earthquake into one flat record:
I ran it as jq -b -c -f 03_clean.jq quakes.geojson > quakes.jsonl. -c gives one record per line and -b keeps Windows from writing CRLF (Chapter 9). Checks on the output:
- 9,891 records written; 214 features dropped by the type filter (72 explosions, 117 quarry blasts, 22 ice quakes, 2 mining explosions, 1 landslide). 9,891 + 214 = 10,105, so nothing went missing.
- The file contains no carriage-return bytes (0, counted with
tr, after I had to fix my first check; see the mistakes). - The unexpected result: the one feature with a null place is a quarry blast, so the type filter had already dropped it, and my "(unknown)" rule for null places never fired (0 records). I had written the rule to handle the invalid record Step 2 found; on this snapshot it is dead code. It would matter for a feed with a null place on an earthquake, so I kept it and wrote that down rather than claim it was exercised.
- The
regionrule is crude. It takes the text after the last comma ofplace. USGS writes "CA" for California but "Alaska" in full, so the biggest regions are "Alaska" (3,323 records) and "CA" (2,828), and 137 places with no comma (for example "Mid-Indian Ridge") keep their whole text. A real analysis would map abbreviations to names.
Step 4: Stream the Lines and Make the Report
The cleaned file is JSON Lines, so a Node script reads it one line at a time (Chapter 9), validates each record against a second, closed schema (additionalProperties: false: this is our own output, so an unknown field is a bug), and tallies. Excerpt:
| Report item | Value on this snapshot |
|---|---|
| Lines / valid / invalid / bad JSON / blank | 9,891 / 9,891 / 0 / 0 / 0; no duplicate ids |
| Magnitude: under 2 / 2-3 / 3-4 / 4-5 / 5-6 / 6+ | 6,898 / 1,713 / 303 / 808 / 161 / 8 (the 4-5 count is larger than the 3-4 count; I did not investigate why) |
| Status | 1,799 automatic, 8,092 reviewed |
| Tsunami flag set | 5 |
| Depth range | -3.43 km to 665.057 km |
| Strongest | M 7.7, 12 km WSW of Pitaloza Arriba, Panama, 2026-10-09T17:56:06Z |
strong.jsonl (mag 5 and over) | 169 lines = 161 + 8 |
| Resource use (Chapter 9 harness) | about 0.4 s, 58 MB peak memory |
The script also checks its own arithmetic: valid + invalid + bad + blank equals the number of lines, and the magnitude buckets sum to the valid count (both true).
Step 5: Check the Numbers a Different Way
A report is only as good as its method, so I recomputed the key numbers with jq straight from the original GeoJSON, not from quakes.jsonl, and compared them with report.json: number of earthquakes, the six magnitude buckets, status counts, the tsunami count and the strongest event's id. They agree. As a control I changed one number in the jq output on purpose and the comparison reported a disagreement. Two methods, two inputs, same answer: the cleaning step did not lose or alter any of the things counted.
Step 6: Patch One Record, Safely
Suppose a reviewer confirms record aka2026ufzwkj, so it should become reviewed and its magnitude 1.5. This is a teaching edit on a copy, not a real seismic correction. A JSON Patch (Chapter 8) states it exactly, with preconditions:
The script streams the JSON Lines file, applies the patch to the one record whose id matches (the library applies it to a copy), checks the patched record against the closed record schema, and only then writes the new file; the original is never opened for writing. Three patches, three outcomes:
| Patch | Result |
|---|---|
| the good patch above | applied; new file written; exactly one line differs from the original (9,891 lines in both); status counts moved from 1,799 automatic and 8,092 reviewed to 1,798 and 8,093 |
a test that expects reviewed on a record that is automatic | failed with JsonPatchTestFailed; no output file; the replace /mag 9.9 after it never ran |
add /note (a valid patch operation) | the patch applied, but the patched record failed the closed schema ("Additional properties are not allowed ('note' was unexpected)"); no output file |
The third row is why the schema check comes after the patch: JSON Patch knows nothing about your schema, so a patch can be perfectly legal and still produce a record your pipeline rejects. The original quakes.jsonl had the same SHA-256 afterwards.
Step 7: Repeatable
I ran steps 2 to 6 twice from the saved snapshot in two fresh folders. The four data files (quakes.jsonl, report.json, strong.jsonl, quakes_patched.jsonl) and the five step logs all had identical SHA-256 values in both runs. That is a modest claim: both runs read the same snapshot, so it shows the scripts are deterministic. It says nothing about a new download, which will differ because the feed changes every minute. That is why Step 1 records the hash of the snapshot.
The README
A pipeline is only reusable if someone else can tell what it did. Mine states: the source and URL; the snapshot time, size, count and SHA-256; the licence and how I learned it (with the caveat); a table of the seven steps with their results on this snapshot; the headline numbers; and the known limits (the crude region rule, the dead "(unknown)" rule, that the patch is a classroom edit, and the exact tool versions). The full file is in the solution to Exercise 3.
tr -cd and an octal code, and tested it on a string that I knew contained a CRLF (it counted 1). How to avoid it: test a checker on input where you know the answer before believing it on input where you do not.2. I expected the null-place feature to be an earthquake. My script stopped (it was set to stop on error) when it could not find that record in the cleaned file, and the reason was that it is a quarry blast. How to avoid it: when a rule exists for a case you have seen, check that the case reaches the rule; a count of how often a rule fired would have told me at once.
3. I could not read the main USGS licence page. The fetch tool returned an empty page, then a direct request returned HTTP 403, and two other USGS pages had no licence text. I used search results instead and wrote that into the README. How to avoid it: say exactly which source a legal or licence claim came from, and say when it is second-hand.
4. The editing tool turned a backslash-r in a script into a real carriage-return character (the same escape problem as in earlier chapters), which made the line unreadable in the saved file. I rewrote it with the backslash built from
chr(92). How to avoid it: after writing a script that must contain a backslash escape, print that line and look for raw control characters.5. My first progress echo printed "content-type: Content-Type: ..." with the label twice. A cosmetic slip, but in a record of what a server said it is the kind of thing that makes a reader doubt the rest. How to avoid it: read the output of a reporting script once as a stranger would.
Exercises
Download one snapshot of the USGS all_month feed with a single request, and record its size, SHA-256, feed clock and the count check. Write a JSON Schema (2020-12) for the fields the pipeline needs, validate every feature with Ajv, cross-check the verdicts with a second validator in Python, and write a control that breaks copies of a real feature in six ways and shows all six are rejected. Name every invalid record and explain each error.
Write a jq program that keeps earthquakes and flattens each to one record (ISO times, a boolean tsunami flag, split coordinates, a region), write the records as JSON Lines without CRLF, and prove nothing was lost (kept + dropped = total). Then write a Node script that streams the file, validates each record against a closed schema, and writes a report and a second JSON Lines file of the magnitude 5 and over events. Recompute the key numbers with jq from the original file, compare, and show that a deliberately changed number is caught. Report whether each of your cleaning rules ever fired.
Write a JSON Patch with two test operations and two replace operations for one record, and a script that streams the JSON Lines file, patches only the record with that id, validates the patched record against the closed schema, and writes a new file only if everything succeeds. Run a good patch, a patch whose test fails and a legal patch that breaks the schema, and show which files exist afterwards, that exactly one line differs, and that the original is unchanged. Run steps 2 to 6 twice and compare checksums. Finish with a README that records the source, snapshot hash, licence and its caveat, the steps with results, and the known limits.
Course Complete: What You Can Now Do
| Chapter | You can now | One fact to remember |
|---|---|---|
| 1 JSON Syntax | read and write exact JSON; know where parsers differ | JSON has six types and a short forbidden list; parsers disagree on edge cases |
| 2 JSON in JavaScript | use parse, stringify, revivers and replacers without surprises | numbers beyond 253 lose digits in JSON.parse |
| 3 JSON in Python | control encodings, types and numbers in the json module | open files with an explicit encoding |
| 4 jq Basics | query JSON from the command line, with correct quoting | exit statuses and shell quoting are half of the work |
| 5 jq Reshaping | group, reduce, format and reuse programs | test a jq feature on your version before relying on it |
| 6 Schema Basics | describe data with JSON Schema 2020-12 | format is ignored unless you ask for it |
| 7 Validating | compile once, choose combinators, read errors | compiling per document was about 24,000 times slower |
| 8 Pointer, Path, Patch | address, search and change parts of a document | apply a patch to a copy; test your library's default mode |
| 9 Lines and Scale | handle large files with JSON Lines and streaming | one line at a time kept memory flat at 17-57 MB; whole-file loads grew with the file |
| 10 Other Formats | choose between JSON and its neighbours | quote YAML strings; TOML has no null |
| 11 Security and Pitfalls | defend an API boundary | build JSON with the library, never with string concatenation |
| 12 Capstone | chain it all into a checked, repeatable pipeline | validate before, cross-check after, record what you did |
curl and other tools you used here in more depth; GraphQL is another way to ask for JSON; and gRPC covers Protocol Buffers in a working service (Chapter 10 compared the formats). If jq or JSON Schema deserve a course of their own, that would be a separate course.
Chapter 12 Quick Reference
- Pipeline: fetch once and record size, hash, clock and count; validate per feature with a compiled schema and a control; clean with
jq -b -cinto JSON Lines; stream, validate and report in a script; recompute key numbers with a second tool; patch with checks and write a new file only on success; run twice and compare checksums - Snapshot used: USGS
all_monthGeoJSON, 2026-10-12T19:05:41Z, 7,173,808 bytes, 10,105 features; 10,104 valid, 1 invalid (null place, a quarry blast); 9,891 earthquakes kept, 214 dropped - Report: magnitude buckets 6,898 / 1,713 / 303 / 808 / 161 / 8; 1,799 automatic and 8,092 reviewed; 5 tsunami flags; strongest M 7.7 (Panama); 58 MB peak; jq and the Node report agree
- Patch: good patch changed exactly 1 line; a failed
testand a schema-breakingaddeach wrote no file; the original hash was unchanged - Honest limits: the "(unknown)" rule never fired; the region rule gives "CA" next to "Alaska"; the licence was read second-hand; repeatability was shown on one snapshot only