NOETRION

Choose a topic

DAY 25 OF 30 · BUILD AND DEMONSTRATE

Working with JSON

Serialize ordinary data, validate the loaded shape, and reject malformed or misleading values.

90–120 minutes suggested study timePython 3.12Not yet completed

Today's outcomes

  • Distinguish JSON text from Python objects.
  • Use loads, dumps, load, and dump.
  • Check schema-like requirements after parsing.

Before you start: Complete Day 24 and its practical challenge.

JSON text → Python records → validated sessions → report
Syntax and application validation are separate boundaries.

Understand the concept

JSON is an interchange format, not a Python object

JSON represents structured data as text. A Python dictionary is an object in a running program; its JSON representation is a string that can be saved or transmitted. Parsing turns that text into Python objects. Serialization turns suitable Python objects back into text. Keep those two directions explicit, because calling dictionary methods on unparsed JSON text is a common error.

JSON has objects, arrays, strings, numbers, booleans, and null. Python usually represents these as dictionaries, lists, strings, int or float, bool, and None. JSON object keys are strings. A Python tuple serializes as an array and parses back as a list; serialization does not preserve every Python type. Sets, paths, and date objects need an explicit representation before serialization.

Double quotes delimit JSON strings. Python's single-quoted dictionary display is not automatically valid JSON. A trailing comma accepted in some Python literals is not accepted by the JSON parser. Never repair a document by replacing every quote: apostrophes inside text and escaped quotation marks make that approach unreliable.

Choose the function by the boundary

json.loads(text) parses a string. json.dumps(value) returns a string. Their names end in s because these operations work with a string boundary. json.load(file) and json.dump(value, file) work through an open file object. Use a context manager and an explicit UTF-8 encoding when writing a text file.

indent=2 makes a document easier to inspect. ensure_ascii=False allows Unicode characters to appear directly rather than as escape sequences. Both are presentation choices; neither establishes that the data meets your application rules. Python permits nonfinite float output by default through its JSON encoder. Setting allow_nan=False rejects those values during encoding when you require standard JSON numbers.

Valid syntax does not prove valid records

A parser can accept a top-level integer even when your application requires a list of session records. It can accept negative minutes or a blank topic. Separate parsing errors from shape errors, then apply field rules deliberately. This boundary is a good place to use the validation and exception handling from Days 10 and 17.

For this lesson, a session has exactly two keys: topic and minutes. A topic is nonempty text after stripping surrounding whitespace. Minutes is an actual integer from 0 through 240. Because bool is a subclass of int, isinstance(True, int) is true; use type(value) is int when Boolean minutes must be rejected. Return new dictionaries so validation does not quietly modify the caller's records.

Worked example

import json

text = '[{"topic": "functions", "minutes": 30}, {"topic": "review", "minutes": 0}]'
sessions = json.loads(text)
assert isinstance(sessions, list)
assert sessions[1]["minutes"] == 0
rendered = json.dumps(sessions, indent=2, ensure_ascii=False, allow_nan=False)
assert json.loads(rendered) == sessions
print(rendered)

try:
    json.loads('{"minutes": 30,}')
except json.JSONDecodeError as error:
    print("Invalid JSON at line", error.lineno, "column", error.colno)
else:
    raise AssertionError("A trailing comma should fail")
Boundary Input Output What it establishes
Parse JSON text Python list Syntax can be decoded
Validate Python list Clean session records Application rules hold
Serialize Clean records JSON text Supported values can be represented
Parse again Serialized text New Python objects Round-trip value agreement

The zero-minute review is deliberately present. A check such as if not minutes would incorrectly treat it as missing. Syntax errors report a location in the document, but a business-rule error should identify the record or field. Different messages help you repair the correct layer.

Guided practice

  1. Predict: serialize a tuple containing two numbers, then parse the result. Explain why the returned type is a list even though the values agree.
  2. Modify: add a topic containing an accented character and compare ensure_ascii True and False. Parse both strings and verify that the Python values are equal.
  3. Reconstruct: write a small validator for a dictionary containing a name and an enabled flag. Accept only a string and a bool, and demonstrate a valid JSON document with the wrong application shape.

Use a temporary directory when practicing file round trips. Open a path for writing, call dump, close it, reopen for reading, and call load. Do not call loads with a Path object or assume a successful write means the next process can interpret the records.

Daily challenge

Implement decode_sessions(text) and encode_sessions(records). Both must apply the same validation rules. Accept an empty list. Reject extra keys, missing keys, a non-list root, a non-dictionary record, blank topics, Boolean minutes, and minutes outside the range. Keep record order. Do not mutate input records. Use ValueError for this exercise's invalid documents or data, including malformed JSON.

Case Expected result
Empty array Empty list
Topic with surrounding spaces Cleaned topic
Minutes 0 and 240 Accepted
Minutes true, -1, or 241 ValueError
Object instead of array ValueError
Trailing comma ValueError with syntax context

Try the challenge before opening the reference. Test serialization as well as parsing; otherwise bad records can enter a saved file through the reverse boundary.

Reference solution

Compare after attempting the challenge
import json

def validate_sessions(records):
    if not isinstance(records, list):
        raise ValueError("sessions must be an array")
    clean = []
    for index, record in enumerate(records):
        if not isinstance(record, dict) or set(record) != {"topic", "minutes"}:
            raise ValueError(f"record {index}: expected topic and minutes")
        topic = record["topic"]
        minutes = record["minutes"]
        if not isinstance(topic, str) or not topic.strip():
            raise ValueError(f"record {index}: topic must be nonempty text")
        if type(minutes) is not int or not 0 <= minutes <= 240:
            raise ValueError(f"record {index}: minutes must be an integer from 0 to 240")
        clean.append({"topic": topic.strip(), "minutes": minutes})
    return clean

def decode_sessions(text):
    try:
        records = json.loads(text)
    except json.JSONDecodeError as error:
        raise ValueError(f"invalid JSON at line {error.lineno}, column {error.colno}") from error
    return validate_sessions(records)

def encode_sessions(records):
    return json.dumps(validate_sessions(records), indent=2,
                      ensure_ascii=False, allow_nan=False)

original = [{"topic": " functions ", "minutes": 30}, {"topic": "review", "minutes": 0}]
clean = decode_sessions(encode_sessions(original))
assert clean == [{"topic": "functions", "minutes": 30}, {"topic": "review", "minutes": 0}]
assert original[0]["topic"] == " functions "
assert decode_sessions("[]") == []
for bad in ['{}', '[{"topic":"x","minutes":true}]',
            '[{"topic":" ","minutes":30}]', '[{"topic":"x","minutes":241}]',
            '[{"topic":"x","minutes":0,"extra":1}]', '[1]', '[{"topic":"x"}]', '{bad']:
    try:
        decode_sessions(bad)
    except ValueError:
        pass
    else:
        raise AssertionError("Invalid case was accepted")
print("JSON syntax, shape, fields, and round trip passed")

Validation precedes encoding, so the same field policy protects both boundaries. The original data remains available for comparison. Strictly rejecting unknown fields is this exercise's policy; another application could allow extensions, but it should state that choice and test it.

Debug and explain

If a decoded value has no expected method, inspect its type and the document's top-level shape. If a valid record disappears, look for truthiness checks that conflate zero with absence. If a file fails after manual editing, inspect the reported JSON location before changing the schema. Explain why a round-trip test alone cannot prove semantic validity: an invalid negative duration can survive a perfectly successful round trip.

Review and next step

Save your accepted and rejected fixtures with the two boundary functions. You now have a repeatable approach for structured text: parse, validate, transform, and serialize. Day 26 uses a different parser for HTML and keeps missing source fields visible in an extraction audit.

Sources and verification

Original teaching examples checked with Python 3.12.14. Library behavior reviewed against these primary references on October 3, 2026. Day 26's examples also use Beautiful Soup 4.14.3.

DAILY CHECKPOINT

Check your understanding

Five questions, 20 points each. Pass at 85% or above, which requires all five correct. Feedback explains each answer; revise the lesson and retry when needed.

01. What does json.loads accept in this lesson?

02. Does syntactically valid JSON prove a valid session record?

03. What does a serialized tuple parse back into?

04. Why use type(minutes) is int under this contract?

05. What does allow_nan=False do during encoding?

Review your practical work

Check these statements after running your own challenge. This is your self-review; the website does not execute or assess your Python code.

Pass the knowledge check and finish the practical self-review to save completion.