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 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(' ' );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));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));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(' ' ));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));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));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));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 import codecsimport jsonimport 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 ])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)) 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)) 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))print ("ensure_ascii=True :" , json.dumps("a\u2028b" ))print ("ensure_ascii=False :" , ascii (json.dumps("a\u2028b" , ensure_ascii=False )))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+000 A U+000 B U+000 C U+000 D U+001 C U+001 D U+001 E 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)