JSON: Reading, Writing and Saving Data
- Explain what JSON is and why it is better than a “free” text file for saving data
- Know the match between JSON types and Python types
- Save and load data from a file with
json.dumpandjson.load - Convert between a JSON string and a Python object with
json.dumpsandjson.loads, and handle a missing or broken file
What will we learn?
- What JSON is and what it looks like.
- How JSON matches Python’s dict and list.
- Saving to a file and loading from it.
- Converting between a JSON string and data.
Why do we need this?
In the previous lesson we saved text, and for that we invented a “format” of our own: Dana,90 on every line. Now suppose every student has a list of grades, an address with a city and a street, and an is_active flag. How do you save that in a line of text? Everyone will invent a different format, and we will have to write complicated parsing code.
JSON is a standard format for structured data. Every programming language knows how to read and write it. And above all:
- The OOP topic — the project will be saved as JSON.
- The backend and databases topics — every request and response in an API is sent as JSON. FastAPI automatically converts a Python dict to JSON and back.
Explanation from the basics
What is JSON?
JSON = JavaScript Object Notation. It was born in JavaScript, but today it is a general format. A JSON file is a text file with a defined structure, which looks almost exactly like Python’s dict and list:
{
"name": "Dana",
"age": 24,
"is_active": true,
"grades": [90, 85, 100],
"address": {"city": "Haifa", "street": "Herzl 10"},
"phone": null
}The match between JSON and Python
| JSON | Python |
|---|---|
object { } | dict |
array [ ] | list |
string "text" | str |
number 42, 3.5 | int, float |
true / false | True / False |
null | None |
Important JSON rules (different from Python!)
- Strings and keys only in double quotes
"...". Single quotes'...'are not valid. true,false,nullin lowercase letters.- No comma after the last item.
- No comments.
- The keys of an object are always strings.
Serialization and Deserialization
| Term | Direction | Function |
|---|---|---|
| Serialization | Python data → JSON text | json.dump / json.dumps |
| Deserialization | JSON text → Python data | json.load / json.loads |
How to remember: the s at the end (dumps, loads) = string, working with a string. Without s (dump, load) = working with a file.
A sofa in the living room (a dict in memory) cannot go through the door as it is. Serialization = taking it apart and packing it in a flat box with assembly instructions (JSON text), which can be kept in a storeroom (a file) or sent in a truck (the network). Deserialization = assembling it again in the new home, and getting an identical sofa.
Syntax
import json
data = {"name": "Dana", "grades": [90, 85]}
# file
with open(path, "w", encoding="utf-8") as file:
json.dump(data, file, indent=2, ensure_ascii=False) # save
with open(path, "r", encoding="utf-8") as file:
data = json.load(file) # load
# string
text = json.dumps(data) # dict -> JSON string
data = json.loads(text) # JSON string -> dictindent=2— writes “pretty” JSON, with line breaks and indentation. Without it, everything is on one line.ensure_ascii=False— text that is not in English (Hebrew, for example) is saved as it is, and not as escape codes (a backslash,uand four hex digits for every letter).
First code example
week-2/files/save_profile.py:
import json
from pathlib import Path
PROFILE_FILE = Path(__file__).parent / "profile.json"
profile = {
"name": "Dana",
"age": 24,
"is_active": True,
"grades": [90, 85, 100],
"address": {"city": "Haifa", "street": "Herzl 10"},
"phone": None,
}
with open(PROFILE_FILE, "w", encoding="utf-8") as file:
json.dump(profile, file, indent=2, ensure_ascii=False)
with open(PROFILE_FILE, "r", encoding="utf-8") as file:
loaded = json.load(file)
print("Loaded type:", type(loaded))
print("City:", loaded["address"]["city"])
print("Average:", sum(loaded["grades"]) / len(loaded["grades"]))
print("Same as original?", loaded == profile)Output:
Loaded type: <class 'dict'>
City: Haifa
Average: 91.66666666666667
Same as original? TrueThe profile.json that was created:
{
"name": "Dana",
"age": 24,
"is_active": true,
"grades": [
90,
85,
100
],
"address": {
"city": "Haifa",
"street": "Herzl 10"
},
"phone": null
}Breaking down the code line by line
| Line | Explanation |
|---|---|
import json | A standard module. No installation needed. |
json.dump(profile, file, indent=2, ...) | Serialization: turns the dict into JSON text and writes it to the file. True became true, and None became null. |
json.load(file) | Deserialization: reads the text and builds a new dict from it. |
type(loaded) | A completely regular dict. You can work with it like with any dict. |
loaded["address"]["city"] | Accessing a nested structure, like in the data structures and functions topic. |
loaded == profile | True: the data survived the “round trip” to the file and back in full. (But loaded is profile is False, because it is a new object.) |
Another example
A small “database” in a JSON file — loading with a default, adding, saving and handling a broken file. Exactly the pattern that will be used in the OOP project. week-2/files/contacts_db.py:
import json
import logging
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
DB_FILE = Path(__file__).parent / "contacts.json"
def load_contacts():
if not DB_FILE.exists():
logging.info("No database file yet - starting empty")
return []
try:
with open(DB_FILE, "r", encoding="utf-8") as file:
return json.load(file)
except json.JSONDecodeError:
logging.error("Database file is corrupted - starting empty")
return []
def save_contacts(contacts):
with open(DB_FILE, "w", encoding="utf-8") as file:
json.dump(contacts, file, indent=2, ensure_ascii=False)
logging.info("Saved %s contacts", len(contacts))
def add_contact(contacts, name, phone):
contact = {"id": len(contacts) + 1, "name": name, "phone": phone}
contacts.append(contact)
return contact
def main():
contacts = load_contacts()
new_contact = add_contact(contacts, "Dana", "050-1234567")
print("Added:", new_contact)
save_contacts(contacts)
print("Total contacts in file:", len(contacts))
if __name__ == "__main__":
main()First run:
INFO No database file yet - starting empty
Added: {'id': 1, 'name': 'Dana', 'phone': '050-1234567'}
INFO Saved 1 contacts
Total contacts in file: 1Second run:
Added: {'id': 2, 'name': 'Dana', 'phone': '050-1234567'}
INFO Saved 2 contacts
Total contacts in file: 2Breakdown:
- The file holds a list of dicts: exactly a “table” of records.
load_contactshandles two cases: no file (the first run) → an empty list. A broken file (json.JSONDecodeError) → logging an error and an empty list. In either case the program does not crash."id": len(contacts) + 1— a simple unique identifier. (Notice: if a contact is deleted, the next identifier may repeat an existing one. In a real Database in the backend and databases topics, the database will hand out identifiers itself, and that is exactly one of the reasons it is used.)- Load → change in memory → save: the basic pattern of working with a data file.
A JSON string — dumps / loads
There is not always a file. On the network, for example, you receive a JSON string:
import json
response_text = '{"id": 7, "name": "Dana", "active": true, "tags": ["new", "vip"]}'
student = json.loads(response_text)
print(student["name"], student["active"], student["tags"][1])
reply = {"status": "ok", "count": 3, "error": None}
print(json.dumps(reply))Dana True vip
{"status": "ok", "count": 3, "error": null}That is exactly what FastAPI will do for us in the backend and databases topics: loads on the incoming request, and dumps on the outgoing response.
Common mistake
Mistake 1: invalid JSON written by hand.
json.loads("{'name': 'Dana'}")json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)In JSON, only double quotes. The common reason: copying the output of print(dict) from Python, which uses single quotes, so it is not valid JSON. The right way to create JSON is always json.dumps, not str(dict).
Mistake 2: confusing dump and dumps.
with open(path, "w", encoding="utf-8") as file:
json.dumps(data, file)TypeError: dumps() takes 1 positional argument but 2 were givenFor a file, use dump (without s). dumps returns a string, and writes nowhere.
Mistake 3: saving something JSON does not know.
json.dumps({"tags": {"a", "b"}})TypeError: Object of type set is not JSON serializableJSON has no set (and no tuple: a tuple is saved as an array and loaded back as a list). Convert first: list(my_set). The same will happen in the OOP topic with objects of classes, and there we will learn to convert them to a dict.
Mistake 4: numeric keys come back as strings.
data = {1: "one"}
loaded = json.loads(json.dumps(data))
print(loaded) # {'1': 'one'}
print(loaded[1]) # KeyError: 1In JSON, keys are always strings, so 1 became "1". Access it with loaded["1"], or plan text keys in advance.
Practice
The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.
Exercise 1: From Python to JSON
For each Python value — what will it look like in JSON?
True None "Dana" [1, 2] {"age": 24}Show solution
| Python | JSON |
|---|---|
True | true |
None | null |
"Dana" | "Dana" — always double quotes |
[1, 2] | [1, 2] — an array |
{"age": 24} | {"age": 24} — an object; the keys are always strings |
Exercise 2: Saving and loading
Save the dictionary to the file profile.json (with indentation, and without encoding non-English text), load it back, and print the name and the highest grade.
profile = {"name": "Dana", "grades": [90, 85, 100]}Expected output:
Dana
100Show solution
import json
profile = {"name": "Dana", "grades": [90, 85, 100]}
with open("profile.json", "w", encoding="utf-8") as file:
json.dump(profile, file, indent=2, ensure_ascii=False)
with open("profile.json", "r", encoding="utf-8") as file:
loaded = json.load(file)
print(loaded["name"])
print(max(loaded["grades"]))Dana
100dump/load— with a file.indent=2— a file readable by people.ensure_ascii=False— non-English text is saved as it is.
Exercise 3: A JSON string
The following response arrived from a server as a string. Convert it to a dictionary and print the name and whether the student is active. Then convert the dictionary reply to a JSON string.
text = '{"id": 7, "name": "Dana", "active": true}'
reply = {"status": "ok", "error": None}Expected output:
Dana True
{"status": "ok", "error": null}Show solution
import json
text = '{"id": 7, "name": "Dana", "active": true}'
student = json.loads(text)
print(student["name"], student["active"])
reply = {"status": "ok", "error": None}
print(json.dumps(reply))Dana True
{"status": "ok", "error": null}loads / dumps (with an s — string) work with a string, not with a file. That is what communicating with a server looks like.
Exercise 4: Settings that are saved
Write load_settings(path) that returns the defaults {"theme": "light", "font_size": 14}, and if the file exists — updates them according to what is saved in it. Also write save_settings(path, settings).
print(load_settings("settings.json"))
save_settings("settings.json", {"theme": "dark"})
print(load_settings("settings.json"))Expected output:
{'theme': 'light', 'font_size': 14}
{'theme': 'dark', 'font_size': 14}Show solution
import json
from pathlib import Path
DEFAULTS = {"theme": "light", "font_size": 14}
def load_settings(path):
settings = DEFAULTS.copy()
if Path(path).exists():
with open(path, "r", encoding="utf-8") as file:
saved = json.load(file)
for key, value in saved.items():
settings[key] = value
return settings
def save_settings(path, settings):
with open(path, "w", encoding="utf-8") as file:
json.dump(settings, file, indent=2)
print(load_settings("settings.json"))
save_settings("settings.json", {"theme": "dark"})
print(load_settings("settings.json")){'theme': 'light', 'font_size': 14}
{'theme': 'dark', 'font_size': 14}DEFAULTS.copy() — so as not to change the defaults themselves (remember the references from the lesson on Mutability?).
Exercise 5: A contact list
Write two functions: add_contact(path, name, phone) that adds a contact to a list saved in a JSON file (and creates the file if it does not exist), and find_phone(path, name) that returns the phone or None.
add_contact("contacts.json", "Dana", "050-1111111")
add_contact("contacts.json", "Avi", "052-2222222")
print(find_phone("contacts.json", "Avi"))
print(find_phone("contacts.json", "Noa"))Expected output:
052-2222222
NoneShow solution
import json
from pathlib import Path
def load_contacts(path):
if not Path(path).exists():
return []
with open(path, "r", encoding="utf-8") as file:
return json.load(file)
def add_contact(path, name, phone):
contacts = load_contacts(path)
contacts.append({"name": name, "phone": phone})
with open(path, "w", encoding="utf-8") as file:
json.dump(contacts, file, indent=2, ensure_ascii=False)
def find_phone(path, name):
for contact in load_contacts(path):
if contact["name"] == name:
return contact["phone"]
return None
add_contact("contacts.json", "Dana", "050-1111111")
add_contact("contacts.json", "Avi", "052-2222222")
print(find_phone("contacts.json", "Avi"))
print(find_phone("contacts.json", "Noa"))052-2222222
NoneThe file holds a list of dictionaries — exactly the structure from the lesson on dict. Every change: load, change, save.
Summary
- JSON = a standard text format for structured data. object ↔
dict, array ↔list,true/false/null↔True/False/None. - Rules: double quotes only, no trailing comma, no comments. Keys are always strings.
- File:
json.dump(data, file)/json.load(file). String:json.dumps(data)/json.loads(text). indent=2for readability,ensure_ascii=Falsefor non-English text.- The pattern: load (with a default) → change in memory → save. Handle a missing file and
JSONDecodeError. - Sets and objects must be converted before saving. JSON is the language of every API in the backend and databases topics.