JSON Lines と NDJSON:ストリーミングデータ
要点:1 行 1 JSON 値にすると、追記可能・ストリーミング可能・破損が 1 行に閉じるという利点が得られます。代償は全体構造の欠如で、検証は行単位で行う必要があります。

要点:バッチ全体を一度に処理するなら JSON 配列を使い、ストリーミング的な追記と壊れた行の隔離が必要なら JSON Lines を使いましょう。全体構造を検証する必要が残っているなら、行区切り形式に切り替えないでください。
データがメモリを超えるか、途切れなく到着し続けるようになると、「一つの大きな配列」という構造が敵になります。
1. 形式の規則
JSON Lines(.jsonl)と NDJSON(.ndjson)は実質的に同義で、どちらも規則は短いです。
- エンコーディングは UTF-8、BOM なし。
- 各行に完全で正しい JSON 値を一つ。
- 行は
\nで区切る。最終行は改行で終わっても終わらなくてもよい。 - 行内にエスケープされていない改行を置けない。文字列内の改行は
\nと書く。 - 空行は通常無視されるが、厳格な実装は不正として扱うべき。
{"time":"2026-10-30T10:41:45+08:00","level":"info","message":"boot"}
{"time":"2026-10-30T10:41:46+08:00","level":"warn","message":"slow query","costMs":812}
{"time":"2026-10-30T10:41:47+08:00","level":"error","message":"timeout","traceId":"a1b2"}
二つの微妙な違い:JSON Lines のウェブサイトは「1 行 1 値で、値は任意の JSON(スカラーを含む)でよい」ことを強調し、 NDJSON 仕様は「空行なし、トップレベルスカラーなし」といったより厳格な実装規則を加えています。実務では厳格な方に従えばトラブルを避けられます。
2. JSON 配列との比較
| 観点 | JSON 配列 | JSON Lines |
|---|---|---|
| レコードを 1 件追記 | ファイル全体を書き換える(閉じる ] を変える) |
1 行追記するだけ |
| ストリーミング読み取り | 括弧の深さを追うストリーミングパーサーが必要 | 行で分割するだけ |
| 1 件の破損 | ファイル全体がパース不能になる | その行だけが影響を受ける |
| ファイル全体の検証 | 一度にすべて検証できる | 行単位でしかできない |
| 圧縮 | gzip 後は同程度のサイズ | 同じ。ブロック単位で並列圧縮できる |
| ネストの表現 | ネイティブにサポート | 各行内ではネスト可能。行同士は無関係 |
| 典型的な利用者 | すべての JSON ツール | ログシステム、ビッグデータパイプライン、LLM 学習データ |
結論は明快です。データが「レコードの集まり」か「一つの文書」かで形式が決まります。
3. 読み取り:行単位のストリーミング
Node では readline が最も簡単です。
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
/**
* JSON Lines ファイルを行ごとに読み、レコードごとにコールバックを呼ぶ。
* @param filePath ファイルのパス
* @param onRecord 1 レコードの処理関数
* @returns 成功行数と失敗行数を含む統計
*/
async function readJsonLines(
filePath: string,
onRecord: (record: unknown, lineNumber: number) => void,
): Promise<{ okCount: number; badCount: number }> {
const lineReader = createInterface({
input: createReadStream(filePath, { encoding: 'utf8' }),
crlfDelay: Infinity,
});
let okCount = 0;
let badCount = 0;
for await (const rawLine of lineReader) {
const line = rawLine.trim();
if (!line) {
continue;
}
try {
onRecord(JSON.parse(line), okCount + badCount + 1);
okCount += 1;
} catch {
badCount += 1;
}
}
return { okCount, badCount };
}
このコードのメモリ使用量は 1 行のサイズだけに依存し、ファイル全体のサイズには依存しません。これが行区切り形式の最大の価値です。
3.1 一部のフィールドだけ必要なら、まず JSON.parse しない
各レコードが 50 フィールドあり、必要なのが 3 つだけなら、JSON.parse は 50 個すべてのオブジェクトを構築します。
ストリーミングパーサー(stream-json、clarinet)は対象フィールドだけを抽出できますが、実装の複雑さが増します。
経験則:1 レコードが数百 KB 未満なら、JSON.parse を直接呼ぶ方が通常は割に合います。
3.2 中国語テキストと BOM
Windows で生成された .jsonl ファイルはしばしば BOM を伴い、最初の行の JSON.parse は必ず失敗します。読み取り前に BOM を除去するか、最初のチャンクをストリーミングするときに先頭 3 バイトを検査してください。
4. 書き込み:追記と原子性
import { appendFile } from 'node:fs/promises';
/**
* JSON Lines レコードを 1 件追記する。
* @param filePath 対象ファイル
* @param record 書き込むレコードオブジェクト
* @returns 書き込み完了時に解決する Promise
*/
async function appendRecord(filePath: string, record: unknown): Promise<void> {
const line = `${JSON.stringify(record)}\n`;
await appendFile(filePath, line, { encoding: 'utf8' });
}
注意点は二つ。
JSON.stringifyの出力に改行は含まれない(文字列の\nが 2 文字にエスケープされる場合を除く)ので、1 行は常に正確に 1 レコードです。- 複数プロセスが同時に追記する場合、PIPE_BUF(Linux では通常 4096 バイト)より小さい単一の
writeは原子的ですが、それより大きい場合は保証されません。 高並列の場面では「プロセスごとに 1 ファイル、ディスクに書き出した後に統合」に切り替えるか、トランザクションを持つストレージを使ってください。
5. 壊れた行の扱い
これは行区切り形式が事前に決めさせてくる判断です。
| 戦略 | 向いているデータ | コスト |
|---|---|---|
| スキップして数える | 欠落を許容するデータ(ログ、分析イベント) | データが静かに失われるので、アラートが必須 |
| 即座に失敗する | 失ってはならないデータ(請求、帳票) | 1 行の破損がバッチ全体を止める |
| デッドレターファイルへ回す | 後で埋め戻しが必要な場合 | 追加のストレージと再実行の仕組み |
どれを選んでも、行番号と元の内容の断片を記録してください。さもないと後から問題を特定できません。行番号は JSON.parse のエラーメッセージに含まれないので、自分で維持する必要があります。
6. 圧縮と分割
- gzip は
.jsonlを安定して高い比率で圧縮します(フィールド名が繰り返されるため)。.jsonl.gzがおすすめです。 - gzip はブロック単位で圧縮し、ストリーミング解凍をネイティブに支えるので、ファイル全体を解凍する必要はありません。
- 大きなファイルはおよそ
1GBのスライスに分割し、名前に連番を入れると並列処理とレジューム転送が楽になります。 .jsonlを「配列に統合する」前処理は絶対にしないでください。利点がすべて失われます。
7. 他のビッグデータ形式とのトレードオフ
| 形式 | 構造 | スキーマを含む | 向いている用途 |
|---|---|---|---|
| JSON Lines | 行ごとに独立 | なし(外部の契約が必要) | ログ、インポート・エクスポート、学習データ |
| CSV | 表形式 | なし | レポート、Excel 連携 |
| Parquet | 列指向 | あり | 分析クエリ、非常に大規模なオフラインデータ |
| Avro | 行指向 | あり | Kafka メッセージ、スキーマ進化 |
JSON Lines の利点はどの言語、どのツールでもすぐに読み書きできること。欠点はスキーマと列指向圧縮がないことです。 データが 1 台のマシンに収まらなくなり、列指向のクエリを頻繁に行う必要が出たら、Parquet を検討する時です。
8. チェックリスト
- [ ] 拡張子は統一されている(
.jsonlか.ndjson)。混在させない。 - [ ] 書き込み側が「1 行 1 レコード」を保証し、文字列内の改行はエスケープ済み。
- [ ] 読み取り側が BOM を除去し、空行をスキップし、行番号を維持する。
- [ ] 壊れた行の戦略が明示され、監視されている(破損行数 / 総行数)。
- [ ] 大きなファイルはサイズで分割し、gzip と並列読み取りを組み合わせている。
- [ ] フィールド契約がある場合は追加のスキーマを用意し、行単位で検証する。
FAQ
JSON 配列と JSON Lines はどう選べばよいですか?
データがレコードの集まりか、単一の文書かで判断します。レコードを追記し、ストリームとして読むなら JSON Lines を選びます。追記は末尾に 1 行書くだけで、読み取りは行分割だけで済みます。全体構造を一度に検証する必要があるなら JSON 配列のままにします。JSON 配列はすべてを一度に検証できますが、JSON Lines は行単位でしか検証できないからです。
壊れた行が 1 つあってもファイル全体が壊れないのはなぜですか?
JSON Lines の各行は独立した完全な JSON 値で、パースは自然に行単位です。ですから破損データはその行だけに影響します。JSON 配列では構文エラーが 1 つあるとファイル全体がパース不能になり、まさにそれが行区切りデータがログや分析イベントに向く理由です。
JSON Lines を JSON 配列に変換すべきですか?
できます。行ごとに読み、各レコードに JSON.parse を呼び、配列に集めます。ただし全内容がメモリに収まる必要があり、ストリーミング読み取りと 1 行追記の利点を失います。.jsonl を配列に前処理すると形式の利点がすべて失われるので、データがメモリに収まる一度きりの受け渡しに限って妥当です。