JSON と CSV、表形式データの相互変換
要点:CSV は二次元、JSON は木構造です。変換の難しさはコードではなく、配列をどう平坦化し、型をどう保つかを事前に決めることにあります。

要点:人間がエクスポートを読むならドット付きキーと JSON 文字列化した配列で平坦化し、データベースに読み込むなら親テーブルと関連テーブルに分割し、ピボット目的のときだけ配列を行に展開しましょう。復元時は大域的な推論ではなく明示的な列定義で変換します。
木を表に平坦化すると必ず階層が失われ、表を木に戻すと必ず型を推測することになります。どちらの方向にも事前に規則を明示する必要があります。
1. CSV が実際に定めていること
RFC 4180 は数ページしかなく、多くの人が思うより少ないことを定めています。
- 各レコードは 1 行を占め、フィールドはカンマで区切る。
- 行終端は CRLF だが、実際には
\nも受け入れられる。 - カンマ、二重引用符、改行を含むフィールドは二重引用符で囲む。
- フィールド内の二重引用符は二つの二重引用符
""と書く。 - 先頭行はヘッダーであってもよい。
- 文字エンコーディングは規定せず、型も規定しない。
id,name,remark
1,山田,"JSON が好きで、末尾カンマが嫌い"
2,佐藤,"彼は言った:""この行には引用符がある"""
3,鈴木,"複数行
の備考"
規則 3 に注意してください。改行を含むフィールドは合法なので、\n で分割してから , で分割する素朴なパーサーは必ず誤ります。
2. JSON → CSV:まず構造を決める
木構造を二次元の表に平坦化する戦略は三つしかありません。
戦略 1:フィールドを平坦化する(最も一般的)
{ "id": 1, "user": { "name": "山田", "city": "東京" }, "tags": ["a", "b"] }
id,user.name,user.city,tags
1,山田,東京,"[""a"",""b""]"
- ネストしたオブジェクトのキーを
.で連結する。 - 配列は列として表現できないので、
JSON.stringifyしてそのままセルに入れる(それでも合法な CSV フィールドです)。 - 欠点:表計算ツールは配列をフィルタできず、二度目のパースが必要です。
戦略 2:配列を複数行に展開する
id,user.name,tag
1,山田,a
1,山田,b
一対多の関係で、下流の利用者が要素ごとに集計する場合に適します。欠点は親フィールドが重複し、行数が膨らむことです。
戦略 3:複数のテーブルに分割する
メインテーブルと、id で結合する関連テーブル。最も綺麗な方法ですが、「単一のエクスポート」を超えてデータモデリングに近づきます。
どれを選ぶか。 人間向けのエクスポートには戦略 1、データベースへの読み込みには戦略 3、ピボットには戦略 2 を使います。
3. CSV → JSON:型はどう推測されるか
CSV に型はないので、復元の各段階は推測です。
| セルの内容 | 考えられる意味 | 数値に直接変換するリスク |
|---|---|---|
00123 |
社員 ID、郵便番号 | 先頭のゼロが失われる → 123 |
1.10 |
バージョン番号、金額 | 1.1 になる |
1e5 |
識別子か指数表記 | 100000 になる |
2026-10-30 |
日付か文字列 | タイムゾーンと形式の曖昧さ |
13800138000 |
電話番号 | 11 桁ならまだ安全だが、一部の ID は精度を失う |
"" |
空文字列か null | 意味の曖昧さ |
推奨の既定:すべてを文字列として扱い、スキーマか明示的な列定義でどの列を数値や真偽値にするか決めます。 「賢い推論」はエクスポート時には楽しく、インポート時には事故の源になります。
type ColumnType = 'string' | 'number' | 'boolean' | 'json';
/**
* 列定義に従って CSV の行をオブジェクトに変換する。
* @param rows 生の行配列。先頭行はヘッダー
* @param columnTypes 列名から型へのマッピング
* @returns 変換されたオブジェクトの配列
*/
function rowsToRecords(
rows: string[][],
columnTypes: Record<string, ColumnType>,
): Array<Record<string, unknown>> {
const [headerRow, ...dataRows] = rows;
return dataRows.map((dataRow) => {
const record: Record<string, unknown> = {};
headerRow.forEach((columnName, columnIndex) => {
const rawValue = dataRow[columnIndex] ?? '';
const columnType = columnTypes[columnName] ?? 'string';
if (columnType === 'number') {
const numericValue = Number(rawValue);
record[columnName] = Number.isFinite(numericValue) ? numericValue : null;
return;
}
if (columnType === 'boolean') {
record[columnName] = rawValue === 'true' || rawValue === '1';
return;
}
if (columnType === 'json') {
try {
record[columnName] = JSON.parse(rawValue);
} catch {
record[columnName] = rawValue;
}
return;
}
record[columnName] = rawValue;
});
return record;
});
}
4. Excel にまつわる三つの落とし穴
4.1 エンコーディング
Excel が BOM なしの UTF-8 CSV を開くと、日本語テキストが文字化けします。エクスポート時に BOM(\uFEFF)を付けるのが最も安い互換策です。
const bom = '\uFEFF';
const csvContent = `${bom}${headerLine}\n${bodyLines.join('\n')}`;
4.2 長い数値が指数表記になる
18 桁のマイナンバーや 19 桁の注文番号は、Excel で 1.23457E+17 と表示され、値自体がすでに切り詰められています。
フィールドの先頭にタブを付けたり、数式(="1234...")として包んだりするとテキスト書式を保てますが、セルが数式になります。
より安全な方法は、ユーザーに「インポート機能で列をテキストに指定する」よう伝えるか、エクスポートと一緒に .xlsx を提供することです。
4.3 ロケールに影響される区切り文字
一部のヨーロッパのロケールでは、区切り文字の既定がセミコロン ; です。地域をまたいで配布するときは、CSV をカンマに固定してその旨を明示するか、TSV(タブ区切り)版を提供しましょう。TSV の方がロケールに寛容です。
5. 大きなファイルの扱い
10 万行を超える CSV を一度にメモリに読み込まないでください。
import { createReadStream } from 'node:fs';
import { parse } from 'csv-parse';
const parser = createReadStream('orders.csv').pipe(
parse({ columns: true, skipEmptyLines: true, bom: true }),
);
for await (const record of parser) {
// 1 レコードずつ処理する。メモリ使用量はファイルサイズに依存しない
}
エクスポート方向も同じです。巨大な文字列を連結するのではなく、随時生成してストリームに書き込みます。
6. 往復一貫性の自己チェック
CSV の往復(JSON → CSV → JSON)が完全に同一になることはほとんどありませんが、どの損失が許容できるかを定義できます。
| チェック | 期待 |
|---|---|
| フィールド数 | 同一 |
| 文字列値 | 同一(先頭のゼロを含む) |
| 数値型 | 推論ではなく列定義で決まる |
| ネストしたオブジェクト | ドット付きパスで復元可能 |
| 配列 | JSON 文字列へのシリアライズ後に可視 |
| 空の値 | null か空文字列に正規化。どちらか一方だけを選ぶ |
この表をテストに入れる方が、口頭の合意よりはるかに効果的です。
7. チェックリスト
- [ ] 「1 行 1 レコード」の意味論を明示し、エクスポートのファイル名に反映している。
- [ ] 精度を失う可能性のある列はすべて文字列としてエクスポートしている。
- [ ] 日本語の CSV には BOM を含めるか、エンコーディングを明示している。
- [ ] カンマ、引用符、改行を含むフィールドを正しくエスケープしている(ライブラリを使い、文字列を手で連結しない)。
- [ ] 大きなファイルはストリーミング読み書きを通す。
- [ ] インポートは列定義に従って型変換し、大域的な賢い推論を使わない。
FAQ
平坦化、行の展開、テーブル分割はどう選べばよいですか?
人間向けのエクスポートには平坦化を使います。ネストしたキーを . で連結し、配列をセルに JSON.stringify します。代償は、表計算でその配列をフィルタできないことです。対象がデータベースなら親テーブルと関連テーブルに分割します。下流の利用者が要素ごとに集計するなら配列を行に展開し、親フィールドの重複を受け入れます。
CSV のインポートが型を自動推論すべきでないのはなぜですか?
RFC 4180 は区切り文字とエスケープを定めますが型は定めないので、復元の各段階は推測になります。00123 は先頭のゼロを失い、1.10 は 1.1 になり、1e5 は 100000 になります。既定ではすべてを文字列として扱い、スキーマか明示的な列定義でどの列を数値や真偽値にするか決めてください。
CSV の往復で元の JSON が正確に再現されることを期待すべきですか?
ほとんど再現されません。代わりに許容できる損失を定義してください。フィールド数は同一、文字列値は先頭のゼロを含めて同一、数値型は列定義で決まる、ネストしたオブジェクトはドット付きパスで復元可能、配列はシリアライズ後に可視、空の値は null か空文字列に正規化してどちらか一方だけを選ぶ、という具合です。