Практика роботи з JSONL-файлами: відкриття, конвертація та пошук пошкоджених рядків
Головне: Якщо весь .jsonl-файл передати JSON.parse або json.load і отримати помилку — майже завжди винен не файл, а спосіб читання. У статті: стандартні патерни перевірки та построкового читання, команди взаємного перетворення з JSON-масивом, таблиця шести типів битих рядків і коректне споживання NDJSON з потокової HTTP-відповіді.

Головне: кожен рядок JSONL-файлу — незалежне коректне JSON-значення, але весь файл єдиною JSON-структурою не є. Якщо передача файлу цілком у JSON.parse або json.load падає, у дев'яти випадках із десяти файл у порядку — ви просто читаєте його неправильно.
Один запис у рядку, без ком між рядками, без зовнішнього масиву — JSONL відмовляється від «цілісної структури документа» в обмін на дозаписування, потокове читання та ізоляцію збою одним рядком. Усі три переваги реалізуються лише при построковій обробці й усі три перетворюються на помилки при спробі розпарсити файл цілком.
1. Три назви, один формат: JSONL, NDJSON, JSON Lines
Новачки часто приймають різницю назв за різницю в синтаксисі. Її немає:
| Назва | Типовий контекст | Розширення / media type |
|---|---|---|
| JSON Lines | Інженерія даних, ML-датасети (pandas, Spark, Hugging Face) | .jsonl |
| NDJSON | Потокові HTTP API, дані в реальному часі | .ndjson, application/x-ndjson |
| JSONL | Побутове скорочення для будь-якого з двох | — |
Інструмент, що читає один варіант, прочитає й інший. Єдиний критерій вибору назви — конвенції вашої екосистеми: пишіть .jsonl, коли готуєте датасети для Hugging Face, ставте application/x-ndjson, коли публікуєте потоковий API. Портативний JSONL-файл задовольняє трьом правилам: кодування UTF-8 (без BOM), одне повне JSON-значення в кожному рядку, рядки розділені \n. Завершальний перенос рядка в кінці файлу наполегливо рекомендується, а порожні рядки не є коректними записами. Деталі протоколу та мотивацію дизайну розібрано в нашому огляді JSON Lines і NDJSON; ця стаття строго про те, що робити, коли файл уже у вас.
2. Огляд: спершу head, потім grep, редактор — наприкінці
Подвійний клік по 500-мегабайтному .jsonl, щоб відкрити його в редакторі, — найчастіша помилка: три мільйони рядків плюс підсвічування синтаксису плюс буфер скасування повісять практично будь-який редактор, і JSON тут ні до чого. Парсинг документа на 45 МБ займає в Node 22 близько 208 мс; підвисає саме рендеринг. Построкові формати створено для рядкових утиліт, бо записи збігаються з фізичними рядками:
# Спершу оцініть масштаб: кількість записів і розмір файлу, не читаючи вміст
wc -l events.jsonl && du -h events.jsonl
# Подивіться, як виглядає запис (поля та структура)
head -n 1 events.jsonl | jq .
# Знайдіть потрібні записи напряму: рядкові утиліти просто працюють — привілей JSONL
grep '"level":"error"' events.jsonl | head -n 5
tail -n 100 events.jsonl | jq 'select(.latency_ms > 500)'
Зверніть увагу на останню команду: кожен рядок на виході tail — як і раніше незалежне JSON-значення, тож його можна подати просто в jq, не збираючи масив заново. На багатогігабайтних файлах цей підхід працює так само майже без витрат пам'яті — той самий принцип, що й у матеріалі про те, чому великі JSON уповільнюють сторінки.
3. Три стандартні патерни построкового читання
У построкового читання три обов'язкові вимоги: пропускати порожні рядки (особливо завершальний перенос), повідомляти помилки з номером рядка й не давати одному битому рядку втратити всю роботу. Стандартні патерни в усіх трьох мовах побудовані навколо цих пунктів.
Python, лише стандартна бібліотека:
import json
with open("events.jsonl", encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
if not line.strip(): # пропускати порожні рядки
continue
try:
record = json.loads(line) # парсити кожен рядок незалежно
except json.JSONDecodeError as e:
print(f"Рядок {line_number}: {e}") # повідомити, не перериваючи
continue # ізолювати биту строку
handle(record)
Node зі встроєним модулем readline — постійна пам'ять незалежно від розміру файлу:
import fs from "node:fs";
import readline from "node:readline";
const input = fs.createReadStream("events.jsonl", "utf8");
const lines = readline.createInterface({ input, crlfDelay: Infinity });
let lineNumber = 0;
for await (const line of lines) {
lineNumber += 1;
if (!line.trim()) continue;
try {
handle(JSON.parse(line));
} catch (e) {
console.error(`Рядок ${lineNumber}: ${e.message}`);
}
}
У Go 1.27 encoding/json/v2 став стабільним за замовчуванням, і його потокове декодування за значеннями ідеально лягає на JSONL; для вбудовування jq-виразів є gojq. З бібліотеками не поспішайте ставити пакети: Python-бібліотека jsonlines повідомляє номер і вихідний текст збійного рядка, а orjsonl читає й пише стиснуті gzip/bzip2/zstd файли напряму; npm-пакет ndjson для Node не випускав релізів із 2020 року — встроєного readline достатньо.
4. Перетворення на JSON-масив і назад
Напрямок має значення; переплутаєте — будь-яка команда буде невірною. Запам'ятайте одне правило: масив → JSONL — це map (кожен запис стискається в один рядок), JSONL → масив — це collect (рядки збираються в список).
# JSON-масив → JSONL: -c стискає кожен елемент в один рядок
jq -c '.[]' records.json > records.jsonl
# JSONL → JSON-масив: -s збирає весь вхід в один масив
jq -s '.' events.jsonl > records.json
# Фільтрація JSONL построково: -s не потрібен, jq обробляє вхід як потік
jq -c 'select(.active == true)' events.jsonl > active.jsonl
Однорядкові команди Python еквівалентні: масив → JSONL пише json.dumps(r, ensure_ascii=False) + "\n" на запис, зворотний напрям збирає json.loads построково в список. У кожного напряму одна пастка. У прямому (масив → JSONL) будь-який реальний перенос рядка всередині запису мусить бути екранованим json.dumps — інакше один запис розірветься на кілька рядків. У зворотному (JSONL → масив) не склеюйте рядки в один гігантський рядок і не парсіть його — на мільйонах рядків це зайва повна копія в пам'яті. Агрегації (суми, групування, середні) вимагають -s, який вантажить усе; на багатогігабайтних файлах переходьте на awk або потоковий акумулятор на Python.
5. Шість видів битих рядків і як їх діагностувати
Майже кожна помилка JSONL живе на рівні рядка. Валідація построково з виводом номерів рядків — єдина надійна діагностика. За частотою:
| Симптом | Коренева причина | Виправлення |
|---|---|---|
Extra data у кінці рядка |
Між рядками додали коми | Видаліть; кожен рядок має бути самостійним |
| Один запис розбито на рядки | Неекранований реальний перенос усередині запису (відформатували вручну) | Пересисніть json.dumps / jq -c |
Перший рядок падає з Unexpected token ï |
Файл із BOM | Перезбережіть у UTF-8 без BOM |
| Помилка лише в останньому рядку, хоча виглядає нормально | Відсутній завершальний перенос або зайві пробіли | Додайте \n; порожні рядки пропускайте за домовленістю |
| Помилка через перенос у лапках | Літеральний перенос усередині рядка, а не \n |
Виправте генератор; ніколи не збирайте JSON-рядки конкатенацією вручну |
| Великі цілі змінюють значення | Парсинг через double (усічення 2^53) | Використовуйте парсер із збереженням точності, див. точність BigInt |
Діагностичний алгоритм завжди з трьох кроків: head -n 1 для перевірки кодування та структури першого рядка; построковий скрипт, який повідомляє номери всіх битих рядків, а не зупиняється на першому; потім витягніть биті рядки по одному — один рядок це один повний запис, і вивчати його ізольовано нічого не коштує. Для пакетних імпортів рекомендовано політику «карантин, а не скасування»: записуйте биті рядки з номерами в errors.jsonl, продовжуйте завантажувати коректні, а рішення про повторний імпорт ухвалюйте за часткою битих рядків.
6. JSONL поверх HTTP: построкове споживання та зворотний тиск
Типова схема NDJSON поверх HTTP: сервер пише безперервно, клієнт читає безперервно. На клієнті не викликайте response.json() — він буферизує все тіло перед парсингом, що знецінює потоковість. Читайте потік тіла напряму, ріжте по \n і переносіть незавершений хвіст у наступну ітерацію:
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop(); // останній кусок може бути неповним рядком
for (const line of lines) {
if (line.trim()) handle(JSON.parse(line));
}
}
На сервері обов'язкові дві речі: по-перше, коли res.write() повернув false, дочекайтеся події drain (зворотний тиск), інакше пам'ять зростатиме без меж щоразу, коли споживач повільніший за виробника; по-друге, вимкніть gzip-прошарку на потокових маршрутах — gzip мусить буферизувати всю відповідь, перш ніж щось стиснути, і це повністю зводить нанівець потоковість NDJSON. Обидві поведінки підтверджено локальними тестами та кількома незалежними англомовними джерелами, а не здогадками.
7. Коли JSONL — поганий вибір
Негативний простір вибору формату також важливий. Три сценарії мають лишитися з JSON-масивом:
| Вимога | Чому JSONL не підходить | Використовуйте замість |
|---|---|---|
| Валідація структури всього документа | Немає загальної структури; дрейф полів лишиться непоміченим | JSON-масив + Schema |
| Довільний доступ до запису N | Довжини рядків змінні; бінарний пошук неможливий | SQLite / Parquet |
| Глибока вкладеність, посилання між записами | Рядки ізольовані; посиланням немає де жити | Звичайний JSON-документ |
Емпіричне правило в один рядок: якщо записи приходять з часом або споживаються построково (логи, події, вивантаження, навчальні вибірки) — беріть JSONL; якщо невеликий обсяг створюється один раз, споживається один раз і вимагає семантики цілого документа — беріть JSON-масив. Похідні варіанти (JSON Text Sequences з RS-роздільником) — окрема тема, див. статтю про RFC 7464.
FAQ
Чи є реальна різниця між JSONL і NDJSON?
В інженерному сенсі немає. Обидва означають «одне повне JSON-значення в рядку, без зовнішньої обгортки, без ком між рядками», і інструмент, що підтримує один формат, підтримає й інший. Різниця в назвах і звичках екосистем: .jsonl домінує в інженерії даних та ML-датасетах, .ndjson і application/x-ndjson — у потокових HTTP API. При інтеграції із зовнішньою системою дотримуйтеся розширення та media type з її документації.
Чи можна читати .jsonl напряму через JSON.parse?
Файл цілком — ні. JSON.parse приймає рівно один JSON-текст, а JSONL — це безліч незалежних значень, викладених у рядок, тому парсинг цілком завжди падає з «Unexpected token» або «Extra data». Розбийте на рядки та парсьте кожен непорожній окремо, повідомляючи номер рядка при збої.
Якщо один рядок у JSONL-файлі пошкоджено, зіпсований чи весь файл?
Ні — построкова ізоляція збоїв є ядром дизайну формату. Битий рядок впливає лише на себе: пропустіть його, запишіть номер і продовжуйте читання. Для пакетних імпортів поміщайте биті рядки в окремий файл-карантин, завантажуйте коректні записи як зазвичай, а рішення про лагодження та повторний імпорт ухвалюйте за часткою битих рядків.
Як конвертувати JSONL в Excel або CSV?
Спершу перетворіть його на масив або витягніть поля построково, потім прогоніть через табличне перетворення: стисніть через jq -c '.[]' data.jsonl і подайте конвертеру, або прокачуйте кожен рядок через json.loads у pandas. Обережно: розгорнуті вкладені поля дають назви колонок із крапками, масиви всередині клітинок неминуче перетворюються на текст, а довгі ID-колонки варто заздалегідь оголосити рядками, щоб не втратити точність.