Learning Goals
3 minBy the end of this lesson you can:
- Save a Python dict or list to a
.jsonfile withjson.dump. - Load it back with
json.load. - Read and write JSON strings with
json.dumps/json.loads. - Use the
indent=2argument to write pretty-printed (human-readable) JSON. - Recognise the Python ↔ JSON type mapping.
Warm-Up · The Round-Trip
5 minWatch a Python dict survive a write/read cycle:
import json # 1 — save data = { "name": "Aisyah", "age": 12, "subjects": ["maths", "art"], "address": {"city": "KL", "postcode": "50480"}, } with open("profile.json", "w", encoding="utf-8") as f: json.dump(data, f, indent=2) # 2 — read back with open("profile.json", encoding="utf-8") as f: loaded = json.load(f) print(loaded == data) # → True print(loaded["address"]["city"]) # → KL
Two function calls. The data goes out as text, comes back as the same dict. Nested structure survives intact.
Every persistence problem you've solved with manual file parsing can be solved with one line of JSON. From here on, save whole data structures — not just strings.
New Concept · Four Functions, One Type Map
14 minThe four functions
json.dump(data, file) Write data to an OPEN file json.load(file) Read data from an OPEN file json.dumps(data) Convert data to a STRING json.loads(s) Parse a JSON STRING into data
Notice the s. dump/load work with file objects; dumps/loads work with strings. The data direction is the same.
The Python ↔ JSON type map
Python JSON
dict object {...}
list / tuple array [...]
str string "..."
int number
float number
True / False true / false
None nullNotice tuples become arrays — and come back as lists. Sets and custom objects don't survive at all (you'll get TypeError: Object of type set is not JSON serializable). Convert sets to lists before saving.
Pretty-print with indent
json.dump(data, f, indent=2) # In the file: # { # "name": "Aisyah", # "age": 12, # "subjects": [ # "maths", # "art" # ] # } json.dump(data, f) # All on one line: # {"name": "Aisyah", "age": 12, "subjects": ["maths", "art"]}
Use indent=2 when humans will read the file. Skip it for machine-only data (faster, smaller).
Unicode safety
By default, json.dump escapes non-ASCII characters. For Malay or other languages with accents, pass ensure_ascii=False:
data = {"name": "Saïd", "city": "Kuala Lumpur 🇲🇾"} # Default (ASCII-only, ugly) json.dump(data, f) # {"name": "Sa\u00efd", "city": "Kuala Lumpur \ud83c\udde6..."} # Better json.dump(data, f, ensure_ascii=False) # {"name": "Saïd", "city": "Kuala Lumpur 🇲🇾"}
The string flavour · for APIs
# Serialise to a string — useful when sending to an HTTP API s = json.dumps({"event": "login", "user": "aisyah"}) print(s) # → {"event": "login", "user": "aisyah"} # Deserialise from a string — useful when receiving from an API incoming = '{"status": "ok", "items": [1, 2, 3]}' data = json.loads(incoming) print(data["items"]) # → [1, 2, 3]
Error handling
Bad JSON → json.JSONDecodeError. Wrap it.
try: with open("config.json", encoding="utf-8") as f: config = json.load(f) except FileNotFoundError: config = {} # first run except json.JSONDecodeError as e: print(f"Corrupt config: {e}") config = {}
Worked Example · Save Your High Scores in JSON
12 minRe-do PY-L2-22's high-score keeper with JSON instead of CSV-ish lines. Way cleaner.
# hi_score_json.py — high scores stored as JSON import json FILE = "scores.json" def load_scores(): try: with open(FILE, encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return [] except json.JSONDecodeError: print("Score file corrupt — starting fresh.") return [] def save_scores(scores): with open(FILE, "w", encoding="utf-8") as f: json.dump(scores, f, indent=2) def add_score(scores, name, score, top_n=5): scores.append({"name": name, "score": score}) scores.sort(key=lambda s: s["score"], reverse=True) del scores[top_n:] save_scores(scores) return scores # Demo scores = load_scores() print("Loaded:", scores) scores = add_score(scores, "Aisyah", 92) scores = add_score(scores, "Wei Jie", 88) scores = add_score(scores, "Priya", 95) print("After:", scores)
scores.json after running
[
{
"name": "Priya",
"score": 95
},
{
"name": "Aisyah",
"score": 92
},
{
"name": "Wei Jie",
"score": 88
}
]Read the diff
Compare to PY-L2-22's version. The whole "parse a line into a dict" logic is gone — json.load hands you the list of dicts directly. The save is even cleaner — json.dump(scores, f, indent=2) writes the whole list at once, pretty-printed. Whole data structures move as a unit — that's JSON's superpower.
Try It Yourself
13 minBuild a small contacts dict and save it to contacts.json. Read it back and print one contact.
Hint
import json contacts = { "Aisyah": {"phone": "012-3456789", "email": "aisyah@example.com"}, "Wei Jie": {"phone": "017-9921122", "email": "wei@example.com"}, } with open("contacts.json", "w", encoding="utf-8") as f: json.dump(contacts, f, indent=2) with open("contacts.json", encoding="utf-8") as f: loaded = json.load(f) print(loaded["Aisyah"])
Try to save a Python set to JSON. Watch it fail. Then fix it by converting to a list first.
Hint
tags = {"python", "level-2", "json", "files"} # This crashes: # json.dump(tags, open("tags.json", "w")) # TypeError: Object of type set is not JSON serializable # This works: with open("tags.json", "w", encoding="utf-8") as f: json.dump(sorted(tags), f, indent=2)
sorted(set) returns a list — sortable, serializable. On the load side, you'll get a list back; convert with set(loaded) if you need the set semantics.
Load a contacts file, add one entry, save it back. This load → mutate → save shape is how every persistent app works.
Hint
def add_contact(name, phone, email): try: with open("contacts.json", encoding="utf-8") as f: data = json.load(f) except FileNotFoundError: data = {} data[name] = {"phone": phone, "email": email} with open("contacts.json", "w", encoding="utf-8") as f: json.dump(data, f, indent=2) print(f"Saved {name}.") add_contact("Priya", "012-1111111", "priya@example.com")
Same three steps, every time: load (handling first-run), mutate the dict, save the whole thing. The file becomes a database with no database engine.
Mini-Challenge · Migrate a Text-File App to JSON
8 minTake your todo_persist.py from PY-L2-21 and convert it to use JSON storage instead of one-task-per-line. Each task is no longer just a string — it's a dict with text, created, and done fields.
{
"tasks": [
{"text": "do laundry", "created": "2026-05-27", "done": false},
{"text": "buy groceries", "created": "2026-05-27", "done": true}
]
}Update the menu to:
- Add a task (text → dict with today's date and done=false).
- List tasks, showing ✓ if done.
- Mark a task done by number.
- Save the whole structure as JSON.
Show one possible solution
# todo_json.py — to-do list backed by JSON import json from datetime import date FILE = "todo.json" def load(): try: with open(FILE, encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return {"tasks": []} def save(state): with open(FILE, "w", encoding="utf-8") as f: json.dump(state, f, indent=2, ensure_ascii=False) state = load() while True: print() for i, t in enumerate(state["tasks"], start=1): mark = "✓" if t["done"] else " " print(f" {i}. [{mark}] {t['text']} ({t['created']})") print() pick = input("(a)dd (d)one (q)uit: ").lower() if pick == "a": text = input("Task: ").strip() if text: state["tasks"].append({ "text": text, "created": date.today().isoformat(), "done": False, }) save(state) elif pick == "d": try: n = int(input("Done #: ")) - 1 state["tasks"][n]["done"] = True save(state) except (ValueError, IndexError): print(" ! Invalid task number.") elif pick == "q": break
Non-negotiables: a JSON file with a nested structure, load-on-start, save-on-change. The text-file version stored just strings; now every task carries a date and a done-status. Adding a new field (priority? due-date?) is one key away — try it.
Recap
3 minJSON saves whole Python data structures with one function call. json.dump writes to a file; json.load reads. json.dumps/json.loads use strings (for APIs). Use indent=2 for human-readable files. Sets and custom objects don't survive — convert to lists/dicts before saving. The load → mutate → save pattern is the basis of every persistent app you'll write from now on.
Vocabulary Card
- JSON
- JavaScript Object Notation. A text format for nested data, used by every web API.
- json.dump / json.load
- File-based versions.
- json.dumps / json.loads
- String-based versions (with
sfor "string"). - indent=2
- Pretty-print with two-space indentation. Skip it for compact machine-only files.
- ensure_ascii=False
- Preserve Unicode characters as-is instead of escaping them.
Homework
4 minBuild recipes.py. A simple recipe storage with three menu options:
- Add a recipe — name, list of ingredients, list of step strings. Saved as a dict.
- List all recipes — print just the names.
- Show a recipe — by name, print ingredients and steps.
Backing file: recipes.json. Structure: a dict of name → recipe-dict.
Sample · recipes.py
# recipes.py — JSON-backed recipe book import json FILE = "recipes.json" def load(): try: with open(FILE, encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return {} def save(data): with open(FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) def add_recipe(book): name = input("Name: ").strip() ingredients = input("Ingredients (comma-separated): ").split(",") ingredients = [i.strip() for i in ingredients if i.strip()] steps = [] print("Enter steps (blank line to finish):") while True: s = input(f" Step {len(steps) + 1}: ").strip() if not s: break steps.append(s) book[name] = {"ingredients": ingredients, "steps": steps} save(book) def list_recipes(book): if not book: print(" (no recipes yet)") for name in book: print(f" - {name}") def show_recipe(book, name): if name not in book: print(" Not found.") return r = book[name] print(f"\n{name}\n{'-' * len(name)}") print("Ingredients:") for i in r["ingredients"]: print(f" - {i}") print("Steps:") for i, s in enumerate(r["steps"], start=1): print(f" {i}. {s}") book = load() while True: pick = input("\n(a)dd (l)ist (s)how (q)uit: ").lower() if pick == "a": add_recipe(book) elif pick == "l": list_recipes(book) elif pick == "s": show_recipe(book, input("Recipe name: ")) elif pick == "q": break
Non-negotiables: JSON load/save with first-run safety, a dict-of-dicts structure (name → recipe), and three sub-functions for add/list/show. The recipe book becomes a database the moment you add JSON.