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 в ячейке Массив сериализуется 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 в тестах: любая смена конвертера, начинающая терять поля, будет автоматически падать.