JSON Toolkit

JSON у CSV: звідки береться заголовок і чому значення змінюються

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

Середній~6 хв читанняОпубліковано 2026-11-04Оновлено 2026-11-04
Стратегії заголовків і збереження значень при перетворенні JSON на CSV

Коли перетворення 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) обожнюють крапки.

Два способи врятуватися:

  1. Змінити роздільник. sep="_" або власний роздільник — після перевірки, що початкові ключі його ніколи не містять.
  2. Змінити нотацію шляхів. 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, і візьміть різницю. Непорожня різниця означає втрачені поля. У пакетних конвеєрах закодуйте це як твердження в тестах, тоді будь-яка зміна конвертера, що починає втрачати поля, падає автоматично.