JSONLがreadlineとsplitlinesで壊れる U+2028の落とし穴

JSONL(NDJSON)は「1行に1件の JSON」という単純な形式なのに、正しく書いたファイルが読み手によっては Unterminated string で落ちる。原因は、JSON の文字列にエスケープなしで入ってよい U+2028(LINE SEPARATOR)などの文字を、読み手が行の区切りとして扱うことである。

ハシトシステムの JSONL⇄JSON変換 は \n と \r だけで行を切っているので、このファイルを 4 件すべて読める。この記事では、公開中のツールの関数を Node.js で取り出し、同じファイルを Node.js の readline と Python の読み方別に読み比べる。あわせて、どの文字で切れるかを調べ、生成側と読み側の対策を確かめる。

U+2028 は JSON の文字列にそのまま入ってよい

RFC 8259 の第7節が「必ずエスケープする」としているのは、引用符、バックスラッシュ、制御文字(U+0000 から U+001F)だけである。U+2028 と U+2029 はこの範囲の外にある。

JSON Lines の仕様(jsonlines.org)は、行の区切りを \n と決めている(\r\n も可)。つまり、U+2028 を生で含む行は、JSON としても JSONL としても正しい。切ってしまう読み手のほうが仕様とずれている。

生成側も素通しする。実測すると、JSON.stringify は U+2028、U+2029、U+0085 をそのまま出力した。Python の json.dumps は既定の ensure_ascii=True なら \u2028 にするが、日本語を読めるように ensure_ascii=False を付けると生のまま出す。

読み方で読める件数が変わる

次の 4 件を JSONL に書いて、読み方ごとに JSON.parse / json.loads に通した(コードは後ろの「実際に試す」にある)。

id text の中身
1 ok
2 a U+2028 b
3 c U+0085 d
4 e U+2029 f
読み方 成功 失敗(断片の数)
ハシトシステムのツールの parseJsonl 4 0
Node.js text.split('\n') 4 0
Node.js readline の createInterface 2 4
Python for line in open(path) 4 0
Python bytes.splitlines() 4 0
Python str.splitlines() 1 6
Python codecs.open(path) を行で回す 1 6

Node.js v24.11.1 と Python 3.11.9 での結果である。read().splitlines() は 4 件のうち 3 件を割り、割れた断片がそれぞれ失敗する。「壊れた行は読み飛ばす」形で try を書いていると、例外は外に出ず、4 件のはずが 1 件になるだけで終わる。

Python の open はテキストモードの既定(newline=None)で \n、\r、\r\n だけを行末として扱うと公式ドキュメントにあり、実測もこれに合う。同じファイルでも、ファイルオブジェクトを回すか splitlines() するかで結果が変わる。

どの文字で切れるのか

Python の str.splitlines については、全コードポイント(0 から 0x10FFFF)を1文字ずつ入れて調べた。行を切った文字は 10 個で、公式ドキュメントの一覧と一致する。Node.js の readline については、同じ 10 個を候補にして調べた。

文字 名前 str.splitlines readline JSON の文字列に生で入るか
U+000A LF 切る 切る 入らない
U+000D CR 切る 切る 入らない
U+000B VT 切る 切らない 入らない
U+000C FF 切る 切らない 入らない
U+001C〜U+001E FS / GS / RS 切る 切らない 入らない
U+0085 NEL 切る 切らない 入る
U+2028 LS 切る 切る 入る
U+2029 PS 切る 切る 入る

U+001F 以下の文字は JSON ではエスケープが必須なので、正しい JSON の行に生で現れることはない。問題になるのは U+0085、U+2028、U+2029 の 3 文字だけである。

readline が U+2028 で切る理由はソースにある。Node.js v24.11.1 の lib/internal/readline/interface.js の定数は次のとおりである。

1
const lineEnding = /\r?\n|\r(?!\n)|\u2028|\u2029/g;

一方、Node.js のドキュメントの 'line' イベントの説明が挙げている行末は \n、\r、\r\n の3つで、ここに U+2028 は書かれていない。ドキュメントだけを読んでいると気づきにくい。

例外が出ずに値が増えることもある

割れた行はたいてい壊れた JSON になるので例外が出る。ただし、文字列の中に区切り文字が2つあると、間の断片が単独で正しい JSON になることがある。次の1行を readline で読むと、3 つの断片に分かれ、真ん中の 42 は JSON.parse を通る。

1
2
3
4
5
{"id":5,"text":"x<U+2028>42<U+2028>y"}
↓
{"id":5,"text":"x
42
y"}

3 つのうち 2 つが失敗するので、失敗を読み飛ばす実装では、元の 1 件が消えて、存在しない 42 というレコードが 1 件残る。件数だけを見ていると、元が 1 件で結果も 1 件なので一致してしまう。

対策は生成側と読み側の両方にある

生成側の対策は、U+2028、U+2029、U+0085 を \uXXXX に書き換えてから出力することである。JSON の文字列の中では同じ値に戻るので、中身は変わらない。Python なら ensure_ascii=True(既定)のままにすれば、非 ASCII がすべてエスケープされる。

1
const escapeLs = (t) => t.replace(/[\u0085\u2028\u2029]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));

この 3 文字が JSON.stringify の結果で文字列の外に現れることはないので、結果全体に掛けてよい。実測では、対策後のファイルは readline でも 4 件すべて読め、パースした値は元と一致した。

読み側の対策は、\n だけで切ることである。他人が作ったファイルを読むなら、生成側の対策には頼れない。Python はファイルオブジェクトをそのまま回す。Node.js は readline の代わりに、「実際に試す」のコードにある readJsonlLines のようなジェネレーターを使う。createReadStream に encoding を渡すと、チャンクの境目で分かれたマルチバイト文字も正しく復元される。

もう一つの落とし穴: 先頭の BOM

jsonlines.org は BOM(U+FEFF)を含めてはならないと書いている。RFC 8259 の第8.1節も、生成側は BOM を付けてはならず、パーサは無視してもよいとしている。無視は義務ではないので、付いていれば実装によっては落ちる。実測では JSON.parse は BOM つきの行で SyntaxError になり、Python の json.loads は Unexpected UTF-8 BOM (decode using utf-8-sig) を返した。Python では encoding="utf-8-sig" で開けば BOM を読み飛ばせる。

実際に試す

Node.js 18 以降(記事では v24.11.1)と Python 3(記事では 3.11.9)で試せる。外部パッケージは使わない。どちらも実行したディレクトリに sample.jsonl などを書き出すので、空のディレクトリで実行する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// try.mjs: U+2028 を含む JSONL を、公開中の変換ツールと Node.js の readline で読み比べる
import { createReadStream, createWriteStream, readFileSync, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { Readable } from 'node:stream';

const html = await (await fetch('https://hashitosystem.com/tools/jsonl/')).text();
const s = html.indexOf('function parseJsonl(');
const e = html.indexOf('// 例外を利用者向け', s);
const { parseJsonl, jsonToJsonl } = new Function(html.slice(s, e) + '; return { parseJsonl, jsonToJsonl };')();

const hex = (t) => [...t].filter((c) => c > '~').map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');

// 1. JSON.stringify は U+2028 をそのまま出力する(エスケープしない)
const records = [
{ id: 1, text: 'ok' },
{ id: 2, text: 'a\u2028b' },
{ id: 3, text: 'c\u0085d' },
{ id: 4, text: 'e\u2029f' },
];
const jsonl = jsonToJsonl(JSON.stringify(records)) + '\n';
writeFileSync('sample.jsonl', jsonl);
console.log('JSON.stringify の出力に残る非ASCII:', hex(jsonl));

// 2. 読み方ごとに、JSON.parse に通った件数と失敗した行の数を数える
const tally = (lines) => {
let ok = 0, ng = 0;
for (const l of lines) {
if (l === '') continue;
try { JSON.parse(l); ok++; } catch { ng++; }
}
return `成功 ${ok} / 失敗 ${ng}`;
};
const text = readFileSync('sample.jsonl', 'utf8');
console.log('変換ツールの parseJsonl :', parseJsonl(text).parsed, '件');
console.log("text.split('\\n') :", tally(text.split('\n')));

const viaReadline = [];
for await (const l of createInterface({ input: createReadStream('sample.jsonl'), crlfDelay: Infinity })) viaReadline.push(l);
console.log('readline :', tally(viaReadline));

// 3. readline が行末として扱う文字を候補から調べる
const candidates = { LF: '\n', CR: '\r', VT: '\v', FF: '\f', FS: '\x1c', GS: '\x1d', RS: '\x1e', NEL: '\x85', LS: '\u2028', PS: '\u2029' };
const splitters = [];
for (const [name, c] of Object.entries(candidates)) {
const got = [];
for await (const l of createInterface({ input: Readable.from([`a${c}b\n`]), crlfDelay: Infinity })) got.push(l);
if (got.length > 1) splitters.push(name);
}
console.log('readline が行を切る文字 :', splitters.join(' '));

// 3b. 区切り文字が2つあると、間の断片が正しい JSON として通ってしまう
const ghost = JSON.stringify({ id: 5, text: 'x\u202842\u2028y' }) + '\n';
const viaGhost = [];
for await (const l of createInterface({ input: Readable.from([ghost]), crlfDelay: Infinity })) viaGhost.push(l);
console.log('断片:', JSON.stringify(viaGhost), '→', tally(viaGhost));

// 4. 生成側で対策: U+2028 / U+2029 / U+0085 を \uXXXX にしてから書く
const escapeLs = (t) => t.replace(/[\u0085\u2028\u2029]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
const safe = records.map((r) => escapeLs(JSON.stringify(r))).join('\n') + '\n';
writeFileSync('safe.jsonl', safe);
const viaReadline2 = [];
for await (const l of createInterface({ input: createReadStream('safe.jsonl'), crlfDelay: Infinity })) viaReadline2.push(l);
console.log('対策後の readline :', tally(viaReadline2));
console.log('対策後の値が元と同じか :', JSON.stringify(safe.trim().split('\n').map((l) => JSON.parse(l))) === JSON.stringify(records));

// 5. 読み側で対策: \n だけで切るストリームリーダー(壊れた既存ファイルも読める)
async function* readJsonlLines(path) {
let buf = '';
for await (const chunk of createReadStream(path, { encoding: 'utf8' })) {
buf += chunk;
const parts = buf.split('\n');
buf = parts.pop();
yield* parts;
}
if (buf) yield buf;
}
const viaOwn = [];
for await (const l of readJsonlLines('sample.jsonl')) viaOwn.push(l);
console.log('\\n だけで切るリーダー :', tally(viaOwn));

// 6. 先頭の BOM
const bom = '\ufeff{"id":1}\n';
try { JSON.parse(bom.split('\n')[0]); console.log('BOM つきの1行目 : 成功'); } catch (err) { console.log('BOM つきの1行目 :', err.name); }
try { parseJsonl(bom); console.log('ツールで BOM つき : 成功'); } catch (err) { console.log(`ツールで BOM つき : ${err.line} 行目でエラー`); }
1
2
3
4
5
6
7
8
9
10
11
12
$ node try.mjs
JSON.stringify の出力に残る非ASCII: U+2028 U+0085 U+2029
変換ツールの parseJsonl : 4 件
text.split('\n') : 成功 4 / 失敗 0
readline : 成功 2 / 失敗 4
readline が行を切る文字 : LF CR LS PS
断片: ["{\"id\":5,\"text\":\"x","42","y\"}"] → 成功 1 / 失敗 2
対策後の readline : 成功 4 / 失敗 0
対策後の値が元と同じか : true
\n だけで切るリーダー : 成功 4 / 失敗 0
BOM つきの1行目 : SyntaxError
ツールで BOM つき : 1 行目でエラー
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# try.py: U+2028 を含む JSONL を Python の読み方別に読み比べる
import codecs
import json
import sys

records = [
{"id": 1, "text": "ok"},
{"id": 2, "text": "a\u2028b"},
{"id": 3, "text": "c\u0085d"},
{"id": 4, "text": "e\u2029f"},
]
with open("sample.jsonl", "w", encoding="utf-8", newline="\n") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")


def tally(lines):
ok = ng = 0
for line in lines:
if not line.strip():
continue
try:
json.loads(line)
ok += 1
except json.JSONDecodeError:
ng += 1
return f"成功 {ok} / 失敗 {ng}"


print("Python", sys.version.split()[0])

# 1. 読み方の比較
with open("sample.jsonl", encoding="utf-8") as f:
print("for line in open() :", tally(f))
with open("sample.jsonl", encoding="utf-8") as f:
print("read().splitlines() :", tally(f.read().splitlines()))
with open("sample.jsonl", "rb") as f:
print("bytes.splitlines() :", tally(f.read().splitlines()))
with codecs.open("sample.jsonl", encoding="utf-8") as f:
print("codecs.open() :", tally(f))

# 2. str.splitlines が行を切る文字を全コードポイントから調べる
cuts = [c for c in range(0x110000) if len(f"a{chr(c)}b".splitlines()) > 1]
print("splitlines が切る文字 :", " ".join(f"U+{c:04X}" for c in cuts))

# 3. そのうち、エスケープなしで JSON 文字列に入れられる文字
raw = [c for c in cuts if chr(c) in json.dumps(chr(c), ensure_ascii=False)]
print("JSON にそのまま入る文字 :", " ".join(f"U+{c:04X}" for c in raw))

# 4. ensure_ascii の既定値(True)なら \uXXXX になる
print("ensure_ascii=True :", json.dumps("a\u2028b"))
print("ensure_ascii=False :", ascii(json.dumps("a\u2028b", ensure_ascii=False)))

# 5. 先頭の BOM
with open("bom.jsonl", "wb") as f:
f.write(b'\xef\xbb\xbf{"id":1}\n')
with open("bom.jsonl", encoding="utf-8") as f:
try:
json.loads(f.readline())
print("encoding=utf-8 の BOM : 成功")
except json.JSONDecodeError as e:
print("encoding=utf-8 の BOM :", e.msg)
with open("bom.jsonl", encoding="utf-8-sig") as f:
print("encoding=utf-8-sig :", json.loads(f.readline()))
1
2
3
4
5
6
7
8
9
10
11
12
$ python try.py
Python 3.11.9
for line in open() : 成功 4 / 失敗 0
read().splitlines() : 成功 1 / 失敗 6
bytes.splitlines() : 成功 4 / 失敗 0
codecs.open() : 成功 1 / 失敗 6
splitlines が切る文字 : U+000A U+000B U+000C U+000D U+001C U+001D U+001E U+0085 U+2028 U+2029
JSON にそのまま入る文字 : U+0085 U+2028 U+2029
ensure_ascii=True : "a\u2028b"
ensure_ascii=False : '"a\u2028b"'
encoding=utf-8 の BOM : Unexpected UTF-8 BOM (decode using utf-8-sig)
encoding=utf-8-sig : {'id': 1}

ページのコードやランタイムの版が変われば結果も変わるので、2026-11-06 時点の結果として読んでほしい。

ハシトシステムのJSONL⇄JSON変換では

JSONL⇄JSON変換 は、JSONL と JSON 配列をブラウザの中だけで相互に変換する。2026-11-06 時点のコードでは次のように動く。

  • JSONL → JSON は /\r\n|\r|\n/ で行を切るので、U+2028 などを含む行も 1 件として読める。
  • JSON → JSONL は JSON.stringify の結果をそのまま 1 行にするので、U+2028 は生のまま出力される。変換結果を Node.js の readline で読むなら、上の対策が要る。
  • 空行は読み飛ばして数え、読み込み件数・空行数・入力行数を表示する。jsonlines.org は空行を正しい値とはしていないので、これはツール側の寛容な扱いである。
  • BOM つきの入力は 1 行目のエラーとして行番号つきで止まる。

見えない文字を探すなら 不可視文字チェッカー や コードポイント確認 で確かめられる。ゼロ幅スペースなどの見えない文字については ゼロ幅スペースの検出と除去の記事 に、JSON の文字列エスケープ全般については JSON の文字列エスケープの記事 に書いた。

まとめ

  • U+2028、U+2029、U+0085 は JSON の文字列に生で入ってよく、JSON.stringify と json.dumps(ensure_ascii=False) はそのまま出力する。
  • Node.js の readline は U+2028 と U+2029 で行を切る。Python の str.splitlines と codecs.open はこれに U+0085 も加わる。4 件のファイルで、読めたのはそれぞれ 2 件と 1 件だった。
  • Python の for line in open(path) と bytes.splitlines()、Node.js の split('\n') は 4 件すべて読めた。
  • 区切り文字が 2 つあると、間の断片が単独の JSON として通り、存在しないレコードが生まれることがある。
  • 生成側は 3 文字を \uXXXX にする。読み側は \n だけで切る。BOM は Python なら utf-8-sig で読み飛ばす。

参考

  • RFC 8259 The JavaScript Object Notation (JSON) Data Interchange Format — 7. Strings、8.1. Character Encoding
  • JSON Lines(jsonlines.org)
  • Python ドキュメント — 組み込み関数 open、str.splitlines、codecs(utf-8-sig)
  • Node.js ドキュメント — Readline、ソース lib/internal/readline/interface.js(v24.11.1)