JSON: чтение, запись и сохранение данных

Цели урока
  • Объяснить, что такое JSON и почему он лучше «свободного» текстового файла для сохранения данных
  • Знать соответствие между типами JSON и типами Python
  • Сохранять и загружать данные из файла с помощью json.dump и json.load
  • Преобразовывать между строкой JSON и объектом Python с помощью json.dumps и json.loads и обрабатывать отсутствующий или испорченный файл

Что мы изучим?

  • Что такое JSON и как он выглядит.
  • Как JSON соответствует dict и list в Python.
  • Сохранение в файл и загрузку из него.
  • Преобразование между строкой JSON и данными.

Зачем это нужно?

В предыдущем уроке мы сохраняли текст и для этого придумали собственный «формат»: Dana,90 в каждой строке. А теперь допустим, что у каждого студента есть список оценок, адрес с городом и улицей и флаг is_active. Как сохранить это в строке текста? Каждый придумает свой формат, и придётся писать сложный код разбора.

JSON — стандартный формат для структурированных данных. Его умеет читать и писать любой язык программирования. И главное:

  • Тема ООП — проект будет сохраняться в JSON.
  • Темы бэкенда и баз данных — каждый запрос и ответ в API отправляется в формате JSON. FastAPI автоматически превращает dict Python в JSON и обратно.

Объяснение с основ

Что такое JSON?

JSON = JavaScript Object Notation. Он родился в JavaScript, но сегодня это общий формат. Файл JSON — текстовый файл с определённой структурой, который выглядит почти так же, как dict и list в Python:

{
  "name": "Dana",
  "age": 24,
  "is_active": true,
  "grades": [90, 85, 100],
  "address": {"city": "Haifa", "street": "Herzl 10"},
  "phone": null
}

Соответствие между JSON и Python

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

Важные правила JSON (отличаются от Python!)

  • Строки и ключи — только в двойных кавычках "...". Одинарные кавычки '...' недопустимы.
  • true, false, null — строчными буквами.
  • Без запятой после последнего элемента.
  • Без комментариев.
  • Ключи объекта — всегда строки.

Сериализация и десериализация

ТерминНаправлениеФункция
Сериализация (Serialization)Данные Python → текст JSONjson.dump / json.dumps
Десериализация (Deserialization)Текст JSON → данные Pythonjson.load / json.loads

Как запомнить: s в конце (dumps, loads) = string, работа со строкой. Без s (dump, load) = работа с файлом.

Аналогия: упаковка мебели при переезде

Диван в гостиной (dict в памяти) не пройдёт в дверь как есть. Сериализация = разобрать его и упаковать в плоскую коробку с инструкцией по сборке (текст JSON), которую можно хранить на складе (в файле) или отправить грузовиком (по сети). Десериализация = собрать его снова в новом доме и получить точно такой же диван.

Синтаксис

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 — записывает «красивый» JSON с переводами строк и отступами. Без него всё в одной строке.
  • ensure_ascii=False — неанглийский текст (например, русский или иврит) сохраняется как есть, а не в виде escape-кодов (обратная косая черта, u и четыре шестнадцатеричные цифры на каждую букву).

Первый пример кода

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)

Вывод:

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

Созданный profile.json:

{
  "name": "Dana",
  "age": 24,
  "is_active": true,
  "grades": [
    90,
    85,
    100
  ],
  "address": {
    "city": "Haifa",
    "street": "Herzl 10"
  },
  "phone": null
}

Разбор кода построчно

СтрокаОбъяснение
import jsonСтандартный модуль. Установка не нужна.
json.dump(profile, file, indent=2, ...)Сериализация: превращает dict в текст JSON и записывает его в файл. True стало true, а None — null.
json.load(file)Десериализация: читает текст и строит из него новый dict.
type(loaded)Совершенно обычный dict. С ним можно работать, как с любым словарём.
loaded["address"]["city"]Доступ к вложенной структуре, как в теме структур данных и функций.
loaded == profileTrue: данные полностью пережили «путешествие» в файл и обратно. (Но loaded is profile — False, потому что это новый объект.)

Ещё один пример

Маленькая «база данных» в файле JSON — загрузка со значением по умолчанию, добавление, сохранение и обработка испорченного файла. Именно этот шаблон будет использоваться в проекте ООП. 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()

Первый запуск:

INFO No database file yet - starting empty
Added: {'id': 1, 'name': 'Dana', 'phone': '050-1234567'}
INFO Saved 1 contacts
Total contacts in file: 1

Второй запуск:

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

Разбор:

  • Файл хранит список словарей: ровно «таблицу» записей.
  • load_contacts обрабатывает два случая: файла нет (первый запуск) → пустой список. Файл испорчен (json.JSONDecodeError) → запись ошибки в лог и пустой список. В любом случае программа не падает.
  • "id": len(contacts) + 1 — простой уникальный идентификатор. (Обратите внимание: если контакт удалить, следующий идентификатор может повторить существующий. В настоящей базе данных в темах бэкенда и баз данных база сама выдаёт идентификаторы, и это как раз одна из причин её использовать.)
  • Загрузить → изменить в памяти → сохранить: базовый шаблон работы с файлом данных.

Строка JSON — dumps / loads

Файл есть не всегда. В сети, например, вы получаете строку JSON:

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}

Именно это FastAPI будет делать за нас в темах бэкенда и баз данных: loads для входящего запроса и dumps для исходящего ответа.

Типичная ошибка

Ошибка 1: недопустимый JSON, написанный вручную.

json.loads("{'name': 'Dana'}")
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

В JSON — только двойные кавычки. Частая причина: скопировали вывод print(dict) из Python, где используются одинарные кавычки, поэтому это не допустимый JSON. Правильный способ создать JSON — всегда json.dumps, а не str(dict).

Ошибка 2: путать dump и dumps.

with open(path, "w", encoding="utf-8") as file:
    json.dumps(data, file)
TypeError: dumps() takes 1 positional argument but 2 were given

Для файла — dump (без s). dumps возвращает строку и никуда не пишет.

Ошибка 3: сохранять то, чего JSON не знает.

json.dumps({"tags": {"a", "b"}})
TypeError: Object of type set is not JSON serializable

В JSON нет множества (и нет tuple: tuple сохраняется как массив и загружается обратно как list). Сначала преобразуйте: list(my_set). То же самое произойдёт в теме ООП с объектами классов, и там мы научимся превращать их в dict.

Ошибка 4: числовые ключи возвращаются строками.

data = {1: "one"}
loaded = json.loads(json.dumps(data))
print(loaded)        # {'1': 'one'}
print(loaded[1])     # KeyError: 1

В JSON ключи всегда строки, поэтому 1 стало "1". Обращайтесь через loaded["1"] или заранее планируйте текстовые ключи.

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Упражнение 1: Из Python в JSON

Для каждого значения Python — как оно будет выглядеть в JSON?

True        None        "Dana"        [1, 2]        {"age": 24}
Показать решение
PythonJSON
Truetrue
Nonenull
"Dana""Dana" — всегда двойные кавычки
[1, 2][1, 2] — массив (array)
{"age": 24}{"age": 24} — объект (object); ключи всегда строки

Упражнение 2: Сохранение и загрузка

Сохраните словарь в файл profile.json (с отступами и без кодирования неанглийского текста), загрузите его обратно и напечатайте имя и самую высокую оценку.

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

Ожидаемый вывод:

Dana
100
Показать решение
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 — с файлом.
  • indent=2 — файл, удобный для чтения людьми. ensure_ascii=False — неанглийский текст сохраняется как есть.

Упражнение 3: Строка JSON

Следующий ответ пришёл от сервера в виде строки. Преобразуйте его в словарь и напечатайте имя и активен ли студент. Затем преобразуйте словарь reply в строку JSON.

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

Ожидаемый вывод:

Dana True
{"status": "ok", "error": null}
Показать решение
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 (с s — string) работают со строкой, а не с файлом. Так выглядит общение с сервером.

Упражнение 4: Настройки, которые сохраняются

Напишите load_settings(path), которая возвращает значения по умолчанию {"theme": "light", "font_size": 14}, а если файл существует — обновляет их согласно сохранённому в нём. Напишите также save_settings(path, settings).

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}
Показать решение
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() — чтобы не менять сами значения по умолчанию (помните ссылки из урока об изменяемости?).

Упражнение 5: Список контактов

Напишите две функции: add_contact(path, name, phone), которая добавляет контакт в список, сохранённый в файле JSON (и создаёт файл, если его нет), и find_phone(path, name), которая возвращает телефон или 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
Показать решение
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

Файл хранит список словарей — ровно структуру из урока о dict. Каждое изменение: загрузить, изменить, сохранить.

Итоги

  • JSON = стандартный текстовый формат для структурированных данных. object ↔ dict, array ↔ list, true/false/null ↔ True/False/None.
  • Правила: только двойные кавычки, без запятой в конце, без комментариев. Ключи — всегда строки.
  • Файл: json.dump(data, file) / json.load(file). Строка: json.dumps(data) / json.loads(text).
  • indent=2 — для читаемости, ensure_ascii=False — для неанглийского текста.
  • Шаблон: загрузить (со значением по умолчанию) → изменить в памяти → сохранить. Обрабатывайте отсутствующий файл и JSONDecodeError.
  • Множества и объекты нужно преобразовать перед сохранением. JSON — язык каждого API в темах бэкенда и баз данных.

Официальная документация