CSV left Papyrus speaking the language of tables, but with a ceiling in plain sight: tables are flat. The shop's members are not — Luis (LUIS-001) has a name, a code and a list of purchases, each with its date and its amount. Squeezing a list into a CSV cell is forcing the format; for nested structures there is JSON (JavaScript Object Notation), the text format in which application settings and the responses of nearly every web API are written today. In this lesson you'll learn the four functions of the json module, the equivalence table between Python and JSON types (and what gets left out), and you'll push the Papyrus storyline two steps further: the catalog of dataclasses will travel to JSON via asdict() and come back via Book(**data), and the shop will get its very own config.json.

Contents

  1. What JSON is and where it lives
  2. The four functions of the json module: dump, load, dumps, loads
  3. Python ↔ JSON types: the equivalence table
  4. indent and ensure_ascii=False: readable JSON with real accents
  5. The catalog in JSON: asdict() on the way out, Book(**data) on the way back
  6. Nesting: the Papyrus members with their purchases
  7. JSON vs CSV: a decision table
  8. config.json: the Papyrus constants leave the code
  9. A mention of pickle
  10. Common mistakes and tips
  11. Exercises with solutions

What JSON is and where it lives

JSON is a plain text format for representing structured data. Its syntax will look suspiciously familiar:

{
    "title": "The Odyssey",
    "price": 12.5,
    "stock": 4,
    "available": true,
    "tags": ["classics", "epic poetry"]
}

It looks like a Python dictionary with a list inside — and it almost is (the differences are in the equivalence table). It was born in the JavaScript world, but today it's neutral and everywhere. You'll find it in three habitats:

  • Web APIs: when your program requests data from a server in module 10 (or serves it with Flask), the responses will be JSON.
  • Configuration files: application preferences, editor settings (VS Code's settings.json, to name one).
  • Exchange and storage of structured data: exactly what Papyrus needs for its catalog and members.

Unlike CSV, JSON nests without limit (objects inside lists inside objects) and preserves the basic types: a 12.5 comes back as a number, not as the string "12.5". The conversion toll we paid in 06-01 and 06-02 all but disappears.

The four functions of the json module: dump, load, dumps, loads

The whole lesson revolves around four standard-library functions. The mnemonic trick: the trailing s stands for string — the s versions work with strings in memory; the versions without it, with open files.

Function Direction Works with Typical use
json.dump(data, f) Python → JSON File open for writing Saving the catalog to catalog.json
json.load(f) JSON → Python File open for reading Loading config.json on startup
json.dumps(data) Python → JSON Returns a string Sending data to an API, printing to debug
json.loads(string) JSON → Python Receives a string Interpreting an API response

All four in action:

import json

book = {"title": "Hamlet", "price": 9.95, "stock": 6}

# dumps/loads: a string in memory (note: no disk involved)
text = json.dumps(book)
print(text)                        # {"title": "Hamlet", "price": 9.95, "stock": 6}
print(type(text))                  # <class 'str'>
copy = json.loads(text)
print(copy["price"] + 1)           # 10.95 — it comes back as a float, not as "9.95"!

# dump/load: straight to a file — the 06-01 rules still apply (with + utf-8)
with open("book.json", "w", encoding="utf-8") as f:
    json.dump(book, f)

with open("book.json", "r", encoding="utf-8") as f:
    recovered = json.load(f)
print(recovered == book)           # True — perfect round trip, types included

That copy["price"] + 1 marks the difference from everything before: in plain text and CSV, numbers came back as strings and you converted by hand; JSON remembers that 9.95 was a number. (The newline="" from 06-02 was a quirk of the csv module; with json it isn't used.)

Python ↔ JSON types: the equivalence table

The translation between the two worlds is defined type by type:

Python (when writing) JSON Python (when reading back)
dict object {...} dict
list, tuple array [...] list (the tuple does NOT come back as a tuple!)
str string "..." str
int number int
float number float
True / False true / false True / False
None null None

And the fine print of the contract, worth reading:

  • The keys of a JSON object are always strings. A Python dict with integer keys ({1: "a"}) is written as {"1": "a"} and on the way back the keys are str. If you use non-string keys, the round trip isn't faithful.
  • Tuples degrade to lists: ("a", "b")["a", "b"]["a", "b"]. JSON has no tuples.
  • What does NOT serialize: set, datetime/date, bytes and — watch out — your own objects: json.dumps(Book("Hamlet", 9.95, 6)) raises TypeError: Object of type Book is not JSON serializable. The standard strategy is to convert to basic types first (a setlist, a date"2026-07-13" with str() or isoformat(), and a dataclass → dict, as you'll see two sections from now).

That last limitation is not a defect: JSON is deliberately small so that any language can read it. Your objects belong to Python; their data is universal.

indent and ensure_ascii=False: readable JSON with real accents

By default, dump/dumps write everything on one line and escape non-ASCII characters. The result works, but it's unreadable for humans:

config = {"store_name": "Papyrus", "featured_author": "García Márquez"}
print(json.dumps(config))
# {"store_name": "Papyrus", "featured_author": "García Márquez"}   ← where did my accents go?

Two arguments fix it:

  • indent=4: formats with line breaks and 4-space indentation — indispensable in files a person will read or edit (configurations).
  • ensure_ascii=False: writes í instead of í. The escaping is an archaic protection for systems that don't understand UTF-8; since we always open with encoding="utf-8" (06-01), we can turn it off and get real accents in the file.
print(json.dumps(config, indent=4, ensure_ascii=False))
{
    "store_name": "Papyrus",
    "featured_author": "García Márquez"
}

Course rule of thumb: in JSON files meant for humans, indent=4, ensure_ascii=False always. In JSON that travels between programs (APIs), the compact version saves bytes and nobody reads it anyway.

The catalog in JSON: asdict() on the way out, Book(**data) on the way back

The obstacle — "your objects don't serialize" — has an elegant solution for dataclasses, and it ships with the library itself: dataclasses.asdict() converts a dataclass (recursively, nested fields included) into a dictionary of basic types, which JSON does understand:

from dataclasses import asdict

odyssey = Book("The Odyssey", 12.50, 4)   # the canonical dataclass from 05-06
print(asdict(odyssey))                    # {'title': 'The Odyssey', 'price': 12.5, 'stock': 4}

And for the return trip, dictionary unpacking with ** (03-02): Book(**{"title": "The Odyssey", "price": 12.5, "stock": 4}) is equivalent to Book(title="The Odyssey", price=12.5, stock=4). Since the dataclass fields have the same names as the dict keys, they snap together on their own. The catalog's full round trip looks like this:

import json
from pathlib import Path
from dataclasses import asdict

def save_catalog_json(catalog, path="catalog.json"):
    """dict[str, Book] → list of dicts → JSON."""
    data = [asdict(book) for book in catalog.values()]
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=4, ensure_ascii=False)

def load_catalog_json(path="catalog.json"):
    """JSON → list of dicts → dict[str, Book]. No str→float conversions: JSON remembers."""
    if not Path(path).exists():          # the usual stopgap; module 7 to the rescue soon
        return {}
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)
    return {normalize_title(d["title"]): Book(**d) for d in data}

save_catalog_json(catalog)
reloaded = load_catalog_json()
print(reloaded == catalog)                          # True
print(reloaded["hamlet"].final_price(member=True))  # 9.83 — the usual figure

Compare load_catalog_json with load_catalog from 06-02: float(row["price"]) and int(row["stock"]) have vanished. JSON returned every type intact and Book(**d) rebuilt the object in one expression — with __post_init__ validating as a bonus, same as always. The resulting catalog.json is a list of objects:

[
    {
        "title": "The Odyssey",
        "price": 12.5,
        "stock": 4
    },
    {
        "title": "Hamlet",
        "price": 9.95,
        "stock": 6
    }
]

(Side note: we save catalog.values() as a list and rebuild the normalized keys on loading, just as in 06-02 — the key is derivable, there's no need to store it.)

Nesting: the Papyrus members with their purchases

Here JSON plays in a league CSV can't reach. Each member is an object with a list of purchases inside, and each purchase is in turn an object — the structure mirrors the SaleLine objects from 05-06:

members = [
    {
        "name": "Luis", "code": "LUIS-001",
        "purchases": [
            {"date": "2026-07-13", "title": "Hamlet", "amount": 9.83},
            {"date": "2026-07-11", "title": "The Odyssey", "amount": 12.35},
        ],
    },
    {
        "name": "Marta", "code": "MARTA-002",
        "purchases": [
            {"date": "2026-07-12", "title": "Don Quixote", "amount": 15.71},
        ],
    },
    {"name": "Pau", "code": "PAU-003", "purchases": []},
]

with open("members.json", "w", encoding="utf-8") as f:
    json.dump(members, f, indent=4, ensure_ascii=False)

And when loading, you navigate the structure with the syntax you already know — chained indexes and keys (04-01/04-03), or better, loops and comprehensions:

with open("members.json", "r", encoding="utf-8") as f:
    members = json.load(f)

print(members[0]["purchases"][1]["title"])     # The Odyssey

spend_by_member = {m["code"]: round(sum(p["amount"] for p in m["purchases"]), 2)
                   for m in members}
print(spend_by_member)    # {'LUIS-001': 22.18, 'MARTA-002': 15.71, 'PAU-003': 0}

Try to picture this data in CSV: either one row per purchase repeating the member's details, or lists crammed into a cell. JSON expresses it exactly the way you think it.

JSON vs CSV: a decision table

Two lessons, two text formats. The criteria for choosing:

CSV (06-02) JSON (this lesson)
Shape of the data Flat table: rows × columns Tree: free nesting
Types Everything comes back as str Preserves number/boolean/null
Excel/Sheets open it Yes, directly No (it's for programs)
Size with many rows Compact (header once) Verbose (keys repeated per object)
Natural habitat Exports, spreadsheets, bulk row data APIs, configurations, nested structures
In Papyrus sales.csv (tabular history Ana opens in Excel) config.json, members.json (structure and types)

Quick rule: is the data a table a human will want to open in a spreadsheet? CSV. Does it have structure, nesting or types that matter? JSON. The Papyrus catalog, being tabular, lives comfortably in both — we've saved it in the two formats precisely so you can compare the code.

config.json: the Papyrus constants leave the code

Since module 1, STORE_NAME, BOOK_VAT and MEMBER_DISCOUNT have lived hard-coded in the source. It works, but changing the member discount means editing a .py file. The professional move is to externalize the configuration to a file a non-programmer can touch — and JSON with indent is ideal:

{
    "store_name": "Papyrus",
    "book_vat": 0.04,
    "member_discount": 0.05
}
import json

def load_config(path="config.json"):
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)

config = load_config()
STORE_NAME = config["store_name"]           # "Papyrus"
BOOK_VAT = config["book_vat"]               # 0.04 — a real float, ready to multiply
MEMBER_DISCOUNT = config["member_discount"] # 0.05

If the booksellers' association agrees a different discount tomorrow, Ana edits config.json in Notepad and no .py file changes. Notice that the types arrive correct with no conversion: 0.04 is a float because JSON remembers. (And if config.json doesn't exist, or someone breaks it while editing? json.load raises JSONDecodeError — another customer waiting at module 7's door.)

A mention of pickle

The standard library includes another persistence route: pickle, which serializes Python objects as they are — sets, dates, your classes, almost anything — into a binary format (files in "wb"/"rb" modes, the ones 06-01 left noted):

import pickle

with open("catalog.pkl", "wb") as f:       # binary: no encoding
    pickle.dump(catalog, f)                # the whole dict[str, Book], objects included

with open("catalog.pkl", "rb") as f:
    reloaded = pickle.load(f)              # Book objects come back, no asdict or **

Tempting, but it has three pieces of fine print that relegate it to occasional use: it's Python-only (no other language, and certainly not Excel, will read it), it's unreadable (you can't open it in an editor to inspect it — the great advantage of text we've been exploiting), and it's unsafe with data from others — unpickling a file from an untrusted source can execute arbitrary code; the official documentation itself warns about this. Course criterion: for saving your own temporary state between your own runs, fine; for exchanging, configuring or archiving, text (CSV/JSON) always. Papyrus sticks with JSON.

Common Mistakes and Tips

  • Mixing up the four functions: json.dump(data, "catalog.json") fails — dump wants an open file, not a path. So does json.load(text) — for strings it's loads. Mnemonic: the s is for string.
  • Trying to serialize objects directly: json.dump(catalog, f) with Book values raises TypeError: ... not JSON serializable. asdict() first (or build dicts/lists by hand).
  • Forgetting that tuples and sets don't survive: the tuple comes back as a list and the set doesn't even get written (TypeError). Convert the set to a list first and convert it back on loading if deduplication matters to you.
  • Non-string keys: {2026: "year"} is saved as {"2026": "year"} and on the way back the key is a str. If the key is a number that matters, store it as a value.
  • Editing JSON by hand and breaking it: a trailing comma after the last element ("stock": 4,}) or single quotes are invalid JSON → JSONDecodeError on loading. Modern editors flag it; until module 7, double-check before saving.
  • Writing without ensure_ascii=False in files people will read: accents turn into í and the file looks corrupted without being so.
  • Tip: to inspect a compact JSON received from outside, print(json.dumps(data, indent=4, ensure_ascii=False)) "beautifies" it instantly. It's the standard debugging pretty-print.

Exercises

Exercise 1: the shop's complete state

Write save_state(catalog, path="state.json") that saves in a single JSON an object with three keys: "store" (the name, from the constant), "saved" (today's date as the string str(date.today()) — remember date doesn't serialize) and "catalog" (the list of asdict() results). Also write load_state(path) that returns the tuple (date, dict[str, Book]). Verify the round trip with the four canonical books.

Exercise 2: the purchase goes on the record

Write record_member_purchase(code, purchase, path="members.json") that loads the members JSON, finds the member by their "code" field, appends the purchase dictionary to their "purchases" list and rewrites the whole file (load → modify → save pattern). Record for Pau (PAU-003) the purchase {"date": "2026-07-13", "title": "Faust", "amount": 20.75} — copies finally arrived — and verify by re-reading the file that his list is no longer empty. If the code doesn't exist, print a warning and don't touch the file.

Exercise 3: format judgment

For each piece of Papyrus data, decide CSV or JSON and justify it in a one-line comment: (a) the ten-year sales history Ana filters in Excel every quarter; (b) the till application's preferences (name, VAT, discount, print receipt?); (c) the members with their purchase lists; (d) the list of new releases the distributor imports into their system, "in whatever format, as long as everything can open it". Then write member Luis's record (with two purchases) in both formats and observe which one forces the structure.

Solutions

Solution 1:

import json
from datetime import date
from dataclasses import asdict
from pathlib import Path

def save_state(catalog, path="state.json"):
    state = {
        "store": STORE_NAME,
        "saved": str(date.today()),               # date → str: the non-serializable toll
        "catalog": [asdict(b) for b in catalog.values()],
    }
    with open(path, "w", encoding="utf-8") as f:
        json.dump(state, f, indent=4, ensure_ascii=False)

def load_state(path="state.json"):
    if not Path(path).exists():
        return None, {}
    with open(path, "r", encoding="utf-8") as f:
        state = json.load(f)
    catalog = {normalize_title(d["title"]): Book(**d) for d in state["catalog"]}
    return state["saved"], catalog

save_state(catalog)
saved, reloaded = load_state()
print(saved, reloaded == catalog)      # 2026-07-13 True

A single file with metadata + data: the embryo of the "save game" of any application.

Solution 2:

import json

def record_member_purchase(code, purchase, path="members.json"):
    with open(path, "r", encoding="utf-8") as f:
        members = json.load(f)

    for member in members:
        if member["code"] == code:
            member["purchases"].append(purchase)  # modify in memory...
            break
    else:                                         # the for/else from 02-02: not found
        print(f"WARNING: no member with code {code}")
        return

    with open(path, "w", encoding="utf-8") as f:  # ...and rewrite the whole thing
        json.dump(members, f, indent=4, ensure_ascii=False)

record_member_purchase("PAU-003", {"date": "2026-07-13", "title": "Faust", "amount": 20.75})

JSON can't be "appended to" like the sales CSV: the file is one complete structure, so the pattern is always load → modify → dump. (20.75 is the canonical figure: Faust's member price.)

Solution 3:

# (a) CSV   — tabular, bulky and Excel-bound: its exact habitat
# (b) JSON  — mixed types (str, float, bool) and configuration structure
# (c) JSON  — lists nested inside each member; CSV would force it
# (d) CSV   — "everything can open it" is the definition of lingua franca

Writing Luis's record in both: in JSON the purchases nest naturally; in CSV you end up repeating Luis,LUIS-001 on every purchase row or inventing a sub-format inside a cell — the unmistakable sign that the format doesn't fit.

Conclusion

JSON completes Papyrus's persistence arsenal: dump/load for files and dumps/loads for strings, types that survive the journey (goodbye, strfloat toll), indent=4, ensure_ascii=False for human-readable files, asdict() + Book(**data) as the idiomatic bridge between dataclasses and disk, nesting for the members with their purchases, and a config.json that takes the constants out of the code. CSV for tables headed to Excel, JSON for structure and configuration, pickle only for your own state and with caution. But look at what's piling up: additions_log.txt, catalog.csv, sales.csv, catalog.json, members.json, config.json… half a dozen loose files in the same folder as the code. A tidy shop doesn't stack its boxes next to the till: we need folders (data/, backups/, reports/), paths that work on any machine, and nightly backups stamped with the date. Organizing all of that — with pathlib in the lead role and the os module that module 3 left promised — is the job of the module's final lesson.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved