本文へスキップ
jsonbeautifiers
日本語

ネストしたJSONをデータを失わずにCSVへ平坦化する

JSONからCSVへの変換ツールは、文書化されていない判断を十数個あなたの代わりに下しています。その判断を並べます。

このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。

これをだいたいどの変換ツールに通してみてください。

[
  { "id": 1, "name": "Ada", "tags": ["admin"] },
  { "id": 2, "name": "Grace", "tags": ["admin", "ops"], "team": { "name": "core" } }
]

多くのツールが返すのは3列、idnametagsです。teamオブジェクトは消えています。切り詰められたのでも、警告されたのでもなく、ただ無いのです。変換ツールが最初のオブジェクトのキーを読み、それをスキーマとみなしたからです。

CSVは長方形です。列の集合は固定で、1セルにスカラーがひとつ。JSONは木構造で、キーは省略でき、深さは任意で、配列はどこにでも現れます。両者のあいだに正しい対応づけは存在せず、あるのは方針の集合だけです。そして扱いやすく感じる変換ツールとは、その方針を何も言わずにあなたの代わりに選んだツールのことです。

列の決定: 最初の行ではなく和集合

列を決める方法は2つあります。全行を走査して葉のパスの和集合を集めるか、オブジェクトをひとつ読んでそのキーを採るか。

後者は性能上の最適化ではなく、もっともらしい言い訳のついたデータ損失です。Papa Parseは明示的にcolumnsオプションを渡さない限り、最初のオブジェクトのキーからフィールドを決めます。

Papa.unparse([{ a: 1 }, { a: 2, b: 3 }]);
// "a\r\n1\r\n2"   b列は最初から存在しなかったことになる

Papa.unparse(rows, { columns: ['a', 'b'] });
// 和集合は自分で用意する

pandasのjson_normalizeは和集合を採ります。人々がこれに手を伸ばす理由のひとつです。代償として、疎な文書は横に広くほとんど空のテーブルになりますが、それは疎な文書の正直な表現です。列を減らしたいなら、意図して落としてください。

ストリーミングだと本当に難しくなります。NDJSONでは最後の行まで列の集合が確定しないので、ファイルをバッファするか、2回走査するかのどちらかになります。

ネストしたオブジェクトと、公開すべき区切り文字

ネストしたオブジェクトはドット区切りのパスに平坦化されるので、{"team": {"name": "core"}}team.nameになります。pandasは既定で.を使い、変更も許します。

pd.json_normalize({"user": {"name": {"first": "Ada"}}})
# 列: user.name.first

pd.json_normalize(data, sep="__")
# 列: user__name__first

区切り文字は設定可能でなければなりません。ドットはJSONのキーに使える正当な文字だからです。次の2つの文書は同じ列に平坦化されます。

{ "a": { "b": 1 } }
{ "a.b": 1 }

いったん衝突すれば復路は推測になり、一方がもう一方を黙って上書きすることを許す変換ツールは、復元すると形の違う文書になるファイルを作ったことになります。キーに現れない区切り文字を選ぶか、キー内に現れる箇所でエスケープするかしてください。キーにドットが現れないと決めてかからないこと。イベントや解析のペイロードでは日常的に現れます。

配列: 4つの方針と、ひとつの妥当な既定

変換ツールの差がいちばん大きく出るのがここです。

方針 tags: ["admin","ops"]の出力 代償
添字の列 tags.0 = admin、tags.1 = ops 列数はファイル中で最長の配列が決める。1行に400個のタグがあれば全行が400列になる
セル内で連結 tags = admin,ops 値に連結文字が含まれた瞬間に壊れ、[][""]が同じに見える
セル内にJSON tags = ["admin","ops"] 見た目は悪く、正しいクォートが必要だが、往復が正確に成立する
行に展開 2行になり、他のフィールドは繰り返される 行数がレコード数と一致しなくなるので、他の列の集計が二重計上になる

添字の列は、要素数が小さく固定の場合には妥当です。緯度経度の対、RGBの三つ組など。上限のないものでは、列数は典型的な行ではなく最悪の行が決めることになります。

連結はもっとも多い既定であり、もっとも悪い選択です。3方向に同時に情報を失います。区切り文字がデータに現れうること、空配列と空文字列1個の配列が潰れて同じになること、そしてネストしたオブジェクトは結局文字列化されること。

展開しない配列に対しては、セル内にJSONが正しい既定です。 正確に可逆な唯一の方針だからです。セルはRFC 4180に従ってクォートされ、内側の引用符は二重化され、その列がJSONを保持していると知っている読み手はそのまま読み戻せます。Excelでの見た目は悪く、そして正しい。

展開が正しいのは、配列そのものが主題であるときです。注文の明細行、セッション内のイベントなど。record_pathがやるのがそれです。

import pandas as pd

data = [
    {"id": 1, "name": "Ada",   "orders": [{"sku": "A1", "qty": 2}]},
    {"id": 2, "name": "Grace", "orders": [{"sku": "B7", "qty": 1},
                                          {"sku": "C3", "qty": 5}]},
]

pd.json_normalize(data, record_path="orders", meta=["id", "name"])
#   sku  qty  id   name
# 0  A1    2   1    Ada
# 1  B7    1   2  Grace
# 2  C3    5   2  Grace

record_pathは行に変える配列を、metaは各行にコピーする親のフィールドを指定します。orders配列が空のレコードに何が起きるかに注目してください。行が1つも生まれず、丸ごと消えます。また1回の走査で扱えるのは1つの配列だけです。兄弟の配列2つは直積を必要とするからで、配列ごとに走査してidで結合してください。

異種混在の配列

オブジェクトごとにキーが違う配列は、1階層下で起きる同じ和集合の問題です。添字の列では[{"a":1},{"b":2}]items.0.aitems.1.bになり、決して両方が埋まらない2列ができ、しかも列の集合が要素の位置に依存します。展開ではabの列を持つ2行になり、位置が同一性の一部でなくなる分こちらが優れています。スカラーとオブジェクトが混ざった配列には長方形の形が存在しません。セル内にJSONとして直列化してください。

CSVに対応物がない値

CSVの型はひとつ、テキストだけです。それ以外はすべて慣習です。

nullと空文字列。 JSONは両者を区別し、CSVはしません。ほとんどの読み手にとって,,,"",は同じ値なので、往復で一方がもう一方に潰れます。それが問題になるなら、\N(PostgresのCOPYの慣習)のような番兵を書くか、nullが空文字列で戻ってくることを受け入れて、そう明記してください。

真偽値。 小文字のtruefalseがJSONの綴りで、そのまま生き残ります。ExcelはTRUE/FALSEと表示し、1/0を出すツールもあります。どちらも戻す際に明示的な対応づけが必要です。

数値。 007を含むJSON文字列をExcelは7として読み、1E5は100000になります。CSVのクォートでは止まりません。Excelは引用符を外したあとで型を推測するからです。大きな整数は、経路のどこかでfloatを通せば精度の境界にぶつかります。数値はソーステキストのまま出力してください。

日付。 JSONに日付型はなく、ISO 8601やRFC 3339の文字列が慣習です。Excelは2026-03-04のような日付らしい文字列を日付値に変換し、そのマシンのロケール形式で表示し直します。03/04/2026のような曖昧な形式はまったく別の日として戻ってくることがあるので、表計算ソフトを途中の経由地にしてはいけません。

噛みついてくるCSVの作法

RFC 4180は短く、従う価値があります。カンマ、二重引用符、改行を含むフィールドはクォートしなければならず、クォートされたフィールド内のリテラルな二重引用符は2つ重ねて書き、行末はCRLFです。クォートされたフィールド内に埋め込まれた改行は正当ですが、いまだにこれを誤るCSVリーダーは山ほどあります。文字列に改行が含まれるなら、まず消費側を試してください。

区切り文字は常にカンマとは限りません。小数点にカンマを使うロケールのExcelはセミコロン区切りのファイルを期待します。妥当なCSVが同僚のマシンでは1列として開かれるのはこのためです。区切り文字の設定を用意するか、Excelが理解するsep=;の先頭行を付けて配布してください。

次にバイト順マーク。ExcelがUTF-8のCSVをUTF-8として読むのは、ファイルがBOMで始まるときだけです。BOMがないとアクセント付き文字はシステムのコードページで復号されて崩れます。この3バイトはほかのあらゆるツールにとってはノイズなので、BOMはトグルにして、Excel向けの経路でだけ有効にしてください。

CSVインジェクション

セルの先頭文字が=+-@のいずれかだと、Excel、Google スプレッドシート、LibreOfficeはそのセルを数式として扱い、開いた時点で評価します。OWASPはこれをCSVインジェクションと呼びます。ガイダンスによってはタブと復帰も引き金の一覧に加えます。

クォートでは逃げられません。RFC 4180のクォートは、数式が評価される前に読み手が取り除くからです。したがって、JSON内のいずれかの文字列がユーザー由来で、それが誰かの開くCSVに到達するなら、信頼された文脈で走る数式を攻撃者に手渡したことになります。数式はリモートのURLを取得できるので、隣接するセルの中身が外部へ出ていくということでもあります。

緩和策は、セルを書き出す時点で先頭文字を無害化することです。

const RISKY = /^[=+\-@\t\r]/;

function safeCell(value) {
  const s = String(value);
  return RISKY.test(s) ? "'" + s : s;
}

アポストロフィはExcelに内容をテキストとして扱わせます。ただし無料ではありません。表計算ソフト以外の読み手にとってそれはデータの一部になったので、その値については往復が壊れます。前置は人が表計算ソフトで開くファイルには正しく、機械が読み戻すファイルには誤りです。つまり隠れた既定ではなく、エクスポートごとのスイッチであるべきものです。

逆方向へ戻す

CSVからJSONには大きな罠がひとつあります。型推論です。ファイル中のあらゆる値はテキストなので、変換ツールはどれが数値かを推測し、予測どおりに失敗します。0077に、1E5100000に、1.01になります。郵便番号、部品番号、電話番号、バージョン文字列はすべて同じ規則で死にます。安全な既定は、すべての値を文字列として出し、呼び出し側が知っているものだけをキャストすること。推論はファイル全体のヒューリスティックではなく、列ごとのオプトインにします。CSV→JSON変換ツールがそのスイッチを明示しているのは、まさにこの理由からです。

明言する価値のある既定方針

判断 既定 理由
すべての葉パスの和集合、ソート済み 最初のオブジェクトの標本抽出は警告なしにフィールドを落とす
ネストしたオブジェクト ドット区切りのパス、区切り文字は設定可能 キーは区切り文字を正当に含みうる
配列 セル内にJSON 可逆な唯一の方針。配列がレコードそのものなら展開する
空のコンテナ []{}をそのまま nullとも空文字列とも区別できる
null 空セル、ただし明記する 区別が意味を担うなら番兵を使う
数値 ソーステキストのまま 出力経路で決してfloatを通さない
行末 CRLF RFC 4180に従う。LFのほうが壊すリーダーが多い
BOM 既定は無効、Excel向けトグルあり パイプラインには正しくExcelには誤りなので、利用者に選ばせる
数式の文字 Excel経路でのみ前置 前置はデータを変えるので、黙って行ってはいけない

これらが唯一の擁護可能な答えというわけではありません。要点は、どの変換ツールも9つの判断すべてを、あなたに告げるかどうかによらず下しているということ、そして目視できない大きさのペイロードを任せられるのは告げてくれるツールだけだということです。フラット化ツールは決定前にパスの集合を見せ、テーブル表示はこれから得られる長方形を見せます。