CSVの引用符1個で後ろの行が1セルに化ける理由と厳密パーサー
id,size の CSV に 1,5"3 という行(5インチ3分のつもりの値)が1行あるだけで、その後ろの行が全部1つのセルにまとめられてしまう。エラーは出ない。3行のデータを変換したのに、JSON には1件しか出てこない。
これは、引用符を見たら「引用符モード」に入り、閉じ引用符が来るまで改行もカンマも値として読む、という素直な作りの CSV パーサーで起きる。ハシトシステムの CSV ⇄ JSON 変換 もこの作りで、2026-11-01 時点で公開されているコードをそのまま取り出して動かすと、上の現象が再現する。
この記事では、RFC 4180 が引用符についてどう決めているかを確認し、同じ入力を Python の csv モジュールにも読ませて結果を比べる。最後に、壊れた入力を黙って読まずに行番号つきで止めるパーサーを書く。
RFC 4180 は「引用符で囲まないなら中に “ を書かない」
RFC 4180(2005年10月、Informational)の 2 節は、CSV の形を7項目で定めている。引用符に関係するのは次の3つである。
- 5: 各フィールドは二重引用符で囲んでも囲まなくてもよい。囲まない場合、フィールドの中に二重引用符を書いてはいけない
- 6: 改行(CRLF)・二重引用符・カンマを含むフィールドは、二重引用符で囲むべき(should)
- 7: 囲んだフィールドの中の二重引用符は、もう1つ二重引用符を前に付けてエスケープする(
"")
つまり 5"3 は、RFC 4180 の上ではそもそも正しい CSV ではない。正しくは "5""3" と書く。ただし RFC 4180 は Informational で、壊れた入力をどう読むかは決めていない。そこは各実装の判断になり、結果が分かれる。
ほかにも、4 は「スペースはフィールドの一部で、無視してはいけない」、2 は「最後のレコードの後ろの改行はあってもなくてもよい」としている。
同じ入力を2つのパーサーに読ませる
比べたのは次の2つ。
- ハシトシステムの CSV ⇄ JSON 変換の
parseCsv(公開ページの HTML から関数をそのまま取り出し、Node.js v24.11.1 で実行) - Python 3.11.9 の
csv.reader(既定のstrict=Falseと、strict=True)
| 入力(2行目以降) | CSV ⇄ JSON 変換 | Python strict=False |
Python strict=True |
|---|---|---|---|
1,5"3 の後ろに 2,7 と 3,9 |
["1", "53\r\n2,7\r\n3,9\r\n"] の1行だけ |
5"3 のまま3行 |
5"3 のまま3行 |
"ab"c,x |
["abc", "x"] |
["abc", "x"] |
csv.Error |
1, "2" |
["1", " 2"] |
["1", " \"2\""] |
["1", " \"2\""] |
"abc,x の後ろに 1,2(閉じない) |
["abc,x\r\n1,2\r\n"] |
同じ | csv.Error |
1,2 と 3,4 の間に空行 |
[""] の行が入る |
[] の行が入る |
[] の行が入る |
| 先頭に BOM(U+FEFF) | 1列目の名前が name |
同じ | 同じ |
読み取れることは次のとおり。
- 途中の
"で後ろが全部飲み込まれるのは、CSV ⇄ JSON 変換だけだった。5の後ろの"で引用符モードに入り、ファイルの最後まで閉じ引用符が来ないので、残りの改行もカンマも全部1つの値になる。"自体は捨てられるので、値も53に変わっている。Python は、フィールドの途中の"をただの文字として扱うので5"3が残る。 - Python の
strict=Trueでも、途中の"はエラーにならない。エラーになったのは「閉じ引用符の直後にカンマ以外がある」(',' expected after '"')と「引用符が閉じないままデータが終わった」(unexpected end of data)の2つだった。strict=Falseではどちらもエラーにならず、閉じない引用符はファイルの最後まで1つの値として読まれる。 1, "2"の扱いが分かれた。CSV ⇄ JSON 変換は空白の後ろの"でも引用符モードに入るので2になる。Python はフィールドの先頭(区切り文字の直後)の"だけを引用符として扱うので、空白と"を含めた"2"が値になる。RFC 4180 の4(空白はフィールドの一部)に照らすと、どちらもフィールドが"で始まっていない以上、正しい CSV ではない。Python には区切りの後ろの空白を読み飛ばすskipinitialspaceがあり、既定はFalseである。- BOM はどちらも取り除かない。1列目のヘッダーが U+FEFF 付きの
nameになるので、JSON にするとrow.nameがundefinedになる。画面では見えないので気付きにくい。
ヘッダーつきでオブジェクトの配列にするときの違いも見た。CSV ⇄ JSON 変換の csvToJson は、列が足りない行を "" で埋め、多い列は黙って捨てる。途中の空行は {"a": "", "b": ""} の1件になる。Python の csv.DictReader は、足りない列を None(restval の既定値)にし、多い列は None というキーにリストで入れる(restkey の既定値)。空行は読み飛ばした。ヘッダーに同じ名前が2つあるときは、どちらも後ろの列の値が残った。
実際に試す
Node.js 18 以降(fetch を使うため。記事では v24.11.1)と、Python 3(記事では 3.11.9)で試せる。追加のパッケージは要らない。
1. 公開中のツールのパーサーで再現する
公開ページの HTML から parseCsv 関数の部分だけを切り出して実行する。ページの中身が変われば結果も変わるので、2026-11-01 時点の結果として読んでほしい。
1 | |
1 | |
2. Python の csv で同じ入力を読む
1 | |
1 | |
sys.stdout.reconfigure は、Windows のコンソールで日本語を表示するために入れている。ファイルから読むときは、Python のドキュメントのとおり open(path, newline='', encoding=...) で開く。newline='' を付けないと、引用符の中の改行が正しく読めない。
3. 壊れた入力を行番号つきで止めるパーサー
変換結果を人が見ずに次の処理へ流すなら、「それらしく読む」より「おかしければ止める」ほうが安全である。RFC 4180 の 2 節の規則から外れた入力をすべてエラーにするパーサーを書いた。
- 引用符はフィールドの先頭でだけ受け付ける(5)。途中の
"はエラー - 閉じ引用符の直後は、区切り文字か改行でなければエラー
- 引用符が閉じないまま終わったら、開いた行を示してエラー
- 全レコードの列数がヘッダーと同じでなければエラー(途中の空行もここで止まる)
- 先頭の BOM(U+FEFF)だけは取り除く
1 | |
1 | |
5"3 の行を "5""3" に直せば、このパーサーでも元のツールでも 5"3 として読める。「N件目のレコード」はヘッダーを1件目として数えている。引用符の中に改行を含むレコードがあると、行番号とレコード番号はずれる。
BOM はどこで取れて、どこで残るか
上の比較では、BOM を含む文字列をそのままパーサーに渡したので、どちらも1列目の名前に U+FEFF が残った。ファイルから読む場合は、どの API で文字列にしたかで結果が変わる。BOM 付き UTF-8 のファイル(先頭が EF BB BF)を作り、先頭の文字を調べた(Node.js v24.11.1 / Python 3.11.9)。
| 読み方 | 先頭の文字 |
|---|---|
Node.js fs.readFileSync(path, 'utf8') |
U+FEFF が残る |
new TextDecoder().decode(bytes) |
取り除かれる(n) |
new TextDecoder('utf-8', { ignoreBOM: true }) |
U+FEFF が残る |
new Blob([bytes]).text() |
取り除かれる |
Python open(path, encoding='utf-8') |
U+FEFF が残る |
Python open(path, encoding='utf-8-sig') |
取り除かれる |
TextDecoder は WHATWG Encoding Standard の仕様で、ignoreBOM の既定値が false、つまり BOM を取り除く。Blob.text() も UTF-8 として BOM を取り除いて読む。Node.js の fs.readFileSync と Python の utf-8 は BOM を普通の文字として返すので、CSV パーサーの側で落とすか、Python なら utf-8-sig で開く。BOM 付きの CSV を作る側の話は CSV 文字コード変換(ハシトシステム)、見えない文字の見つけ方は ゼロ幅スペースなど見えない文字の検出と除去の記事 に書いた。
ハシトシステムの CSV ⇄ JSON 変換では
CSV ⇄ JSON 変換 は、引用符の中のカンマと改行、"" のエスケープ、CRLF・LF・CR だけの改行には対応している。記事の表に載せたもの以外に、引用符の中のカンマと改行、"" のエスケープ、CR だけの改行といった正しい形の入力も試したが、どれも Python と同じ結果になった。
一方、2026-11-01 時点のコードでは次の点に注意がいる。
- 引用符で囲まないフィールドの途中に
"があると、そこから後ろが1つの値になり、エラーは出ない。変換後の件数が元の行数と合っているかを確かめる。 - 先頭の BOM は取り除かない。テキストエリアに貼り付けた CSV に BOM が入ることは少ないが、BOM を含む文字列を貼り付けた場合は、1列目のキー名に見えない U+FEFF が付く。
- 列が多い行の余りは捨てられ、空行は全部の値が
""の1件になる。
JSON から CSV に戻す方向では、値に "・区切り文字・改行が含まれるときだけ引用符で囲み、中の " を "" にする。RFC 4180 の 6 と 7 のとおりで、この方向で壊れた CSV ができることはない。JSON 側の文字列エスケープについては JSON.stringify のエスケープの落とし穴の記事 にも書いた。
まとめ
- RFC 4180 では、引用符で囲まないフィールドに
"を書いてはいけない。5"3は"5""3"と書く。 - 壊れた入力の読み方は実装ごとに違う。素直な作りのパーサーは途中の
"で引用符モードに入り、後ろの行を全部1つの値に飲み込む。Python のcsvは途中の"を文字として残す。 - Python の
strict=Trueでエラーになるのは「閉じ引用符の後ろの文字」と「閉じない引用符」で、途中の"はエラーにならない。 - BOM は
TextDecoderとBlob.text()では取れ、fs.readFileSync(..., 'utf8')と Python のutf-8では残る。 - 結果を人が見ずに使うなら、規則から外れた入力を行番号つきで止めるパーサーにする。
参考
- RFC 4180 Common Format and MIME Type for Comma-Separated Values (CSV) Files: 2. Definition of the CSV Format
- Python 3.11 ドキュメント csv — CSV File Reading and Writing: Dialect.strict、Dialect.skipinitialspace、DictReader の restkey / restval、reader の
newline=''の注記 - Python 3.11 ドキュメント codecs: utf-8-sig
- WHATWG Encoding Standard: TextDecoder(ignoreBOM)