JSON: Reading, Writing and Saving Data

Lesson goals
  • 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.dump and json.load
  • Convert between a JSON string and a Python object with json.dumps and json.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

JSONPython
object { }dict
array [ ]list
string "text"str
number 42, 3.5int, float
true / falseTrue / False
nullNone

Important JSON rules (different from Python!)

  • Strings and keys only in double quotes "...". Single quotes '...' are not valid.
  • true, false, null in lowercase letters.
  • No comma after the last item.
  • No comments.
  • The keys of an object are always strings.

Serialization and Deserialization

TermDirectionFunction
SerializationPython data → JSON textjson.dump / json.dumps
DeserializationJSON text → Python datajson.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.

Analogy: packing furniture for moving

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 -> dict
  • indent=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, u and 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? True

The 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

LineExplanation
import jsonA 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 == profileTrue: 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: 1

Second run:

Added: {'id': 2, 'name': 'Dana', 'phone': '050-1234567'}
INFO Saved 2 contacts
Total contacts in file: 2

Breakdown:

  • The file holds a list of dicts: exactly a “table” of records.
  • load_contacts handles 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 given

For 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 serializable

JSON 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: 1

In 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
PythonJSON
Truetrue
Nonenull
"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
100
Show 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
100
  • dump / 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
None
Show 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
None

The 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=2 for readability, ensure_ascii=False for 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.

Official documentation