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 в ячейке | Массив сериализуется JSON-строкой в ячейке | Табличные инструменты не могут фильтровать и агрегировать элементы | Сначала сохранность, ручной просмотр |
| Индексированные колонки | tags.0, tags.1, по колонке каждая |
Число колонок взрывается с длиной самого длинного массива; много пустых | Небольшие массивы фиксированной длины |
| Развёрнутые строки | Одна строка на элемент, поля родителя дублируются | Инфляция строк; поведение пустых массивов надо определить отдельно | Детальный анализ, агрегация по элементу |
Наши конвертеры /convert/csv и /convert/table используют JSON в ячейке. Вход [{"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 нет трёх состояний, только «символы есть» и «символов нет». После round-trip все три читаются как ""; исходная семантика невосстановима.
2. Вложенные объекты и массивы становятся JSON-строками. Кавычки удваиваются по RFC 4180 ({""name"":""张三""}), и, вставив ячейку в любой JSON-парсер, значение возвращает — потому JSON в ячейке сохраняет данные.
3. 20-значное целое теряет точность уже при парсинге — и вина не на CSV. Вход "id": 9007199254740993; в CSV уже 9007199254740992. JSON.parse в JavaScript использует IEEE 754 double: потеря происходит при парсинге, до всякого CSV-конвертера. Python json.load сохраняет полностью. Поля-идентификаторы (snowflake ID, номера заказов) должны приходить строками ещё от производителя. Подробнее о точности чисел в почему большие числа теряют точность.
4. Ведущие нули в строках в безопасности на текстовом уровне CSV. "phone": "00123" остаётся 00123 в CSV, потому что это строка от начала до конца, и ничто её не трогает. Нули царапает последующий шаг, когда Excel трактует ячейку как число — см. четыре способа конвертировать JSON в Excel.
5. Поля с запятыми, кавычками или переводами строк. papaparse следует RFC 4180: заключить поле в кавычки, удвоить внутренние кавычки. "He said \"hi\", ok" выходит как "He said ""hi"", ok" и проходит round-trip без потерь. Самописные сериализаторы, обходящие библиотеку, почти всегда падают именно здесь.
Round-trip: что меняет переключатель вывода типов
CSV → JSON должен угадать тип каждой ячейки. Наш конвертер по умолчанию выключает вывод типов и возвращает всё строками:
[{ "id": "9007199254740992", "phone": "00123" }]
С включённым typeInference id становится числом (точность уже потеряна), а phone остаётся строкой — правила вывода исключают формы с ведущим нулём вроде 010, которые не являются легальными числовыми литералами, так что строка выживает по счастливой случайности.
Запомните эту асимметрию: выключенный вывод типов в худшем случае стоит вам типов; включённый может стоить данных. Колонки, которым нужны числа, конвертируются явными определениями колонок, а не глобальным выводом. Реализация rowsToRecords из нашей прошлой статьи JSON, CSV и табличные данные — ровно этот принцип в коде.
Выбрать стратегию по потребителю: таблица решений
| Кто получает CSV | Стратегия заголовка | Обработка массивов | Типы |
|---|---|---|---|
| Коллега, открывающий один раз | Объединение ключей | JSON в ячейке | Без вывода типов |
| Excel / отчётность | Объединение ключей | JSON в ячейке, или индексированные колонки при фиксированной длине | Колонки-идентификаторы принудительно текстом |
| Импорт в базу данных | Явный список колонок | Отдельные таблицы master/detail, или развёртывание | Явные определения колонок |
| BI / агрегация | Явный список колонок | Развёрнутые строки, гранулярность = минимальная единица анализа | Явные определения колонок |
| Автоматический конвейер | Явный список колонок | JSON в ячейке, развёртывание ниже по потоку | Вывод типов выключен, конвертация по колонкам |
Общий закон: чем ближе к человеку, тем больше можно ослаблять; чем ближе к машине, тем больше должно быть явного.
Чек-лист для копирования
- [ ] Заголовок из полного объединения ключей или из явного списка? Если нет — можете доказать, что ни одна запись не потеряла поле?
- [ ] Могут ли ключи содержать точки или слэши? Проверена ли коллизия с разделителем сплющивания?
- [ ] Стратегия массивов задокументирована? Поведение пустых массивов решено?
- [ ] Для
null, отсутствия и пустой строки: что читает потребитель для каждого? Выберите правило и зафиксируйте его. - [ ] Колонки-идентификаторы (ID, номера заказов, телефоны) путешествуют строками от начала до конца?
- [ ] После развёртывания число строк равно сумме длин массивов? Проверено выборочно?
- [ ] Поля-примеры с запятыми, кавычками и переводами строк проходят round-trip без потерь?
- [ ] Глобальный вывод типов выключен при реимпорте, с явными определениями для числовых колонок?
Только когда все восемь имеют ясный ответ, этот CSV «корректен» — а не просто «успешно сконвертирован».
FAQ
Почему конвертер не сплющивает вложенные объекты в колонки по умолчанию?
Сплющивание делает число колонок неуправляемым: глубокая вложенность порождает имена вроде user.address.geo.lat, а в сочетании с объединением ключей одна запись другой формы добавляет целую стопку колонок. JSON-строки в ячейках сохраняют данные и стабильное число колонок; сплющивайте явно, когда нужно.
Что делать, если в заголовке дублируются имена колонок?
Коллизия двух разных ключей после сплющивания (как a.b против вложенного a→b) решается выше по потоку: смените разделитель или перейдите на JSON Pointer. Для дубликатов внутри одного объекта конвертеры обычно оставляют последнее значение; дубликаты в заголовке зависят от реализации — unparse papaparse не дедуплицирует, а Excel при открытии молча объединяет или отбрасывает дубликаты. Именно поэтому это в чек-листе.
Дублированные поля родителя после развёртывания — это баг?
Нет — это дизайн. Развёрнутая строка — строка деталей, и строка деталей должна привязываться к родительской записи по ключу. Агрегируйте ниже по потоку по родительскому ключу; если размер пугает, экспортируйте файл деталей и отдельную master-таблицу.
Как проверить, что моя конвертация не потеряла ни одного поля?
Проведите аудит ключей: вычислите объединение всех ключей исходного JSON, затем множество заголовков колонок CSV, и возьмите разность. Непустая разность означает потерянные поля. В пакетных конвейерах закодируйте это как assert в тестах: любая смена конвертера, начинающая терять поля, будет автоматически падать.