Skip to main content
Learning Center
Python Programming

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
      • [1] · object
        • noticeId · "A-2"
        • estimatedValue · "155000"

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.

The root object becomes a Python dict; results becomes a list with indexes 0 and 1, each holding a dict. totalRecords is the reported count, while len(results) counts the returned items; both are 2 in this fixture. Parsing preserves the two estimatedValue types. Neither null nor an absent field tells you the business meaning without the data dictionary.

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

  1. What Python value does JSON null become, and how does that differ from an absent key?
  2. Which exception does malformed JSON raise, and which broader type can catch it?
  3. Why does record.get("a", {}).get("b", "x") survive a missing a, while record["a"].get("b", "x") does not?
  4. Name the four things a data-dictionary entry should record.
Answers
  1. It becomes None. The key exists with the value None, so get's default does not apply; an absent key means get returns the default.
  2. json.JSONDecodeError, which is a subclass of ValueError, so except ValueError: catches it.
  3. The first defaults the intermediate step to an empty dictionary, so the second lookup has something to run on. The second reads a with square brackets, which raises before any default is reached.
  4. 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.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.