JSON у CSV: звідки береться заголовок і чому значення змінюються
Головне: Дев'ять експериментів на справжньому конвертері: стратегія «ключів першого рядка» мовчки втрачає поля, null, відсутність і порожній рядок стають порожніми клітинками, 20-значне число втрачає точність уже на JSON.parse, а крапка в ключі зіштовхує два різні шляхи в одну колонку. Як формується заголовок, які значення змінюються і як обирати стратегію за призначенням.

Коли перетворення JSON на CSV втрачає дані, майже ніколи це не відбувається на кроці «конвертації». Втрата трапляється у двох питаннях, які ніхто не поставив: хто визначив заголовок, і чи можна ще впізнати початкове значення в кожній клітинці.
Поле зникає за тридцять секунд
Багато конвертерів — включно з ідіоматичним конвеєром jq — беруть заголовок лише з першого запису. Відтворіть наслідки у Python:
data = [{"id": 1, "name": "A"}, {"id": 2, "email": "b@x.com"}]
first_keys = list(data[0].keys()) # ['id', 'name']
rows = [[r.get(k, "") for k in first_keys] for r in data]
# email другого запису зникає без помилки й без попередження
Ключа email немає в заголовку, тож він навіть не має права стати порожньою клітинкою — колонку так і не створили, а значення мовчки викинули. Виправлення — об'єднання ключів: просканувати всі записи і створити колонку для кожного ключа, що трапляється хоч раз. Ті самі дані через об'єднання дають заголовок id,name,email, де брак name заповнено порожньою клітинкою, і нічого не втрачається.
Прямий конвертер нашої сторінки /convert/csv використовує саме цю стратегію. На вході [{"id":1,"name":"A"},{"id":2,"email":"b@x.com"}] він дає:
id,name,email
1,A,
2,,b@x.com
Ключі впорядковано за першою появою, а відсутні поля стають порожніми клітинками. І jsonoperations, і jsonbeam називають це «виправленням класичної помилки» — але «використовуй об'єднання» лише половина історії. Об'єднання має власну ціну, про яку — далі.
Три способи побудувати заголовок і те, для кого кожен із них
| Стратегія заголовка | Як будується | Сильна сторона | Ціна |
|---|---|---|---|
| Ключі першого рядка | Сканується лише перший запис | Мало колонок, стабільно, один рядок jq | Нові поля пізніших записів мовчки втрачаються |
| Об'єднання ключів | Скануються всі записи | Жодне поле не втрачається | Розріджені колонки й багато порожніх клітинок за різної форми записів |
| Явний перелік колонок | Задається вручну або схемою | Повний контроль, відтворюваність | Потрібно підтримувати; нові поля додаються вручну |
Критерій вибору не в тому, «що вигадливіше», а в тому, що робитиме з цим CSV споживач далі:
- Колега гляне один раз: ключів першого рядка достатньо, форми записів зазвичай однакові.
- Звіт або дані для конвеєра: об'єднання ключів — мінімум; порожня клітинка сама по собі інформація.
- Автоматичний регулярний експорт: явний перелік колонок. Дрейф форми (додане поле вгорі, перейменований ключ) має гучно падати з помилкою, а не тихо видавати таблицю, яку ніхто не вважає неправильною.
Третій варіант нехтують найчастіше. В автоматизації «конвертація вдалася» і «конвертація правильна» — різні твердження, і явний перелік колонок — єдиний механізм, що їх розрізняє.
Коли ключ містить крапку, шляхи флэтенінгу стикаються
Флэтенінг крапковою нотацією перетворює {"a": {"b": 2}} на колонку a.b. Але якщо ключ сам містить крапку? Перевірено pandas:
import pandas as pd
d = [{"a.b": 1, "a": {"b": 2}}]
pd.json_normalize(d, sep=".")
# Лишається одна колонка ['a.b'] — два цілком різні ключі злилися на одному шляху
Літеральний ключ a.b і вкладений шлях a → b дають однакову назву колонки, одна перекриває іншу, і дані мовчки псуються. Це сконструйований приклад, але в реальних даних таке трапляється: назви метрик моніторингу (cpu.usage) і поля журналів (error.type) обожнюють крапки.
Два способи врятуватися:
- Змінити роздільник.
sep="_"або власний роздільник — після перевірки, що початкові ключі його ніколи не містять. - Змінити нотацію шляхів. JSON Pointer використовує
/як роздільник (/a/b); конвертер Excel від toolvore обирає саме цей шлях. Має ту саму неоднозначність — ключ із/треба писати як~1— але правила екранування стандартизовані в RFC 6901, а не вигадуються кожним інструментом.
Що б ви не обрали, правило має потрапити до документації експорту. Заголовок CSV — єдина «схема», яку ці дані несуть; читачеві ніде відновити ваші правила флэтенінгу.
Масиви: три стратегії, кожна втрачає своє
Масиви — справжній поріг у JSON → CSV: одна клітинка не вміщує кількох значень. Три стратегії та їхні втрати, перевірені на практиці:
| Стратегія | Форма виходу | Що втрачається | Для чого найкраща |
|---|---|---|---|
| JSON-in-cell | Масив серіалізується як JSON-рядок у клітинці | Інструменти таблиць не можуть фільтрувати й агрегувати елементи | Точність понад усе, ручний перегляд |
| Індексовані колонки | tags.0, tags.1 — окрема колонка |
Кількість колонок вибухає за найдовшим масивом; багато порожнин | Малі масиви фіксованої довжини |
| Рядки-вибухи | Один рядок на елемент, поля батька дублюються | Роздування рядків; поведінку порожніх масивів треба визначити окремо | Детальний аналіз, агрегація за елементами |
Наші /convert/csv і /convert/table використовують JSON-in-cell. Вхід [{"orderId":"A001","items":[{"sku":"X1","qty":2},{"sku":"Y9","qty":1}]}] дає:
orderId,items
A001,"[{""sku"":""X1"",""qty"":2},{""sku"":""Y9"",""qty"":1}]"
Коли потрібні рядки-вибухи, pandas record_path робить це просто:
orders = [{"orderId": "A001", "total": 99,
"items": [{"sku": "X1", "qty": 2}, {"sku": "Y9", "qty": 1}]}]
items = pd.json_normalize(orders, record_path=["items"],
meta=["orderId", "total"], sep=".")
# Два деталі; orderId і total дублюються в кожному
У вибухах три пастки, які треба залагодити заздалегідь: одночасний вибух кількох масивів будує декартів добуток; порожні масиви типово не лишають рядків (ваш список клієнтів убуває); дубльовані поля батька вимагають дедуплікації при агрегації за orderId далі за течією. Гайд convertcsvonline ставить «спершу визнач зернистість» першим кроком саме тому — коли зернистість визначено, усі три питання мають одну відповідь.
Перевірено: значення, які змінюються після одного проходу
Тест точності на нашому справжньому конвертері (Node v22.13.1 + papaparse, та сама реалізація, що за /convert/csv):
1. null, відсутність і порожній рядок стають порожніми клітинками. Вхід [{"a":null},{"b":""},{"c":1}] — усі три клітинки виходять порожніми. У CSV немає трьох станів, лише «є символи» і «немає символів». Після зворотного походу всі три читаються як ""; початкова семантика невідновна.
2. Вкладені об'єкти й масиви стають JSON-рядками. Лапки подвоюються за RFC 4180 ({""name"":""张三""}), і клітинка, вставлена назад у будь-який JSON-парсер, відновлює значення — саме тому JSON-in-cell зберігає точність.
3. 20-значне число втрачає точність уже при розборі, і CSV тут не винен. Вхід "id": 9007199254740993; у CSV уже 9007199254740992. JSON.parse у JavaScript працює з числами подвійної точності IEEE 754, тож втрата відбувається під час розбору, ще до будь-якого CSV-конвертера. Python json.load зберігає значення повністю. Поля-ідентифікатори (сніжинки-ID, номери замовлень) мають приходити рядками від джерела. Більше про точність чисел — у чому великі числа втрачають точність.
4. Нулі на початку рядка безпечні на текстовому рівні CSV. "phone": "00123" лишається 00123 у CSV, бо це рядок від початку до кінця, і ніхто його не обрізає. Нулі обрізає пізніший крок, коли Excel сприймає клітинку за число — див. чотири способи конвертувати JSON у Excel.
5. Поля з комами, лапками чи новими рядками. papaparse дотримується RFC 4180: поле в лапках, внутрішні лапки подвоюються. "He said \"hi\", ok" виходить як "He said ""hi"", ok" і проходить туди-назад без втрат. Ручні серіалізатори, що обходять бібліотеку, майже завжди падають саме тут.
Зворотний хід: чим відрізняється перемикач виведення типів
CSV → JSON мусить вгадувати тип кожної клітинки. Наш конвертер типово вимикає виведення типів і повертає все як рядки:
[{ "id": "9007199254740992", "phone": "00123" }]
З увімкненим typeInference поле id стає числом (точність уже не повернеться), а phone лишається рядком — правила виведення виключають форми з нулем на початку, як-от 010, що не є коректним числовим літералом.
Цю асиметрію варто запам'ятати: вимкнене виведення коштує щонайбільше типів; увімкнене може коштувати даних. Колонки, де потрібні числа, перетворюйте явними визначеннями колонок, а не довіряйте глобальному виведенню. Реалізація rowsToRecords із нашого попереднього матеріалу JSON, CSV і табличні дані — це саме цей принцип у коді.
Вибір стратегії за споживачем: одна таблиця рішень
| Хто отримує CSV | Стратегія заголовка | Робота з масивами | Типи |
|---|---|---|---|
| Колега, що відкриє один раз | Об'єднання ключів | JSON-in-cell | Без виведення |
| Excel / звітність | Об'єднання ключів | JSON-in-cell, або індексовані колонки за фіксованої довжини | Колонки-ідентифікатори примусово текстом |
| Імпорт у базу даних | Явний перелік колонок | Окремі майстер/детальні таблиці, або вибух | Явні визначення колонок |
| BI / агрегація | Явний перелік колонок | Рядки-вибухи, зернистість = найменша одиниця аналізу | Явні визначення колонок |
| Автоматизований конвеєр | Явний перелік колонок | JSON-in-cell, вибух далі за течією | Виведення вимкнено, конвертація поколонно |
Спільний закон: чим ближче до людей, тим більше розслаблення можна собі дозволити; чим ближче до машин, тим більше має бути явного.
Чекліст, який можна скопіювати
- [ ] Заголовок — це повне об'єднання ключів чи явний перелік? Якщо ні, можете довести, що жоден запис не втратив поля?
- [ ] Чи можуть ключі містити крапки чи слеші? Перевірено колізію з роздільником флэтенінгу?
- [ ] Стратегію масивів задокументовано? Поведінку порожніх масивів визначено?
- [ ] Що читає споживач для
null, відсутності й порожнього рядка? Оберіть одне правило і зафіксуйте його. - [ ] Колонки-ідентифікатори (ID, номери замовлень, телефони) всюдидорого несуться рядками?
- [ ] Після вибуху кількість рядків дорівнює сумі довжин масивів? Вибірково перевірено?
- [ ] Поля-зразки з комами, лапками й новими рядками проходять туди-назад без змін?
- [ ] Глобальне виведення типів вимкнено при зворотному імпорті, а для числових колонок є явні визначення?
Лише коли всі вісім мають чітку відповідь, цей CSV «правильний», а не просто «успішно сконвертований».
FAQ
Чому конвертер типово не розгортає вкладені об'єкти в колонки?
Розгортання робить кількість колонок некерованою: глибока вкладеність породжує назви на кшталт user.address.geo.lat, а в поєднанні з об'єднанням ключів один запис іншої форми додає цілу пачку колонок. JSON-рядки в клітинках зберігають точність і стабільну кількість колонок; розгортайте явно, коли потрібно.
Що робити, якщо в заголовку дублюються назви колонок?
Зіткнення двох різних ключів після флэтенінгу (як a.b проти вкладеного a→b) треба вирішувати вгорі за течією: змінити роздільник або перейти на JSON Pointer. Для дублікатів усередині одного об'єкта конвертери зазвичай лишають останнє значення; дублікати в заголовку залежать від реалізації — unparse у papaparse не дедуплікує, а Excel мовчки зливає чи відкидає дублікати при відкритті. Тому це в чеклісті.
Чи є дублювання полів батька після вибуху помилкою?
Ні — це дизайн. Рядок вибуху — це детальний рядок, і він мусить з'єднуватися з батьківським записом за ключем. Далі агрегуйте за батьківським ключем; якщо розмір турбує — експортуйте окремо детальний файл і окремо майстер-таблицю.
Як перевірити, що конвертація не втратила полів?
Проведіть аудит ключів: обчисліть об'єднання всіх ключів у вихідному JSON, потім множину колонок заголовка CSV, і візьміть різницю. Непорожня різниця означає втрачені поля. У пакетних конвеєрах закодуйте це як твердження в тестах, тоді будь-яка зміна конвертера, що починає втрачати поля, падає автоматично.