# JSON: Reading, Writing and Saving Data<no value>

## 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** = **J**ava**S**cript **O**bject **N**otation. 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:

```json
{
  "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`, `null` in **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`) = **s**tring, working with a **string**. Without s (`dump`, `load`) = working with a **file**.

{{< box "analogy" "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.
{{< /box >}}

## Syntax

```python
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`:

```python
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:**

```text
Loaded type: <class 'dict'>
City: Haifa
Average: 91.66666666666667
Same as original? True
```

**The `profile.json` that was created:**

```json
{
  "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`:

```python
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:**

```text
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:**

```text
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**:

```python
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))
```

```text
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.**

```python
json.loads("{'name': 'Dana'}")
```

```text
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`.**

```python
with open(path, "w", encoding="utf-8") as file:
    json.dumps(data, file)
```

```text
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.**

```python
json.dumps({"tags": {"a", "b"}})
```

```text
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.**

```python
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.

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## 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?

```text
True        None        "Dana"        [1, 2]        {"age": 24}
```

{{< 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 |
{{< /solution >}}

### 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.

```py
profile = {"name": "Dana", "grades": [90, 85, 100]}
```

**Expected output:**

```text
Dana
100
```

{{< solution >}}
```python
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"]))
```

```text
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.
{{< /solution >}}

### 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.

```py
text = '{"id": 7, "name": "Dana", "active": true}'
reply = {"status": "ok", "error": None}
```

**Expected output:**

```text
Dana True
{"status": "ok", "error": null}
```

{{< solution >}}
```python
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))
```

```text
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.
{{< /solution >}}

### 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)`.

```python
print(load_settings("settings.json"))
save_settings("settings.json", {"theme": "dark"})
print(load_settings("settings.json"))
```

**Expected output:**

```text
{'theme': 'light', 'font_size': 14}
{'theme': 'dark', 'font_size': 14}
```

{{< solution >}}
```python
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"))
```

```text
{'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?).
{{< /solution >}}

### 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`.

```python
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:**

```text
052-2222222
None
```

{{< solution >}}
```python
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"))
```

```text
052-2222222
None
```

The file holds **a list of dictionaries** — exactly the structure from the lesson on dict. Every change: load, change, save.
{{< /solution >}}

<!-- exercises:end -->

## 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.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

## Official documentation

- [Saving structured data with json](https://docs.python.org/3/tutorial/inputoutput.html#saving-structured-data-with-json)
- [json.dump()](https://docs.python.org/3/library/json.html#json.dump)
- [json.load()](https://docs.python.org/3/library/json.html#json.load)

<!-- docs:end -->
