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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// lenient.mjs: 公開中の CSV⇄JSON 変換の parseCsv をそのまま取り出して動かす
const html = await (await fetch('https://hashitosystem.com/tools/csvjson/')).text();
const src = html.slice(html.indexOf('function parseCsv('), html.indexOf('function csvToJson('));
const parseCsv = new Function(src + '; return parseCsv;')();

const cases = [
['04 引用なしの途中に "', 'id,size\r\n1,5"3\r\n2,7\r\n3,9\r\n'],
['05 閉じ引用符の後ろに文字', 'a,b\r\n"ab"c,x\r\n'],
['06 区切りの後ろの空白', 'a,b\r\n1, "2"\r\n'],
['07 閉じていない引用符', 'a,b\r\n"abc,x\r\n1,2\r\n'],
['09 途中の空行', 'a,b\r\n1,2\r\n\r\n3,4\r\n'],
['12 BOM 付き', 'name,age\r\n田中,30\r\n'],
];
for (const [name, text] of cases) {
// BOM は画面に見えないので  と書き出す
console.log(name, JSON.stringify(parseCsv(text, ',')).replace(//g, '\\ufeff'));
}
1
2
3
4
5
6
7
$ node lenient.mjs
04 引用なしの途中に " [["id","size"],["1","53\r\n2,7\r\n3,9\r\n"]]
05 閉じ引用符の後ろに文字 [["a","b"],["abc","x"]]
06 区切りの後ろの空白 [["a","b"],["1"," 2"]]
07 閉じていない引用符 [["a","b"],["abc,x\r\n1,2\r\n"]]
09 途中の空行 [["a","b"],["1","2"],[""],["3","4"]]
12 BOM 付き [["name","age"],["田中","30"]]

2. Python の csv で同じ入力を読む

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# compare.py: 同じ入力を Python の csv で strict=False / True の両方で読む
import csv, io, sys
sys.stdout.reconfigure(encoding='utf-8')

cases = [
('04 引用なしの途中に "', 'id,size\r\n1,5"3\r\n2,7\r\n3,9\r\n'),
('05 閉じ引用符の後ろに文字', 'a,b\r\n"ab"c,x\r\n'),
('06 区切りの後ろの空白', 'a,b\r\n1, "2"\r\n'),
('07 閉じていない引用符', 'a,b\r\n"abc,x\r\n1,2\r\n'),
('09 途中の空行', 'a,b\r\n1,2\r\n\r\n3,4\r\n'),
]
for strict in (False, True):
print(f'--- strict={strict}')
for name, text in cases:
try:
print(name, list(csv.reader(io.StringIO(text, newline=''), strict=strict)))
except csv.Error as e:
print(name, 'csv.Error:', e)
1
2
3
4
5
6
7
8
9
10
11
12
13
$ python compare.py
--- strict=False
04 引用なしの途中に " [['id', 'size'], ['1', '5"3'], ['2', '7'], ['3', '9']]
05 閉じ引用符の後ろに文字 [['a', 'b'], ['abc', 'x']]
06 区切りの後ろの空白 [['a', 'b'], ['1', ' "2"']]
07 閉じていない引用符 [['a', 'b'], ['abc,x\r\n1,2\r\n']]
09 途中の空行 [['a', 'b'], ['1', '2'], [], ['3', '4']]
--- strict=True
04 引用なしの途中に " [['id', 'size'], ['1', '5"3'], ['2', '7'], ['3', '9']]
05 閉じ引用符の後ろに文字 csv.Error: ',' expected after '"'
06 区切りの後ろの空白 [['a', 'b'], ['1', ' "2"']]
07 閉じていない引用符 csv.Error: unexpected end of data
09 途中の空行 [['a', 'b'], ['1', '2'], [], ['3', '4']]

sys.stdout.reconfigure は、Windows のコンソールで日本語を表示するために入れている。ファイルから読むときは、Python のドキュメントのとおり open(path, newline='', encoding=...) で開く。newline='' を付けないと、引用符の中の改行が正しく読めない。

3. 壊れた入力を行番号つきで止めるパーサー

変換結果を人が見ずに次の処理へ流すなら、「それらしく読む」より「おかしければ止める」ほうが安全である。RFC 4180 の 2 節の規則から外れた入力をすべてエラーにするパーサーを書いた。

  • 引用符はフィールドの先頭でだけ受け付ける(5)。途中の " はエラー
  • 閉じ引用符の直後は、区切り文字か改行でなければエラー
  • 引用符が閉じないまま終わったら、開いた行を示してエラー
  • 全レコードの列数がヘッダーと同じでなければエラー(途中の空行もここで止まる)
  • 先頭の BOM(U+FEFF)だけは取り除く
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
// strict-csv.js: RFC 4180 の 2 節に沿って、壊れた CSV を黙って読まずに位置つきで止める
function parseCsvStrict(text, delim = ',') {
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // 先頭の BOM を落とす
const rows = [];
let row = [], field = '', i = 0, line = 1, quoteLine = 0;
let state = 'start'; // start | plain | quoted | afterQuote
const fail = (msg) => { throw new Error(`${line}行目: ${msg}`); };
const endField = () => { row.push(field); field = ''; state = 'start'; };
const endRow = () => { endField(); rows.push(row); row = []; line++; };
while (i < text.length) {
const ch = text[i];
if (state === 'quoted') {
if (ch === '"') {
if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
state = 'afterQuote'; i++; continue;
}
if (ch === '\n') line++;
field += ch; i++; continue;
}
if (ch === delim) { endField(); i++; continue; }
if (ch === '\r' || ch === '\n') {
i += (ch === '\r' && text[i + 1] === '\n') ? 2 : 1;
endRow(); continue;
}
if (state === 'afterQuote') fail(`閉じ引用符の後ろに ${JSON.stringify(ch)} がある`);
if (ch === '"') {
if (state === 'start') { state = 'quoted'; quoteLine = line; i++; continue; }
fail('引用符で囲んでいないフィールドの途中に " がある');
}
field += ch; state = 'plain'; i++;
}
if (state === 'quoted') { line = quoteLine; fail('ここで開いた引用符が最後まで閉じていない'); }
if (field !== '' || row.length > 0 || state === 'afterQuote') endRow();
const width = rows[0] ? rows[0].length : 0;
rows.forEach((r, n) => {
if (r.length !== width) throw new Error(`${n + 1}件目のレコード: 列数が ${r.length}(ヘッダーは ${width})`);
});
return rows;
}

const cases = [
'a,b\r\n"x,y","1\r\n2"\r\n',
'id,size\r\n1,5"3\r\n2,7\r\n',
'a,b\r\n"ab"c,x\r\n',
'a,b\r\n"abc,x\r\n1,2\r\n',
'a,b\r\n1\r\n',
'name,age\r\n田中,30\r\n',
];
for (const c of cases) {
try { console.log('OK ', JSON.stringify(parseCsvStrict(c))); }
catch (e) { console.log('ERR', e.message); }
}
1
2
3
4
5
6
7
$ node strict-csv.js
OK [["a","b"],["x,y","1\r\n2"]]
ERR 2行目: 引用符で囲んでいないフィールドの途中に " がある
ERR 2行目: 閉じ引用符の後ろに "c" がある
ERR 2行目: ここで開いた引用符が最後まで閉じていない
ERR 2件目のレコード: 列数が 1(ヘッダーは 2)
OK [["name","age"],["田中","30"]]

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)

CSVの引用符1個で後ろの行が1セルに化ける理由と厳密パーサー
https://blog.hashito.biz/2026/11/01/csv-stray-double-quote-swallows-rows-rfc4180-python-csv-strict/
著者
hashito
作成日
2026年11月1日
著作権