Потоковый разбор больших JSON в Python через ijson
Файл на 8 ГБ не влезает в json.load — процесс убивает по OOM. Разбираем, как ijson читает такой JSON потоком, где он выигрывает у стандартной библиотеки и чем за это платит.

Стандартный json.load() читает файл целиком: сначала весь текст попадает в память как строка, потом парсер строит из него дерево Python-объектов. Для файла на 8 ГБ это означает пик потребления в 20–40 ГБ — дерево из словарей и списков в CPython занимает в несколько раз больше, чем исходный текст. На машине с 16 ГБ RAM процесс просто получает Killed от OOM-киллера, а в контейнере — код выхода 137.
Библиотека ijson читает JSON как поток событий: она не держит в памяти весь документ, а отдаёт по одному элементу за раз. Разберём, когда это спасает, как выглядит API на практике и где потоковый разбор ломается или оказывается медленнее обычного.
Задача, на которой падает json.load
Типичный случай — выгрузка из внешней системы или бэкап в виде одного большого JSON-массива:
{
"exported_at": "2024-05-01",
"records": [
{"id": 1, "amount": 100, "country": "RU"},
{"id": 2, "amount": 250, "country": "KZ"},
... ещё 12 миллионов объектов
]
}Вам нужно посчитать сумму amount по строкам, где country == "RU". Данные нужны все, но одновременно в памяти — только текущая запись. json.load() тут не подходит по определению: он не начнёт отдавать данные, пока не разберёт файл до последней скобки.
Дальше версии: код проверялся на ijson 3.x и Python 3.11. У ijson есть несколько бэкендов разбора, о них ниже.
Три уровня API ijson
ijson даёт три способа читать один и тот же поток — от самого низкого к самому удобному.
1. Поток событий: ijson.parse
Самый низкий уровень. Возвращает кортежи (префикс, тип_события, значение). Полный контроль, но код многословный.
import ijson
with open('export.json', 'rb') as f:
for prefix, event, value in ijson.parse(f):
# prefix показывает путь внутри документа,
# event — тип: start_map, end_map, map_key, string, number и т.д.
print(prefix, event, value)
# Пример вывода на нашем файле:
# records.item.id number 1
# records.item.amount number 100
# records.item.country string RUФайл открывается в бинарном режиме ('rb') — ijson работает с байтами, а не со строками.
2. Объекты по префиксу: ijson.items
Средний уровень и самый частый в реальном коде. Вы указываете путь, и ijson собирает по нему готовые Python-объекты, отдавая их по одному.
import ijson
total = 0
with open('export.json', 'rb') as f:
# 'records.item' — каждый элемент массива records
for record in ijson.items(f, 'records.item'):
if record['country'] == 'RU':
total += record['amount']
print(total)Здесь в памяти в каждый момент живёт один словарь record, а не 12 миллионов. Именно это отличает потоковый разбор от json.load: пик памяти не зависит от размера файла.
3. Разбор по ключам вложенного объекта: ijson.kvitems
Когда нужно пройтись не по массиву, а по парам ключ-значение большого объекта — например, словарь {"user_1": {...}, "user_2": {...}} на миллионы ключей:
import ijson
with open('users.json', 'rb') as f:
# 'users' — объект, по чьим парам идём
for user_id, data in ijson.kvitems(f, 'users'):
process(user_id, data)Про синтаксис путей
Путь в ijson.items — это точечная нотация. item — служебное слово для «каждого элемента массива». Несколько частых случаев:
''(пустая строка) — корневой элемент, если весь файл это один массив:[{...}, {...}];'item'— элементы корневого массива;'records.item'— элементы массиваrecordsвнутри корневого объекта;'data.orders.item.lines.item'— элементы вложенного массиваlinesвнутри каждого заказа.
Точную нотацию для нетривиальной вложенности проще всего подобрать, распечатав первые сотни событий через ijson.parse и посмотрев, какие префиксы генерируются.
Бэкенды: чистый Python против C
По умолчанию ijson использует бэкенд на чистом Python — он работает всюду, но медленный. Если установлена библиотека YAJL, доступны C-бэкенды, которые разбирают поток в разы быстрее.
Выбрать бэкенд можно явно при импорте:
# Явный выбор C-бэкенда yajl2 через C-расширение (yajl2_c)
import ijson.backends.yajl2_c as ijson
for record in ijson.items(open('export.json', 'rb'), 'records.item'):
...Список доступных бэкендов и точные имена модулей смотрите в актуальной документации ijson для вашей версии — набор менялся между релизами, и подставлять имя вслепую не стоит. Если C-бэкенд не установлен, импорт конкретного модуля упадёт с ImportError, и это лучше, чем молча получить медленный разбор.
Потоковый разбор экономит память, но не процессор. Событий на большом файле — десятки миллионов, и на чистом Python-бэкенде разбор может идти в разы дольше, чем json.load на файле, который в память ещё влезает.Когда что выбирать
| Ситуация | Инструмент |
|---|---|
| Файл влезает в память с запасом, скорость важнее | json.load |
| Файл больше доступной RAM, нужен один проход по массиву | ijson.items |
| Нужен тонкий контроль над структурой или частичный разбор | ijson.parse |
| Файл — это построчный JSONL (по объекту на строку) | обычный json.loads в цикле по строкам |
Отдельно про JSONL: если ваш файл — не один гигантский массив, а по одному JSON-объекту на строку, ijson тут лишний. Читайте файл построчно и вызывайте json.loads на каждой строке — это проще и быстрее.
Что ломается на практике
- Файл открыт в текстовом режиме.
open('f.json')без'rb'на C-бэкенде даёт ошибку типа — ijson ждёт байты. - Неверный префикс. Опечатка в пути не вызывает ошибку — цикл просто не даёт ни одной итерации. Проверяйте: если
for record in ijson.items(...)ничего не вывел, начните сijson.parseи посмотрите реальные префиксы. - Числа приходят как Decimal. ijson по умолчанию отдаёт числа с дробной частью как
decimal.Decimal, а неfloat. Сложение сfloatтогда упадёт сTypeError. Приводите тип явно или используйте параметрuse_float=True, если он есть в вашей версии — сверьтесь с документацией. - Битый JSON в середине. Потоковый парсер обнаружит синтаксическую ошибку только когда до неё дойдёт, — то есть после того, как часть данных уже обработана. Если обработка не идемпотентна, продумайте откат.
- Файл нельзя перемотать. Поток читается один раз. Нужен второй проход — открывайте файл заново.
Как измерить выигрыш у себя
Не берите цифры на веру — замерьте на своих данных. Память процесса удобно смотреть через tracemalloc из стандартной библиотеки или внешне через /usr/bin/time -v (смотреть Maximum resident set size). Сравните пик RSS для json.load и для ijson.items на одном файле — на большом массиве разница будет от кратной до «один падает по OOM, другой работает».
Что дальше
Если после разбора данные нужно куда-то сложить пачками — накапливайте записи в список по 1000–5000 штук и вставляйте батчами в БД, не по одной. Если источник данных ваш и вы можете влиять на формат — переведите выгрузку в JSONL: построчный формат читается проще, чем один мегамассив, и не требует ijson вовсе. А для регулярных больших выгрузок посмотрите в сторону колоночных форматов вроде Parquet — они компактнее и быстрее JSON на аналитике.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: ijson — документация проекта на PyPI, Python json — стандартная библиотека
Частые вопросы
У меня ijson 2.x, а не 3.x — примеры сработают?
Цикл по ijson.items не выдаёт ни одной записи, ошибок нет — почему?
ijson быстрее json.load?
Получаю TypeError при сложении чисел из ijson — в чём дело?
Можно ли этим разбирать JSONL, где по объекту на строку?
Что будет, если в середине большого файла окажется битый JSON?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.