Reading, writing, and using structured data — converting between JSON text and Python objects, handling nested records, and building programs that persist information across sessions
Chapter 30 guided you through building and documenting a complete Python project. This chapter extends your practical skills by introducing one of the most widely used data formats in modern programming: JSON. You already know how to read and write plain text files and CSV table data. JSON gives you a third option — one that handles structured, nested, real-world data far more naturally than a flat table can. By the end of this chapter you will be able to read JSON files, write JSON files, convert between Python data structures and JSON text, and design small programs that store and retrieve structured records.
Every program you have written so far stores data in one of three places: in variables while the program runs, in plain text files, or in CSV files. Variables disappear when the program stops. Plain text files store unstructured prose. CSV files store flat tables. None of these is ideal when your data has layers — a student record with a name, a list of courses, and a nested address dictionary. JSON solves exactly that problem.
JSON is a standard, human-readable format that maps so naturally onto Python's own data types that the translation between them is nearly invisible. It is used everywhere: web APIs, configuration files, database exports, app preferences, and structured logging. Learning to work with JSON is a practical skill you will use in almost every serious Python project.
By the end of this chapter, you should be able to:
json module and identify the four main functions it provides.json.loads() to parse a JSON string into a Python object.json.dumps() to convert a Python object to a JSON string, with and without the indent parameter.json.load() to read JSON data from a file.json.dump() to write Python data to a JSON file.json.JSONDecodeError and FileNotFoundError in programs that work with JSON files.Every program you have written so far stores data in one of three places: in variables while the program runs, in plain text files, or in CSV files. Variables disappear when the program stops. Plain text files store unstructured prose. CSV files store flat tables with rows and columns. None of these is ideal when you want to save something like this:
JSON solves this problem. JSON stands for JavaScript Object Notation. Despite the name, JSON has nothing to do with JavaScript for your purposes. It is simply a standard text format for representing structured data — strings, numbers, lists, dictionaries, booleans, and null values — in a way that both humans and programs can read.
JSON looks like this:
{
"name": "Adriana Sousa",
"student_id": "MGU-2041",
"gpa": 3.7,
"enrolled": true,
"courses": ["CS101", "CS201", "MATH150"],
"address": {
"city": "Lakeview",
"state": "OR",
"zip": "97401"
}
}
If you have worked through Chapter 19 on nested data structures, this should look familiar. It resembles a Python dictionary containing strings, a number, a boolean, a list, and another dictionary. That resemblance is not accidental. Python's json module converts between JSON text and Python objects so smoothly that the translation is nearly invisible.
Learning to work with JSON means your Python programs can communicate with the real world. Nearly every web API and most modern data pipelines speak JSON. The four functions you will learn in this chapter — json.loads(), json.dumps(), json.load(), and json.dump() — are among the most practically useful things in the standard library.
JSON defines a small set of value types. Each one maps cleanly to a Python type. Understanding this mapping is essential because json.loads() and json.load() automatically convert JSON values into their Python equivalents, and json.dumps() and json.dump() convert back.
| JSON Type | JSON Example | Python Equivalent | Notes |
|---|---|---|---|
| String | "hello" | str | JSON strings always use double quotes — single quotes are not valid JSON. |
| Number | 42 or 3.14 | int or float | JSON has one number type; Python distinguishes int and float by the presence of a decimal point. |
| Boolean | true / false | True / False | JSON booleans are lowercase; Python's are capitalized. The module handles this automatically. |
| Null | null | None | Both mean "absence of a value." Converted automatically in both directions. |
| Array | [1, 2, 3] | list | Ordered, can hold mixed types, uses square brackets — just like Python lists. |
| Object | {"key": "val"} | dict | Key-value pairs. JSON requires all keys to be strings. Python dictionaries allow other key types, but only string keys survive the JSON round-trip. |
Two differences trip up beginners constantly. First, JSON requires double quotes around strings — including dictionary keys. Writing {'key': 'value'} looks like valid Python but is not valid JSON. Second, JSON booleans are lowercase: true and false, not True and False. The json module handles the conversion automatically, but when you write JSON by hand (in a file or a test string), you must use the JSON spelling.
When you load JSON into Python, you get back perfectly ordinary Python objects. A JSON array becomes a Python list you can index, slice, and iterate. A JSON object becomes a Python dictionary you can look up with square brackets or .get(). No special JSON-specific methods are needed after loading.
Python's standard library includes the json module. You do not need to install anything. You just import it at the top of your program.
import json
The json module provides four main functions. Learning to distinguish them is the most important step in this chapter.
| Function | Input | Output | What It Does |
|---|---|---|---|
json.loads(text) | A string | A Python object | Parses a JSON-formatted string into Python data. |
json.dumps(obj) | A Python object | A string | Converts a Python object into a JSON-formatted string. |
json.load(file) | An open file object | A Python object | Reads JSON from a file and returns Python data. |
json.dump(obj, file) | A Python object + file | Nothing (writes to file) | Writes a Python object to a file in JSON format. |
The versions ending in s — json.loads() and json.dumps() — work with strings. The versions without the s — json.load() and json.dump() — work with file objects. The s stands for "string." Once you see this pattern, you will never mix them up.
The function json.loads() takes a string containing JSON text and returns the corresponding Python object. The name is read as "JSON load string."
import json
text = '{"name": "Theo", "score": 95, "passed": true}'
result = json.loads(text)
print(result)
print(type(result))
print(result["name"])
print(result["score"])
print(result["passed"])
Notice what happened: the JSON string became a Python dictionary, JSON's true became Python's True, and you can use the result exactly like any other dictionary.
import json
text = '[10, 20, 30, 40, 50]'
result = json.loads(text)
print(result)
print(type(result))
print(result[2])
import json
text = '''
{
"title": "Python Basics",
"chapters": [1, 2, 3, 4],
"author": {
"name": "M. Gale",
"email": "[email protected]"
}
}
'''
data = json.loads(text)
print(data["title"])
print(data["chapters"])
print(data["author"]["name"])
Triple-quoted strings let you write multi-line JSON text directly in your code, which is useful for testing before you connect a real file. When you are ready to move to actual files, you will switch to json.load() — the logic for accessing the returned data stays the same.
The function json.dumps() takes a Python object and returns a JSON-formatted string. The name is read as "JSON dump string." This is the reverse of json.loads().
import json
student = {
"name": "Priya Nair",
"gpa": 3.85,
"enrolled": True,
"courses": ["CS101", "MATH150"]
}
text = json.dumps(student)
print(text)
print(type(text))
Notice that Python's True became JSON's true. The output is compact — everything on one line. For human-readable output, use the indent parameter.
text = json.dumps(student, indent=2)
print(text)
| Argument | Example | Effect |
|---|---|---|
indent=2 | json.dumps(data, indent=2) | Adds line breaks and two-space indentation for readability. |
indent=4 | json.dumps(data, indent=4) | Same as above with four-space indentation. |
sort_keys=True | json.dumps(data, sort_keys=True) | Sorts dictionary keys alphabetically, making output predictable. |
text = json.dumps(student, indent=4, sort_keys=True)
print(text)
Use indent=2 or indent=4 when writing files that humans might open and read — configuration files, saved records, debug output. Skip the indent when writing files that only programs will read. Compact JSON is slightly smaller and faster to parse.
Most real JSON data lives in files, not strings typed by hand. The function json.load() reads JSON directly from an open file object. It is the file-based counterpart of json.loads().
Suppose you have a file called config.json with this content:
{
"app_name": "StudyTracker",
"version": "1.0",
"debug": false,
"max_students": 100
}
You read it like this:
import json
with open("config.json", "r") as f:
config = json.load(f)
print(config["app_name"])
print(config["version"])
print(config["debug"])
print(config["max_students"])
1. Open the file with open() using "r" mode (read).
2. Call json.load(f) to parse the entire file contents into a Python object.
3. The with block closes the file automatically.
4. Use the result like any Python dictionary or list — no special JSON methods needed.
Suppose students.json contains a JSON array at the top level:
[
{"name": "Ada", "score": 88},
{"name": "Ren", "score": 74},
{"name": "Paz", "score": 95}
]
import json
with open("students.json", "r") as f:
students = json.load(f)
for student in students:
print(student["name"], student["score"])
JSON arrays at the top level become Python lists. You iterate over them exactly as you would any list of dictionaries — skills you already have from Chapter 19.
The function json.dump() writes a Python object to an open file as JSON text. It is the file-based counterpart of json.dumps().
import json
inventory = [
{"item": "pencil", "quantity": 120, "price": 0.25},
{"item": "notebook", "quantity": 45, "price": 2.99},
{"item": "ruler", "quantity": 30, "price": 1.50}
]
with open("inventory.json", "w") as f:
json.dump(inventory, f, indent=2)
print("Inventory saved.")
After running this program, inventory.json will contain:
[
{
"item": "pencil",
"quantity": 120,
"price": 0.25
},
{
"item": "notebook",
"quantity": 45,
"price": 2.99
},
{
"item": "ruler",
"quantity": 30,
"price": 1.5
}
]
1. Open the file with open() using "w" mode (write). This creates the file if it does not exist and overwrites it if it does.
2. Call json.dump(data, f, indent=2) to write the data.
3. The with block closes the file.
Important: "w" mode always overwrites the entire file. This is the right behavior when saving program state — you want the file to contain the current complete state, not old data plus new data appended together.
Do not open a JSON file in "a" (append) mode to add records. A JSON file is one complete structure from start to finish. Appending to it would break the JSON format and make the file unparseable. Always overwrite the whole file with the updated data.
Many programs follow a cycle: read data from a file, modify it in memory, write it back. JSON files fit this pattern well. Here is a complete example — a program that manages a simple task list stored in tasks.json.
import json
import os
FILENAME = "tasks.json"
def load_tasks():
if os.path.exists(FILENAME):
with open(FILENAME, "r") as f:
return json.load(f)
return []
def save_tasks(tasks):
with open(FILENAME, "w") as f:
json.dump(tasks, f, indent=2)
def add_task(tasks, description):
task = {
"description": description,
"done": False
}
tasks.append(task)
def mark_done(tasks, index):
if 0 <= index < len(tasks):
tasks[index]["done"] = True
else:
print("Invalid task number.")
def show_tasks(tasks):
if not tasks:
print("No tasks.")
return
for i, task in enumerate(tasks):
status = "DONE" if task["done"] else "TODO"
print(f" [{i}] {status} {task['description']}")
tasks = load_tasks()
add_task(tasks, "Read Chapter 31")
add_task(tasks, "Complete the exercises")
add_task(tasks, "Review key terms")
mark_done(tasks, 0)
show_tasks(tasks)
save_tasks(tasks)
print("Tasks saved.")
| Function | Design decision and why |
|---|---|
load_tasks() | Checks whether the file exists before trying to read it. If not, returns an empty list. This prevents a crash on first run — a critical safety measure. |
save_tasks() | Always overwrites the file with the complete current state. No partial writes, no appending. |
add_task() | Each task is a dictionary with a "description" and a "done" boolean. This is the JSON-friendly record shape. |
mark_done() | Validates the index before modifying, preventing an IndexError. |
| Main code | Create tasks, modify one, display all, save. The separation into small functions makes the program easy to test and extend. |
This pattern — load from JSON, work in memory, save back to JSON — is used in real applications: note-taking tools, local game saves, configuration managers, and small personal databases. Once you know it, you can build a large family of useful programs without needing a database system.
JSON handles nested structures naturally. You already know how to work with nested Python dictionaries and lists from Chapter 19. Those skills transfer directly — the only new thing is that the data may arrive from a JSON file rather than being written by hand in your source code.
import json
student = {
"name": "Leila Okonkwo",
"id": "MGU-3017",
"gpa": 3.6,
"courses": [
{"code": "CS201", "grade": "A"},
{"code": "MATH201", "grade": "B+"},
{"code": "PHYS101", "grade": "A-"}
],
"contact": {
"email": "[email protected]",
"phone": "555-0192"
}
}
# Access nested data
print(student["name"])
print(student["contact"]["email"])
print(student["courses"][0]["code"])
for course in student["courses"]:
print(f" {course['code']}: {course['grade']}")
Saving and loading nested data works exactly the same as simpler data:
with open("student.json", "w") as f:
json.dump(student, f, indent=2)
with open("student.json", "r") as f:
loaded = json.load(f)
print(loaded["name"])
print(loaded["courses"][1]["grade"])
The round-trip — json.dump() then json.load() — preserves every value, every type, and every level of nesting. A list stays a list. A dictionary stays a dictionary. A boolean stays a boolean (though it travels as true/false inside the file and returns as True/False in Python). None travels as null and returns as None. You do not need to do any type conversion manually.
Two main things can go wrong with JSON. A robust program handles both.
If a file or string contains malformed JSON — a missing quote, a trailing comma, a single-quoted key — json.loads() or json.load() raises a json.JSONDecodeError.
import json
bad_text = "{'name': 'Ali'}" # Single quotes — not valid JSON
try:
data = json.loads(bad_text)
except json.JSONDecodeError as e:
print("JSON error:", e)
The error message tells you exactly where the problem is. Single-quoted keys are the most common hand-written JSON mistake.
If you call json.load() on a file that does not exist, you get a FileNotFoundError — the same error you learned in Chapter 21.
import json
try:
with open("missing.json", "r") as f:
data = json.load(f)
except FileNotFoundError:
print("File not found. Starting with empty data.")
data = {}
A robust program handles both errors. Here is a safe loading function you can reuse across projects:
import json
import os
def load_json_file(filename, default=None):
"""
Load JSON from a file. Return default if the file does not exist
or contains invalid JSON.
"""
if default is None:
default = {}
if not os.path.exists(filename):
return default
try:
with open(filename, "r") as f:
return json.load(f)
except json.JSONDecodeError as e:
print(f"Warning: Could not read {filename}: {e}")
return default
Use it like this:
config = load_json_file("config.json", default={"debug": False})
students = load_json_file("students.json", default=[])
If either file is missing or broken, the program continues with safe default values instead of crashing. Providing a sensible default is always better than letting an uncaught exception stop the program.
You now know three formats for storing data in files: plain text, CSV, and JSON. The right choice depends on the shape of your data, not personal preference.
| Feature | Plain Text | CSV | JSON |
|---|---|---|---|
| Flat table data | Poor | Excellent | Good |
| Nested records | Poor | Poor | Excellent |
| Human readable | Good | Good | Good |
| Lists inside records | No | No | Yes |
| Mixed field types | No | Manual | Yes |
| Open in a spreadsheet | No | Yes | No |
| Standard format | No | Yes | Yes |
| Boolean values | No | No | Yes |
| Null / missing values | No | Workaround | Yes |
Use plain text for unstructured prose — logs, notes, reports, long written content.
Use CSV when your data is a flat table — every record has the same fields, all values are simple strings or numbers, and you might open the file in a spreadsheet.
Use JSON when your records have layers — a list of grades inside a student, an address dictionary inside a contact, optional fields, or boolean flags. JSON is also the right choice when you need to save program state and reload it later.
Most JSON programs in Python fall into one of three patterns. Recognizing the pattern makes the program easier to plan and write.
Many programs read a JSON configuration file at startup rather than hardcoding settings in the source code. This lets you change settings without editing Python.
import json
def load_config(filename="config.json"):
with open(filename, "r") as f:
return json.load(f)
config = load_config()
app_name = config.get("app_name", "MyApp")
debug = config.get("debug", False)
max_items = config.get("max_items", 50)
Using .get(key, default) instead of [key] means the program keeps working even if the configuration file is missing some keys. This is especially important for optional settings that might not have been written yet.
import json
import os
STATE_FILE = "state.json"
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r") as f:
return json.load(f)
return {"high_score": 0, "games_played": 0}
def save_state(state):
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
state = load_state()
state["games_played"] += 1
state["high_score"] = max(state["high_score"], 88)
save_state(state)
print("Games played:", state["games_played"])
print("High score:", state["high_score"])
Each time the program runs, it reads the saved state, updates it, and writes it back. The state persists across sessions without any database or external tool.
import json
def add_record(records, new_record):
records.append(new_record)
def find_by_field(records, field, value):
return [r for r in records if r.get(field) == value]
def save_records(records, filename):
with open(filename, "w") as f:
json.dump(records, f, indent=2)
def load_records(filename):
try:
with open(filename, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
contacts = load_records("contacts.json")
add_record(contacts, {"name": "Sam Rivera", "city": "Portland", "active": True})
add_record(contacts, {"name": "Yuki Tanaka", "city": "Portland", "active": True})
add_record(contacts, {"name": "Ben Osei", "city": "Salem", "active": False})
portland = find_by_field(contacts, "city", "Portland")
for contact in portland:
print(contact["name"])
save_records(contacts, "contacts.json")
Notice except (FileNotFoundError, json.JSONDecodeError) — you can catch multiple exception types in a single except clause by grouping them in a tuple. This keeps the safe-loading logic compact without needing two separate except blocks.
json.loads(), json.dumps(), json.load(), and json.dump().indent and sort_keys keyword arguments.json module when it encounters invalid JSON syntax — for example, single-quoted keys or trailing commas.json.dumps() and json.dump() that adds line breaks and indentation to make the JSON output human-readable.json.dumps() and json.dump() that sorts dictionary keys alphabetically in the output, making the result predictable.None when loaded.json.dump() and reading it back with json.load(), with all types and structure preserved.Read each code block and answer the questions without running the code. Only check with the interpreter after you have written your answers.
import json
data = json.loads('{"x": 10, "y": null, "active": false, "tags": ["a", "b"]}')
print(type(data))
print(data["y"])
print(data["active"])
print(len(data["tags"]))
print(data["tags"][1])
json.loads() return here?data["y"]?data["active"]?data["tags"]?data["tags"][1] return?import json
import os
def load_or_create(filename, default):
if not os.path.exists(filename):
with open(filename, "w") as f:
json.dump(default, f, indent=2)
return default
with open(filename, "r") as f:
return json.load(f)
default parameter?JSONDecodeError? Under what conditions?Trace through this code and write the exact output before checking.
import json
records = [
{"name": "Finn", "score": 82},
{"name": "Asha", "score": 91},
{"name": "Eli", "score": 74}
]
text = json.dumps(records)
loaded = json.loads(text)
total = 0
for record in loaded:
total += record["score"]
average = total / len(loaded)
print(f"Records: {len(loaded)}")
print(f"Average: {average:.1f}")
Trace this code step by step. For each step, write what variable changes and what its new value is.
import json
state = {"level": 1, "score": 0, "lives": 3}
text = json.dumps(state)
state["score"] = 500
state["lives"] = 2
loaded = json.loads(text)
print(state["score"])
print(loaded["score"])
print(state["lives"])
print(loaded["lives"])
state and loaded have different values for "score" and "lives".loaded "take a snapshot" of the data?Trace the values in this nested access chain. Write the output of each print() call, then explain the last line.
import json
text = '''
{
"school": "MGU",
"departments": [
{"name": "Computer Science", "students": 240},
{"name": "Mathematics", "students": 185},
{"name": "Physics", "students": 120}
]
}
'''
data = json.loads(text)
print(data["school"])
print(data["departments"][1]["name"])
print(data["departments"][2]["students"])
total = sum(d["students"] for d in data["departments"])
print(total)
import json
original = {"a": 1, "b": [2, 3], "c": True}
text = json.dumps(original)
print(text)
import json
text = '[true, false, null, 42, "hello"]'
data = json.loads(text)
for item in data:
print(type(item).__name__, item)
import json
bad = "{'key': 'value'}"
try:
data = json.loads(bad)
print("Parsed:", data)
except json.JSONDecodeError:
print("Invalid JSON.")
import json
data = {"name": "River", "scores": [70, 85, 92]}
text = json.dumps(data, indent=2, sort_keys=True)
print(text)
This code may or may not crash. Trace it carefully. Explain what it does, whether it raises an error, and why.
import json
data = {'city': 'Portland', 'population': 650000}
text = json.dumps(data)
loaded = json.loads(text)
print(loaded['city'])
This code is supposed to update a score and save it. Find the logic error and write the corrected version.
import json
with open("scores.json", "r") as f:
data = json.load(f)
data["high_score"] = 9999
# Save to a different variable name by mistake
updated = json.dumps(data, indent=2)
print("Saved.")
What exception does this code raise and why? Write a version that handles the error gracefully.
import json
with open("settings.json", "r") as f:
config = json.load(f)
print(config["theme"])
Find and fix both bugs in this code.
import json
students = [
{name: "Yara", grade: 88},
{name: "Dani", grade: 75}
]
with open("students.json", "w") as f:
json.dump(students, f indent=2)
"title", "author", "year", and "available" (a boolean). Convert the dictionary to a JSON string using json.dumps() with indent=2 and print the result.save_to_json(data, filename) that saves any Python object to a JSON file with indent=2. Then write a function called load_from_json(filename) that reads it back. Test both functions with a small dictionary.text = '''
[
{"name": "Omar", "score": 92},
{"name": "Freya", "score": 78},
{"name": "Luz", "score": 85},
{"name": "Tariq", "score": 63},
{"name": "Sofia", "score": 97}
]
'''
update_field(filename, key, value) that loads a JSON object from a file, updates the given key with the given value, and saves the updated object back to the same file. Test it by creating a settings.json file and calling update_field("settings.json", "debug", True)."name" key and a "score" key.contacts.json if the file exists. Ask the user to enter a name and email. Add the new contact and save the updated list. Print how many contacts are now stored."subject" name, a "sessions" count, and a "total_minutes" value. The file stores a list of these entries.study_log.json at startup; save back to the same file after every change. Handle the case where the file does not yet exist.load_log(), save_log(), add_session(), show_summary(), show_leader()."last_studied" field that records the date of the most recent session. Import datetime and convert the date to a string before saving (dates are not a JSON type and must be stored as strings).json module is a library that implements the standard — the format itself belongs to no language.{'key': 'value'} is valid Python syntax but is not valid JSON. If you pass it to json.loads(), you will get a JSONDecodeError. When writing JSON by hand, always use double quotes.json.dumps() returns a string — it does not touch any file. To save to a file, you use json.dump() (without the s), passing an open file object as the second argument. The naming is easy to confuse: remember that the s means "string," not "save."json.dump() in "w" mode.data = {
"class": "CS201",
"students": [
{"name": "Kenji", "email": "[email protected]"},
{"name": "Mira", "email": "[email protected]"}
]
}
with statement do when opening files? Why is it preferable to calling open() and close() separately?try/except block that catches both FileNotFoundError and json.JSONDecodeError when loading a JSON file, and prints a specific message for each case.import json
with open("tasks.json", "r") as f:
tasks = json.load(f)
done = [t for t in tasks if t["done"]]
pending = [t for t in tasks if not t["done"]]
print(f"Done: {len(done)}")
print(f"Pending: {len(pending)}")
for t in pending:
print(" -", t["description"])
Appendix J provides a reference map of the Python standard library — the most useful modules a beginning-to-intermediate programmer should know: math, random, datetime, csv, pathlib, os, sys, json, and statistics. This chapter introduced json in depth. Appendix J shows you what each of the other modules offers and how to start reading its documentation so you can explore them independently.