JSON から CSV へ:ヘッダーはどこから来て、値はなぜ変わるのか
要点:実際のコンバーターで九つの実験を実施。先頭行キー方式はフィールドを黙って欠落させ、null・欠損・空文字はすべて空セルになり、20 桁の整数は JSON.parse の時点で精度を失い、ドットを含むキーは別々のパスが同一列に衝突します。ヘッダーの生成方法、値の変化、下流用途ごとの戦略選択を解説します。

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 一行で可能 | 後続レコードの新フィールドが黙って欠落 |
| キー結合 | 全レコードを走査 | フィールドを失わない | レコード形状が揃わないと列が疎になり空セル増加 |
| 明示的列リスト | 手動または schema で指定 | 完全に制御可能、再現性あり | 保守が必要、新フィールドは手動追加 |
選定基準は「どちらが高機能か」ではなく、この 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)。toolvore の Excel コンバーターはこの方式です。キーに/があれば~1と書くという同種の曖昧さはありますが、エスケープ規則が RFC 6901 として標準化されている点が違います。
どれを選んでも、規則はエクスポート文書に書き込む必要があります。CSV のヘッダーはこのデータが持つ唯一の「schema」であり、読者にはあなたのフラット化規則を復元する場所が他にありません。
配列の扱い:三つの処理、それぞれ異なるものを失う
配列は 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. 二十桁の整数は解析の時点で精度を失う。責任は CSV にありません。 入力 "id": 9007199254740993 に対し、CSV にはすでに 9007199254740992 が入ります。JavaScript の JSON.parse は IEEE 754 倍精度を使うため、損失はどの CSV コンバーターも走る前の解析段階で起きます。Python の json.load は完全に保持します。識別子的なフィールド(Snowflake ID、注文番号)は上流から文字列で送らせるべきです。数値精度の詳細は大きな数字が精度を失う理由へ。
4. 文字列の先頭ゼロは CSV のテキスト層では安全。 "phone": "00123" は CSV でも 00123 のままです。最初から最後まで文字列であり、剥ぎ取る者がいないからです。先頭ゼロを剥ぐのは、後段で Excel がセルを数値として扱う時です。JSON を Excel に変換する四つの方法を参照。
5. カンマ・引用符・改行を含むフィールド。 papaparse は RFC 4180 に従います。フィールドを引用符で囲み、内部の引用符を倍化する。"He said \"hi\", ok" は "He said ""hi"", ok" と出力され、往復しても無損です。ライブラリを bypass して手で文字列を組み立てると、ほぼ必ずここで失敗します。
往復還元:推論スイッチの有無の差
CSV → JSON は全セルの型を推測します。当サイトのコンバーターは既定で型推論を無効にし、すべて文字列として還元します。
[{ "id": "9007199254740992", "phone": "00123" }]
typeInference を有効にすると id は数値になり(精度はもう戻りません)、phone は文字列のまま残ります。推論規則が 010 のような先頭ゼロ形式(正しい数値リテラルではない)を除外しているためです。
この非対称性を覚えておく価値があります。推論を切れば失うのは多くとも型。推論を付けると、データを失っているかもしれない。 数値が必要な列は明示的な列定義で変換し、大域推論を信用しないこと。以前の記事 JSON と CSV・表データの相互変換の rowsToRecords は、列定義ごとに型を変換するこの原則の実装です。
下流用途で戦略を選ぶ:一枚の意思決定表
| CSV の受け手 | ヘッダー戦略 | 配列の扱い | 型の扱い |
|---|---|---|---|
| 同僚が一度開くだけ | キー結合 | JSON-in-cell | 推論なし |
| Excel / 帳票 | キー結合 | JSON-in-cell、長さ固定ならインデックス列 | 識別子列は強制テキスト |
| データベース投入 | 明示的列リスト | 主表 + 関連表に分割、または行展開 | 明示的列定義 |
| BI / 集計分析 | 明示的列リスト | 行展開、粒度 = 分析の最小単位 | 明示的列定義 |
| 自動パイプライン | 明示的列リスト | JSON-in-cell、行展開は下流で | 推論オフ、列ごとに変換 |
共通する法則:人に近いほど緩くてよく、機械に近いほど明示的でなければならない。
コピーして使えるチェックリスト
- [ ] ヘッダーは全レコードのキー結合か、明示的列リストか。後者でないなら、フィールドを欠落させたレコードがないと証明できるか。
- [ ] キーにドットやスラッシュが入る可能性は?フラット化の区切り文字との衝突を確認したか。
- [ ] 配列の扱いは文書化したか。空配列で行を残すかは決めたか。
- [ ]
null・欠損・空文字の三入力を、下流はそれぞれ何として読むか。一つの規則を選び固定する。 - [ ] 識別子の列(ID・注文番号・電話番号)は終始文字列で運んでいるか。
- [ ] 行展開後の行数 = 各配列の長さの合計、を抜き取り確認したか。
- [ ] カンマ・引用符・改行を含むサンプルフィールドが、往復しても変わらないか。
- [ ] 還元時に大域型推論を切ってあるか。数値が必要な列に明示的定義はあるか。
この八項すべてに明確な答えがあって初めて、その CSV は「変換に成功した」だけでなく「変換が正しい」と言えます。
よくある質問
コンバーターはネストしたオブジェクトを既定で複数列に展開しないのですか
展開すると列数が制御不能になります。深いネストは user.address.geo.lat のような長い列名を生み、キー結合と組み合わさると、形状の違うレコード一つで列が一気に増えます。セルに JSON 文字列を入れる方式は忠実さと安定した列数を両立します。必要なら明示的な規則で自分で展開してください。
ヘッダーに重複した列名がある場合は
フラット化後に異なるキーが衝突する場合(a.b とネストした a→b など)は上流で解決します。区切り文字を変えるか JSON Pointer に切り替えます。同一オブジェクト内の重複キーは通常最後の値が残り、ヘッダー部の重複は実装次第です。papaparse の unparse は重複を除去せず、Excel はファイルを開くときに黙って合併または破棄します。だからチェックリストに載っています。
行展開後に親フィールドが重複するのはバグですか
いいえ、設計です。行展開の意味は「明細行」であり、明細行は親キーで主レコードに結合できなければなりません。下流では親キーでグループ化して集計します。サイズが気になるなら、明細ファイルと主表を別々にエクスポートします。
変換がフィールドを失っていないか検証するには
キー監査を行います。元の JSON の全キー結合と CSV ヘッダーの列集合を取り、差分を計算します。差分が空でなければフィールドが失われています。バッチ処理ではこの断言をテストに組み込めば、コンバーターの変更がフィールドを落とし始めた時点で自動的に検知できます。