Learning Platform
Урок 07.02 · 20 мин
Начальный
JSONJSONLNDJSONorjsonStreaming
JSON deep: типы, числа, Unicode и кастомные энкодеры CSV и JSON: текстовые форматы в data lake

Зачем нужен 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:

JSONPython
stringstr
number (int)int
number (float)float
true / falseTrue / False
nullNone
objectdict
arraylist

Заметьте: в 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 ровно для такого создан.

TIP

В Loki, S3-логах, BigQuery в JSONL-формате хранятся миллиарды событий. Если ваш ETL когда-нибудь будет обрабатывать «прод-логи» — это будут именно .jsonl.gz файлы. Уметь стримить JSONL — практически обязательное умение DE.

CSV vs JSON: когда что

КритерийCSVJSON / 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, разница в скорости становится значимой. Тогда подключают

orjson
:

import 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
. Не на Python, но любой DE его знает.

# первая строка 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: форматы для конфигов, не для данных. Там, наоборот, читаемость важнее скорости, а вложенность управляется отступами.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5