Зачем нужен JSON
В прошлом уроке мы выяснили, что CSV хорош для табличных данных, но плохо переживает вложенные структуры и типы. JSON — ровно то, что закрывает эту дыру: в нём есть number, string, boolean, null, array, object, и они комбинируются как угодно.
Для junior DE JSON — это формат API. 99% REST-эндпоинтов возвращают JSON, 99% NoSQL-баз (MongoDB, Elasticsearch, DynamoDB) хранят JSON-документы. Когда вы получаете данные «с того конца провода» — это JSON. Когда вы их сохраняете в archive-слой data lake’а — обычно тоже JSON (точнее, его потоковая разновидность JSONL, о которой ниже).
Поэтому уметь читать, писать и трансформировать JSON — base skill, без которого нельзя сесть за свою первую таску.
Базовый stdlib
В Python — модуль json в stdlib. Четыре главные функции:
import json
# из строки → Python-объект
data = json.loads('{"id": 1, "ok": true, "items": [10, 20]}')
# {'id': 1, 'ok': True, 'items': [10, 20]}
# из файла → Python-объект
with open("response.json", encoding="utf-8") as f:
data = json.load(f)
# Python-объект → строка
text = json.dumps({"id": 1, "country": "RU"})
# Python-объект → файл
with open("out.json", "w", encoding="utf-8") as f:
json.dump({"id": 1}, f)
Запомните: loads/dumps — для строк (буква s в конце как «string»), load/dump — для файлов. Это та же конвенция, что у pickle и многих сторонних библиотек.
Маппинг JSON ↔ Python:
| JSON | Python |
|---|---|
string | str |
number (int) | int |
number (float) | float |
true / false | True / False |
null | None |
object | dict |
array | list |
Заметьте: в JSON нет ни datetime, ни Decimal, ни bytes, ни set. Эти типы при сериализации придётся либо превращать в строки, либо использовать спец-параметры (см. ниже).
Параметры, которые junior должен знать
Базовый json.dumps(obj) работает, но в production вы будете каждый раз указывать ещё несколько параметров. Вот пять важнейших.
json.dumps(
obj,
ensure_ascii=False, # не экранировать не-ASCII символы
indent=2, # человекочитаемое форматирование с отступами
sort_keys=True, # стабильный порядок ключей
separators=(",", ":"), # компактный вариант, без пробелов
default=str, # как сериализовать незнакомые типы
)
ensure_ascii=False — без этого кириллица превращается в Иван. Технически такой JSON валиден, но он нечитаем людьми и занимает в 6 раз больше места. В русскоязычных проектах всегда ставьте False.
indent=2 — для отладки, ручного чтения, фиксации в git. Для production-stream — наоборот, не ставить (или separators=(",", ":")), чтобы экономить байты.
sort_keys=True — гарантирует стабильный порядок. Это важно, когда JSON-файл хэшируют для дедупликации или сравнивают diff’ом.
default=... — callback, который вызывается для типов, которые json не знает. Самый частый паттерн — default=str, который превращает datetime, Decimal, UUID в их строковое представление:
from datetime import datetime, UTC
from decimal import Decimal
data = {"at": datetime.now(UTC), "price": Decimal("19.99")}
json.dumps(data, default=str, ensure_ascii=False)
# '{"at": "2026-05-13 12:34:56+00:00", "price": "19.99"}'
Это «грязный» способ — на десериализации обратно datetime уже не получится без знания формата. Чище — Pydantic-модель (Module 04), которая знает и про сериализацию, и про обратное чтение.
Что JSON не умеет
Тут нужно сразу проговорить, чего в JSON нет, и это его принципиальные ограничения.
Нет комментариев. Стандарт это запрещает. Если вы видите файл с // или # — это не JSON, это какой-то его суперсет (JSON5, JSONC). json stdlib такие файлы не читает.
Нет trailing comma. {"a": 1, "b": 2,} — невалидный JSON. Это бесит, когда генерируешь руками или редактируешь, но json будет ругаться.
Нет дат, decimal’ов, UUID’ов нативно. Всё — строки или числа. Pydantic умеет их распарсить из строк автоматически, но stdlib json — нет.
Числа без ограничения точности. json.loads("0.1 + 0.2") — это вообще не JSON. Но даже валидное 0.1 после round-trip может стать 0.10000000000000001 — это уже float64 представление, на которое стандарт не накладывает ограничений. Для денег используйте Decimal и сериализуйте в строку.
Один корень. Файл {}\n{}\n{} — это не JSON, это три отдельных JSON-объекта. Если у вас именно такой формат — это JSONL, о котором сейчас.
JSONL — JSON для потоков
JSONL (он же NDJSON, newline-delimited JSON) — это формат, где каждая строка — отдельный JSON-объект. Никаких квадратных скобок вокруг, никаких запятых между объектами.
{"id": 1, "country": "RU", "amount": 100}
{"id": 2, "country": "US", "amount": 50}
{"id": 3, "country": "RU", "amount": 200}
Зачем? Два главных применения.
Streaming. В обычном JSON [{}, {}, {}] парсер должен прочитать весь файл, чтобы найти закрывающую ]. На гигабайтных дампах это либо OOM, либо очень медленно. В JSONL каждая строка независима — парсер читает построчно и выдаёт по одному объекту. Это та же модель, что в CSV.
Логи и события. Большинство production-систем пишут логи именно в JSONL: один event = одна строка JSON. Loki, Splunk, Datadog, ELK — все они эффективно обрабатывают этот формат.
Запись JSONL ничем не пугает:
import json
from pathlib import Path
events = [
{"id": 1, "type": "click", "user": "alice"},
{"id": 2, "type": "view", "user": "bob"},
]
with Path("events.jsonl").open("w", encoding="utf-8") as f:
for event in events:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
Чтение — генератор:
from collections.abc import Iterator
from pathlib import Path
import json
def read_jsonl(path: Path) -> Iterator[dict]:
with path.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue # пустые строки в конце файла — пропускаем
yield json.loads(line)
# использование
counts: dict[str, int] = {}
for event in read_jsonl(Path("events.jsonl")):
counts[event["type"]] = counts.get(event["type"], 0) + 1
Этот код работает для файла любого размера: 100 МБ, 100 ГБ, 1 ТБ. В памяти всегда одна строка. Это та же идиома потокового чтения, что в уроке 03/01 про генераторы — JSONL ровно для такого создан.
В Loki, S3-логах, BigQuery в JSONL-формате хранятся миллиарды событий. Если ваш ETL когда-нибудь будет обрабатывать «прод-логи» — это будут именно .jsonl.gz файлы. Уметь стримить JSONL — практически обязательное умение DE.
CSV vs JSON: когда что
| Критерий | CSV | JSON / JSONL |
|---|---|---|
| Структура | плоская таблица | вложенные объекты |
| Типы | всё текст | string/number/bool/null/object/array |
| Размер | компактнее (raw) | чуть больше из-за {} и имён ключей |
| Понимают | абсолютно все | абсолютно все |
| Streaming | да (по строкам) | только JSONL (по строкам) |
| Прод-обмен | legacy, не-DE системы | API, логи, NoSQL |
Решение простое. Если данные табличные и плоские, и у получателя нет JSON-парсера — CSV. Если есть хоть один вложенный объект, или это API/логи, или вы будете грузить в любую современную систему — JSON / JSONL.
orjson: когда stdlib не хватает
Stdlib-модуль json написан на C, но он старый и не оптимизирован под современные процессоры. В DE-задачах, где гоняют гигабайты JSON, разница в скорости становится значимой. Тогда подключают
orjsonimport orjson
# loads — принимает bytes или str
data = orjson.loads(b'{"id": 1}')
# dumps — возвращает bytes, не str
raw = orjson.dumps({"id": 1, "at": datetime.now(UTC)})
# b'{"id":1,"at":"2026-05-13T12:34:56+00:00"}'
# опции
raw = orjson.dumps(
{"id": 1},
option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS,
)
Особенности orjson, которые отличаются от stdlib:
dumpsвозвращаетbytes, неstr— это сделано для скорости (не нужен decode UTF-8). Записывать в файл —open("w", "b")или.write_bytes().- Поддерживает
datetimeиUUIDиз коробки, безdefault=. Сериализуется в ISO 8601. - В 3-10 раз быстрее stdlib на realistic JSON.
- Не поддерживает кастомные encoders по умолчанию — для редких типов используется параметр
defaultточно так же, как в stdlib.
Когда переключаться на orjson: когда обрабатываете больше ~100 МБ JSON в день. Если задача — раз в минуту распарсить ответ API в 10 КБ, stdlib справится так, что разницы не заметите.
Эксплорация: jq для одноразовых задач
Когда нужно посмотреть один большой JSON-файл без написания скрипта — есть
jq# первая строка JSONL
head -1 events.jsonl | jq
# фильтр по типу
jq 'select(.type == "click")' events.jsonl
# выбрать пару полей
jq '{id, user}' events.jsonl
# count by group
jq -s 'group_by(.type) | map({type: .[0].type, count: length})' events.jsonl
Это не для прода — для exploration. Прилетел незнакомый JSON-дамп, надо за минуту понять структуру и количество — jq это делает быстрее, чем открывать редактор и писать код.
DE-кейс: API response → JSONL archive
Соберём всё на боевой задаче. Источник — REST API, который отдаёт страницы по 1000 событий за раз. Задача: за сутки выкачать все события и сложить в archive-слой data lake — один файл в день, формат .jsonl.gz, имя events/YYYY-MM-DD.jsonl.gz.
import gzip
import json
from datetime import date
from pathlib import Path
from collections.abc import Iterator
import httpx
def fetch_events(client: httpx.Client, day: date) -> Iterator[dict]:
"""Постранично выгружает события за день. Каждая страница — JSON-массив."""
cursor: str | None = None
while True:
params: dict[str, str | int] = {"date": day.isoformat(), "limit": 1000}
if cursor:
params["cursor"] = cursor
resp = client.get("https://api.example.com/events", params=params)
resp.raise_for_status()
payload = resp.json()
yield from payload["items"]
cursor = payload.get("next_cursor")
if not cursor:
return
def archive_day(day: date, out_dir: Path) -> Path:
"""Сохранить события дня в архивный .jsonl.gz."""
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{day.isoformat()}.jsonl.gz"
with httpx.Client(timeout=30) as client, gzip.open(out_path, "wt", encoding="utf-8") as f:
for event in fetch_events(client, day):
f.write(json.dumps(event, ensure_ascii=False) + "\n")
return out_path
Несколько важных деталей.
fetch_events — генератор. Он не сохраняет в память все события сразу — выдаёт постранично, и yield from payload["items"] отдаёт каждый элемент текущей страницы поштучно.
gzip.open(path, "wt") — компрессия на лету. Файл пишется уже сжатым, не нужно отдельной стадии «сохранить → запаковать». Об этом подробно — в уроке 05 про компрессию.
ensure_ascii=False — UTF-8 в файле останется UTF-8, а не \u...-эскейпами.
out_dir.mkdir(parents=True, exist_ok=True) — идемпотентная директория, классика из 04/pathlib.
Этот скрипт можно поставить в Airflow @daily, и он будет молотить — память расходуется на одну страницу за раз, формат на выходе — стандартный для всего DE-мира.
Что мы получили
- JSON — формат с типами и вложенностью; идеален для API и NoSQL-обмена.
json.dumpsвсегда вызываем сensure_ascii=False(русские буквы) иdefault=str(datetime/Decimal).- JSONL — JSON-line-delimited, по объекту на строку; читается генератором, грузит O(1) памяти.
orjson— 3-10x быстрее stdlib, возвращаетbytes, умеетdatetimeнативно. Включать когда объёмы реально большие.- CSV для табличного и legacy, JSONL для всего остального.
- DE-паттерн: API → JSONL →
.gz→ S3 partition — это классический archive layer любого data lake.
В следующем уроке — YAML и TOML: форматы для конфигов, не для данных. Там, наоборот, читаемость важнее скорости, а вложенность управляется отступами.