Module 6: Files, JSON, CSV, and Data Quality
JSON: Nested Data and the Keys That Are Not There
Turning a JSON payload into Python objects, walking nested structures without crashing on absent branches, and writing the data dictionary that says what each field means.
Lesson 20 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can turn a JSON payload into Python objects, reach a value buried two or three levels down without crashing when a branch is missing, and write down what each field means so the next person does not have to guess.
Why it matters
JSON is how nearly all public data arrives, including the procurement notices this course's project reviews. It maps almost exactly onto the Python types from Module 1 and Module 3, so the parsing itself is one line.
The work is everything after that line. Real payloads nest, and they omit. A search response wraps its records in an envelope; a record's location is an object that some records simply do not carry; a field is a number in one record and a string in the next. None of that is malformed JSON, so the parser is perfectly happy, and your code is the first thing that notices.
Concept
json.loads(text) parses a JSON string into Python objects. json.dumps(obj) goes the other way, and json.dumps(obj, indent=2) produces something a person can read. There are also json.load(file) and json.dump(obj, file) for file handles; the names without the s take a file, the ones with it take a string.
The type mapping is direct: JSON object becomes dict, array becomes list, string becomes str, number becomes int or float, true/false become True/False, and null becomes None. Note the last one: an explicit null in the payload gives you a key whose value is None, which is not the same as the key being absent. Module 3's rule applies exactly here, get's default fires only for a missing key.
Malformed text raises json.JSONDecodeError, which is a subclass of ValueError, so except ValueError: catches it. The message includes a line and column, which is usually enough to find the problem in a payload you can see.
For nested access, chaining square brackets is brittle: record["placeOfPerformance"]["state"] raises KeyError the first time a record omits the location. When the location is either absent or an object, defaulting the intermediate step to an empty dictionary handles the absent branch:
state = record.get("placeOfPerformance", {}).get("state", "unknown")
The outer get yields an empty dictionary when the branch is absent, and the inner get then finds nothing and returns the default. An explicit null is different: it becomes None, which has no .get method. Validate or normalise a present branch of the wrong type before using this chain; a present state whose value is None also keeps that value rather than using the default.
Finally, a data dictionary. For each field the project relies on, record four things: its name in the source, what it means in one sentence, its type, and what your program does when it is absent or unusable. This is a document, not code, and it is the artefact that stops a team from arguing about whether an empty set-aside means "none" or "not yet published". The capstone requires one.
Read the code
import json
payload = """
{
"totalRecords": 2,
"results": [
{"noticeId": "A-1", "estimatedValue": 310000, "setAside": null},
{"noticeId": "A-2", "estimatedValue": "155000"}
]
}
"""
data = json.loads(payload)
results = data.get("results", [])
print(data["totalRecords"], len(results))
print(results[0].get("setAside", "absent"))
print(results[1].get("setAside", "absent"))
print(type(results[0]["estimatedValue"]).__name__, type(results[1]["estimatedValue"]).__name__)
The payload is an envelope: a count plus an array of records, which is how most search responses are shaped. Reading results with a default of [] supplies an empty list only when that key is absent from the parsed dictionary. It does not validate the envelope or replace an explicit null or a value of the wrong type; the later totalRecords read and record indexing still assume this worked payload’s structure.
The two setAside reads are the lesson in one pair of lines. The first record carries an explicit null; the second omits the key entirely. Neither state alone establishes the business meaning of the missing information. The data dictionary must define that meaning; the two states also behave differently in code.
The last line asks what type each estimated value actually arrived as. One is a number, the other is text, in the same array, in a well-formed payload. Discovering that at parse time is much cheaper than discovering it when a comparison raises three modules later.
Inspect the nested payload and missing-key comparison
Same envelope; different field states
Payload structure before Python type conversion
Each branch belongs to the object or array above it. Brackets mark zero-based array indexes; quotes mark JSON strings.
- root · object
- totalRecords ·
2 - results · array (2 items)
- [0] · object
- noticeId ·
"A-1" - estimatedValue ·
310000 - setAside ·
null
- noticeId ·
- [1] · object
- noticeId ·
"A-2" - estimatedValue ·
"155000"
- noticeId ·
- [0] · object
- totalRecords ·
results[0] · A-1
setAside key- Present in the object.
JSON value → Python- null → None
get("setAside", "absent")- Returns None; the default is not used.
estimatedValue- 310000 is a JSON number; Python receives int.
A present key can hold an empty value. That does not make it an absent key.
results[1] · A-2
setAside key- Absent: there is no setAside leaf in this object.
JSON value → Python- No value exists for this key. Nothing is converted to None here.
get("setAside", "absent")- Returns the supplied string "absent".
estimatedValue- "155000" is a JSON string; Python receives str.
The tree shows only fields that exist; absence is explained here, not drawn as a null leaf.
Predict the output
Predict all four printed lines.
Check your prediction
2 2
None
absent
int str
Line 2 is None, not absent: the key exists and its value is null, so the default never applies. Line 3 is absent, because that key is genuinely missing.
Line 4 shows the two different types. Neither record is invalid JSON, and no amount of careful parsing will make them consistent; only a normalisation step you write on purpose can.
Modify the code
Change the first record's "setAside": null to "setAside": "", and predict lines 2 and 3.
What changes, and why
Line 2 becomes an empty line, and line 3 is unchanged.
Printing an empty string produces a blank line, which in a real report is indistinguishable from a formatting glitch. There are now three distinct ways this field can say "nothing": absent, null, and empty text, and they print as absent, None, and nothing at all.
This is the argument for normalising early. One function that maps all three to a single agreed representation, and records which of the three it saw, turns an ongoing source of confusion into one decision made once. The data dictionary is where that decision is written down.
Debug the bug
An assistant was asked to extract the state from each record. It produced this and said missing locations default to "unknown".
import json
payload = '{"results": [{"noticeId": "A-1", "placeOfPerformance": {"state": "OR"}}, {"noticeId": "A-2"}]}'
data = json.loads(payload)
for record in data["results"]:
state = record["placeOfPerformance"].get("state", "unknown")
print(record["noticeId"], state)
What's actually wrong
It prints A-1 OR and then raises:
KeyError: 'placeOfPerformance'
The "unknown" default is on the inner lookup, which only helps when the location object exists but has no state. The second record has no location object at all, so the outer square-bracket read fails before the default is ever consulted.
The output shape is worth noticing too. One record processed successfully, then a crash, so a run over four hundred notices would produce a partial report with no indication that it was partial. Module 5's advice applies: either handle the case and count it, or fail before any output is produced. A half-written report is the worst of both.
The repair is the empty-dictionary idiom:
state = record.get("placeOfPerformance", {}).get("state", "unknown")
The general lesson: a default handles an absent key only at the lookup it is attached to. This repair fits the two records shown; a present branch must still have the expected object type.
Try it yourself
Parse a payload shaped like a real search response and flatten it, handling the record that omits its location entirely.
Loading this exercise…
Practical challenge (optional)
Optional: write data-dictionary entries for four fields, noticeId, title, estimatedValue, and placeOfPerformance.state. For each, record the source name, one sentence of meaning, the type you will store it as, and what your program does when it is absent, null, or empty. Keep the result; the capstone's first phase asks for exactly this document, and writing it now while the examples are in front of you is far easier than reconstructing it later.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- What Python value does JSON
nullbecome, and how does that differ from an absent key? - Which exception does malformed JSON raise, and which broader type can catch it?
- Why does
record.get("a", {}).get("b", "x")survive a missinga, whilerecord["a"].get("b", "x")does not? - Name the four things a data-dictionary entry should record.
Answers
- It becomes
None. The key exists with the valueNone, soget's default does not apply; an absent key meansgetreturns the default. json.JSONDecodeError, which is a subclass ofValueError, soexcept ValueError:catches it.- The first defaults the intermediate step to an empty dictionary, so the second lookup has something to run on. The second reads
awith square brackets, which raises before any default is reached. - The field's source name, its meaning in one sentence, the type you store it as, and what your program does when it is absent or unusable.
Sign in to track your progress on this exercise.
Summary and next step
JSON maps onto Python types directly, null and absent are different states, nested access needs an explicit policy for absent keys and present values of the wrong type, and the data dictionary is where "what does empty mean here" gets settled once. Next: CSV, the format reviewers actually want back, and the normalisation that turns a mixed batch into validated records.