本文へスキップ
jsonbeautifiers
日本語

NDJSONとJSON Lines - 1行に1レコード

巨大なJSON配列はストリーミングできず、追記できず、部分的にも救えません。1行に1つのJSON値がその3つとも解決します。

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

エクスポートをダウンロードすると拡張子は.jsonで、先頭2行はこうなっています。

{"ts":"2026-09-01T10:00:00Z","level":"info","msg":"started"}
{"ts":"2026-09-01T10:00:01Z","level":"warn","msg":"retry 1"}

各行は妥当なJSONです。ファイルは妥当ではありません。開き角括弧もカンマも閉じ角括弧もないので、全体に向けたパーサーはどれも2つ目のレコードで失敗します。これはNDJSONで、選んだのはあなたではありません。ファイルを作った側が選んだのです。代替手段では、そのファイルが到達する予定のサイズに耐えられないからです。

形式の正確なところ

  • 1行に完結したJSON値をひとつ。ふつうはオブジェクトですが、裸の数値や文字列も正当です。
  • UTF-8。バイト順マークは付けません。
  • レコードは改行で区切ります。多くの読み手はCRLFも許容しますが、こちらから出力すべきではありません。
  • 空行は無視されるので、ファイル末尾の改行は問題なく、むしろ慣習です。
  • 全体を囲む配列はなし。レコード間のカンマもなし。
  • 拡張子は.ndjson.jsonl。もっとも、世に出回るファイルの多くは.json.logという名前です。

この形式が成り立つのは、JSON文字列の性質のおかげです。JSON文字列はリテラルな改行を含めません。U+0020未満の制御文字はエスケープが必須なので、正しく直列化されたレコードに生の\nが入ることはありません。だからこそ「改行で分割する」が当て推量ではなく安全なトークナイズになるのです。

NDJSONかJSON Linesか

同じものです。小さな仕様が2つ別々に書かれましたが、ファイルがパースできるかを決める点ではすべて一致しています。1行に1つのJSON値、UTF-8、改行区切り。JSON Lines側は拡張子.jsonlを好み、NDJSON側は.ndjsonを好み、どの読み手も両方を受け付けます。どちらの文書もあなたのコードを1行たりとも変えません。どちらを作っているのかと聞かれたら、正直な答えは「両方」です。

単一の配列にできない3つのこと

流し込む。 JSON配列はひとつの値なので、従来のパーサーは全体を保持してからでないと何も返せません。文書の木を組むにはヒープが入力サイズの何倍もかかります。当サイトのパーサーでは、10MBの文書におよそ294MB。ブラウザではその手前でより硬い壁に当たります。JavaScriptエンジンが単一の文字列を536,870,888文字、約512MBに制限しているからです。それを超えるものはテキストとしてメモリに読み込むことすらできず、パース以前の話になります。NDJSONにはその天井がありません。一度に保持するのは1レコードだけだからです。パーサー側の詳細は巨大なJSONファイルの扱いをどうぞ。

追記する。 JSON配列にレコードを足すとは、閉じ角括弧まで戻り、カンマを書き、レコードを書き、角括弧をまた書くことです。2つの書き手が同時にやればゴミが生まれます。NDJSONへの追記はファイル末尾への1回の書き込みで、事前に読むものは何もありません。世界中のログ転送ツールがこの形式の上に建っているのは、まさにこのためです。

壊れても生き延びる。 JSON配列をどこかで切り詰めれば文書全体を失います。Unexpected end of JSON inputが出て、救えるレコードはゼロです。NDJSONを切り詰めれば失うのは最後の1行だけ。壊れたレコードのコストは1レコードで、行ごとに例外を捕まえる読み手はそのまま進みます。

すでに出会っている場所

Dockerの既定ログドライバーjson-file。コンテナごとに1行1JSONオブジェクトで書きます。Elasticsearchの_bulk API。その変種で、アクション行、次に文書行という並びで、末尾の改行を必須とします。BigQueryのロードジョブ。ソース形式の名前がそのままNEWLINE_DELIMITED_JSONです。ClickHouseのJSONEachRowjq -cが書き出すものすべて。LLMの補完を含むストリーミングAPIは、同一というより隣接です。server-sent eventsはdata:行ごとに1つのJSON値を運びますが独自の枠組みを足すので、SSEストリームはNDJSONファイルではありません。中身のペイロードがそうだとしても。

間違えたときの見え方

上の2レコードの例をひとつの文書としてパースすると、メッセージはすぐ判別できるほど具体的です。

Node(V8):

Unexpected non-whitespace character after JSON at position 61 (line 2 column 1)

Python:

Extra data: line 2 column 1 (char 61)

どちらも同じ意味です。完結したJSON値のパースに成功したあと、入力がまだ続いていた、ということ。それを追っているなら、PythonのExtra dataが変種を扱っています。

逆向きの間違いも同じくらいよくあります。整形済みの文書をNDJSONの読み手に渡すと、最初の行、つまり{だけを単独でパースしようとします。

# Node:   Expected property name or '}' in JSON at position 1 (line 1 column 2)
# Python: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

行単位で読むたびに1行目の2列目でエラーになるのは、書き出す前にインデントされた文書の署名です。

読み書きの仕方

Python。 ファイルハンドルを反復してください。メモリに読み込まないこと。

import json

with open("events.ndjson", encoding="utf-8") as f:
    for n, line in enumerate(f, 1):
        line = line.strip()
        if not line:
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError as e:
            print(f"line {n}: {e}")

形式を壊すのは書き出しのほうで、要は引数ひとつです。

with open("out.ndjson", "w", encoding="utf-8") as f:
    for record in records:
        f.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")

separators=(",", ":")json.dumpsが既定で入れる空白を取り除きます。indent=は絶対に渡さないこと。レコードの内側に改行を吐き、ファイルを破壊します。ensure_ascii=Falseは任意で、非ASCII文字を\uXXXXエスケープにせずそのまま残します。既定はTrueで、妥当ですがサイズは大きくなります。

Node。 readlineがバッファ境界を扱い、crlfDelay: Infinityは2つのチャンクにまたがった\r\nが2つの改行として読まれるのを防ぎます。

import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const rl = createInterface({
  input: createReadStream("events.ndjson", "utf8"),
  crlfDelay: Infinity,
});

let n = 0;
for await (const line of rl) {
  n++;
  if (!line.trim()) continue;
  try {
    handle(JSON.parse(line));
  } catch (e) {
    console.error(`line ${n}: ${e.message}`);
  }
}

Goは既定で正しく振る舞います。json.NewEncoder(w).Encode(v)はコンパクトなレコードを書き、改行を付け足します。<>&をエスケープにしたくなければSetEscapeHTML(false)を呼びます。

jqは空白区切りの値のストリームをそのまま読みます。-cは1行に1つのコンパクトな値を出し、-sはストリームを1つの配列にすすり込みます。

jq -c '.[]' big-array.json > events.ndjson   # 配列からNDJSONへ
jq -s '.'   events.ndjson  > big-array.json  # NDJSONから配列へ
jq -c 'select(.level == "warn")' events.ndjson

pandasは両方向でlines=Trueを受け取り、chunksizeは読み込みをフレームのイテレータに変えるので、ファイルを丸ごと具体化せずに済みます。

import pandas as pd

df = pd.read_json("events.ndjson", lines=True)
df.to_json("out.ndjson", orient="records", lines=True)

for chunk in pd.read_json("events.ndjson", lines=True, chunksize=50_000):
    ...

人が破りがちな規則

固い規則はただひとつ、1レコードがちょうど1行を占めること。つまり各レコードはミニファイされていなければなりません。整形ツールからNDJSONを作るなら、ファイルではなくレコードごとにミニファイしてください。ファイルは改行で終えること。読み手は空行を飛ばしますし、終端を要求する消費側もありますし、cat a.ndjson b.ndjsonは両方のファイルに改行がある場合にだけ正しく動きます。

行の規律がもたらす、過小評価されがちな利点がもうひとつ。このファイルはいまや、手持ちのツールが理解できるテキストです。wc -lがレコードを数え、grepが絞り込み、sortdiffが使え、splitがパーサーなしでファイルを分割します。整形済みの配列はそのどれも与えてくれません。2つのエクスポートの比較がたいてい両方を構造差分に読み込む作業になるのは、そのためです。

使うべきでないとき

ブラウザが一括で消費するもの全般。fetch(...).then(r => r.json())はNDJSONを読めませんし、<script type="application/json">ブロックも読めません。単一の妥当な文書でなければならないもの全般。設定ファイル、APIのレスポンスボディ、スキーマで検証するペイロード、ビューアーに渡して眺めるファイルなど。NDJSONはレコードの流れのための転送・保存形式であって、文書形式ではありません。

その境界を越える必要があるときは、手で編集せず変換してください。NDJSON→JSON変換ツールはブラウザ内で双方向に変換し、あるレコードが失敗したときにはファイル全体を落とすのではなく、その行番号を教えます。