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
- What JSON is and where it lives
- The four functions of the
jsonmodule:dump,load,dumps,loads - Python ↔ JSON types: the equivalence table
indentandensure_ascii=False: readable JSON with real accents- The catalog in JSON:
asdict()on the way out,Book(**data)on the way back - Nesting: the Papyrus members with their purchases
- JSON vs CSV: a decision table
config.json: the Papyrus constants leave the code- A mention of
pickle - Common mistakes and tips
- 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 includedThat 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 arestr. 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,bytesand — watch out — your own objects:json.dumps(Book("Hamlet", 9.95, 6))raisesTypeError: Object of type Book is not JSON serializable. The standard strategy is to convert to basic types first (aset→list, adate→"2026-07-13"withstr()orisoformat(), 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 withencoding="utf-8"(06-01), we can turn it off and get real accents in the file.
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 figureCompare 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:
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.05If 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 —dumpwants an open file, not a path. So doesjson.load(text)— for strings it'sloads. Mnemonic: thesis for string. - Trying to serialize objects directly:
json.dump(catalog, f)withBookvalues raisesTypeError: ... 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 alistfirst 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 astr. 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 →JSONDecodeErroron loading. Modern editors flag it; until module 7, double-check before saving. - Writing without
ensure_ascii=Falsein 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 TrueA 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 francaWriting 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, str → float 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
