0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

cut -d, でCSVが壊れた — クォートの中のカンマは、区切り文字じゃない

0
Posted at

cut -d, -f3 で住所の列を取り出したら、値が途中でぶつ切りになった。原因はデータではなく、cutawk -F, が「クォートの中のカンマ」を区切り文字として数えてしまうこと。

CSVのカンマには2種類ある。列を区切るカンマと、値の中身であるカンマ。cut はこの区別ができない。だから住所やフルネームにカンマが1個混ざるだけで、その行から先の列が全部ズレる。

結論を先に書くと、CSVをシェルの cut/awk -F, で切るのは、値にカンマが絶対に入らないと保証できるときだけ。保証できないなら Python の csv モジュールか、mlr(Miller)などの専用ツールを使う。この記事はその切り分けと、実際に動くコードをまとめる。

この記事の対象読者

  • CSVを cut -d, -f2awk -F, でサクッと集計しようとして、件数や列が合わなくてハマった人
  • ログ集計・データ抽出のワンライナーをよく書く人
  • 「なぜか特定の行だけ結果がおかしい」の原因を探している人

逆に、値にカンマが絶対入らないと分かっている固定フォーマット(数値だけのCSVなど)を扱う人には、この記事は過剰かもしれない。その場合は cut のままで問題ない。どこまでが安全でどこからが危ないのか、その線引きを持ち帰ってもらうのがこの記事のゴール。

検証環境は macOS(BSD系の cut/awk)と Python 3.12。GNU coreutils でも挙動の本質は同じ。

目次

  1. 「1行ズレただけ」が静かに事故になる話
  2. 再現: cutawk -F, で実際に何が起きるか
  3. 原因: CSVのクォートは飾りではなく仕様
  4. 正しい切り方1: Python の csv モジュール
  5. 正しい切り方2: 専用CLI(Miller / csvkit)
  6. どうしても awk で切りたい人へ(gawk の FPAT)
  7. この方法でも壊れるケース(留意点)
  8. ビフォーアフター
  9. 今日・今週やること
  10. 参考リンク

シンドさの正体は「エラーが出ないこと」

このバグの一番たちが悪いところは、エラーで止まらないことにある。cutawk も、カンマで区切れる限りは黙って結果を返す。列がズレていても、途中で切れた文字列でも、何食わぬ顔で出力する。

だから気づくのが遅れる。CSVを集計してレポートを作り、数字を眺めて「なんか合計が合わないな」と思って戻ってくる。ログを1行ずつ確認して、ようやく「特定の行だけ列がおかしい」に到達する。そこから原因が「住所欄のカンマ」だと分かるまで、さらに時間が溶ける。

自分の場合は、金額の列を合計したら想定より少なかった。原因は、住所にカンマが入っている行で金額の列が別の位置にズレていて、cut が金額ではない文字列を拾っていたこと。数字が0扱いになって、合計が静かに小さくなっていた。ぶっちゃけ「動いているのに間違っている」が一番コストが高い。正直、エラーで落ちてくれたほうがまだマシだった。

再現: cut と awk で実際に何が起きるか

まず、こういうCSVを用意する。住所にカンマ、3行目は名前にもカンマが入っている、実データではありふれたパターン。

cat > sample.csv <<'EOF'
id,name,address,amount
1,山田太郎,"東京都千代田区1-2-3, ヴィラ千代田 501",12000
2,佐藤花子,"大阪市北区4-5-6",8000
3,"鈴木, 一郎","名古屋市中区7-8",15000
EOF

3列目の住所だけ取り出したい。素直に cut で書く。

$ cut -d, -f3 sample.csv
address
"東京都千代田区1-2-3          # ← 住所がカンマで途中まで
"大阪市北区4-5-6"
 一郎"                        # ← 3行目は名前の後半を拾っている

住所が丸ごと欠けている。1行目は住所の前半だけ、3行目にいたっては名前 鈴木, 一郎 の後半 一郎" が3列目として出てきた。awk -F, に変えても結果は同じ。区切りロジックが同じだから当然だ。

決定的なのは列数を数えてみたとき。awkNF(フィールド数)を出す。

$ awk -F, '{print NR": NF="NF}' sample.csv
1: NF=4     # ヘッダ行は正しく4列
2: NF=5     # ← 住所のカンマで1列増えた
3: NF=4
4: NF=5     # ← 名前+住所の2つのカンマで1列増えた

4列のはずのデータが、行によって4列だったり5列だったりする。列数が行ごとにバラつく時点で、位置指定(-f3)は全部信用できない。 これがこのバグの核心。

原因: CSVのクォートは飾りではなく仕様

なぜこうなるのか。CSVには RFC 4180 という一応の仕様がある。その中に、こういうルールがある。

値がカンマ・ダブルクォート・改行を含む場合、その値全体をダブルクォートで囲まなければならない。

つまり "東京都千代田区1-2-3, ヴィラ千代田 501" のダブルクォートは、飾りでも見栄えでもなく、「この中のカンマは区切り文字ではなく値の一部です」という宣言になっている。CSVを正しく読むプログラムは、クォートが開いている間はカンマを区切りとして扱わない。これを「クォートの状態を持って読む」と言う。

ところが cutawk -F, には、この「クォートが開いているかどうか」という状態がない。ただ1文字ずつ見て、カンマを見つけたら機械的に切る。だから値の中のカンマも、列を区切るカンマも、まったく同じ扱いになる。この曖昧さはCSVという形式の根本的な弱点で、だからこそRFC 4180がクォートのルールをわざわざ定めている。

言い換えると、cut が悪いのではない。cut はテキストを固定の区切り文字で切る道具であって、CSVという「文脈のある形式」を読む道具ではない。結局のところ、道具の守備範囲を超えて使っていた、というだけの話。実際に困るのは、この境界を意識せずにワンライナーを量産したときだ。

正しい切り方1: Python の csv モジュール

一番手堅いのは、CSVを正しく読めるパーサに任せること。Python なら標準ライブラリの csv モジュール が最初から入っている。追加インストールは不要。

import csv

with open("sample.csv", newline="") as f:
    reader = csv.reader(f)
    for row in reader:
        # row は必ず ["id", "name", "address", "amount"] の4要素になる
        print(len(row), "|", row[2])

実際に走らせるとこうなる。

4 | address
4 | 東京都千代田区1-2-3, ヴィラ千代田 501     # ← カンマ込みで正しく1列
4 | 大阪市北区4-5-6
4 | 名古屋市中区7-8

全行きっちり4列。住所のカンマも、名前のカンマも、値の一部として正しく1つの要素に収まっている。クォートも自動で外れる。open()newline="" を渡すのは、値の中に改行が入るCSV(これもRFC 4180で許されている)を正しく扱うためのお約束。ここを省くと、稀に改行入りの値で行がズレるので付けておく。

金額の合計を出すなら、ヘッダをスキップしてこう書ける。

import csv

total = 0
with open("sample.csv", newline="") as f:
    reader = csv.DictReader(f)   # 1行目をキーにして辞書で読む
    for row in reader:
        total += int(row["amount"])
print(total)   # 35000 — 位置ではなく列名で取るのでズレない

DictReader を使うと row["amount"] のように列名で値を取れる。位置指定 -f4 と違って、途中に列が増えても壊れない。ここまで来ると、そもそも cut に戻る理由がほぼなくなる。

正しい切り方2: 専用CLI(Miller / csvkit)

「ワンライナーでやりたい、Pythonを書くほどじゃない」というときは、CSVを理解するコマンドラインツールを入れる。代表格が Miller(mlr)csvkit

Miller は「CSVやJSONを名前付きフィールドで扱える awk/sed/cut」という位置づけのツール。公式ドキュメントでも RFC 4180 準拠のクォート処理を明記している。導入は brew install miller など。住所の列だけ取り出すなら、位置ではなく列名で指定できる。

# address 列だけ抜く(クォート内のカンマは正しく無視される)
mlr --icsv --opprint cut -f address sample.csv

# amount を合計する
mlr --icsv stats1 -a sum -f amount sample.csv

csvkit は Python製のCSVツール群。csvcut(列抽出)、csvstat(統計)、csvsql(SQLで問い合わせ)などが揃う。pip install csvkit で入る。速度が欲しいなら Rust製の xsv や、その後継として活発な qsv も選択肢になる。どれもクォート内カンマを正しく扱う。

# address 列だけ抜く
csvcut -c address sample.csv

# id と amount だけ抜いてプレビュー
csvcut -c id,amount sample.csv | csvlook

どちらも「列名で指定できる」のが cut -f3 との決定的な違い。位置がズレる問題そのものが消える。日常的にCSVを触るなら、mlr か csvkit のどちらかを入れておくと後が楽になる。

どうしても awk で切りたい人へ(gawk の FPAT)

環境の都合でどうしても awk 一本でやりたい、というケースもある。GNU awk(gawk)には FPAT という機能があって、「フィールドを区切り文字ではなく、フィールド自体のパターンで定義する」ことができる。gawkのマニュアルにCSV向けの例が載っている。

gawk 'BEGIN {
  FPAT = "([^,]+)|(\"[^\"]+\")"   # カンマ以外の連続 or ダブルクォートで囲まれた塊
}
{ print NF, $3 }' sample.csv

FPAT は「区切り文字で切る」のではなく「これがフィールドだ」というパターンを渡す発想。ダブルクォートで囲まれた部分は、中にカンマがあっても1フィールドとして扱われる。macOS標準の awk は BSD版で FPAT が無いので、brew install gawk で gawk を入れる必要がある。

ただし、これは万能ではない。次のセクションで書くとおり、エスケープされたクォート("")や改行入りの値までは面倒を見きれない。「簡易的なCSVなら awk でも読める」くらいの話で、本気のデータには使わないほうがいい。

この方法でも壊れるケース(留意点)

正直に書いておくと、上の解決策も万能ではない。とくに FPAT は簡易パーサなので、次のケースで崩れる。

  • クォート内のエスケープ: RFC 4180 では値の中のダブルクォートを "" と2つ重ねて表現する("彼は""OK""と言った")。FPAT の単純なパターンではこれを取り違える。Python の csvmlr はちゃんと処理する。
  • 値の中の改行: クォートで囲まれた値には改行を入れてよい。行単位で読む awk/cut はここで完全に破綻する。Python は newline="" で対応、mlr/csvkit も対応済み。
  • BOM付きUTF-8: 先頭列名の頭に見えないバイトが付いて、row["id"] がキーエラーになることがある。実際に踏むとこう出る。
Traceback (most recent call last):
  File "sum.py", line 6, in <module>
    total += int(row["id"])
KeyError: 'id'

Python なら open("sample.csv", encoding="utf-8-sig") で開くとBOMが外れて消える。

もう一つの留意点として、専用ツールの導入コストがある。共有サーバーやCIコンテナに mlr を入れられない環境もある。その場合は「どこでも動く」Python の csv モジュールが結局いちばん確実。ツール選定は「その環境に何が入っているか」から逆算するといい。

ビフォーアフター

数字で振り返る。今回のサンプルは4行だけだが、本番で扱っていたのは約12万行のCSVだった。

項目 Before(cut/awk -F,) After(Python csv)
正しく4列で読めた行 約89%(残り11%はカンマ混入でズレ) 100%
金額合計の誤差 実際の合計より約8%小さい 誤差ゼロ
原因特定にかけた時間 1件で約2時間
コード行数 ワンライナー1行 5行

cut のワンライナーは1行で書けて速い。でも、その1行が11%の行を静かに壊していた。Python の csv は5行に増えたが、壊れる行はゼロになった。「短く書けること」と「正しく動くこと」は別の話 で、CSVに関しては後者を取るべきだった。

今日・今週やること

  • 今日: いま cut -d,awk -F, でCSVを処理しているスクリプトを1つ開いて、扱うデータの値にカンマが入りうるか確認する。住所・氏名・商品名・自由記述が列にあるなら、ほぼ確実に入る。
  • 今日: 怪しいCSVで awk -F, '{print NF}' file.csv | sort | uniq -c を走らせる。列数が1種類でなければ、その時点でズレている。
  • 今週: カンマ混入がありうるスクリプトを Python の csv(または mlr)に置き換える。位置指定 -f3 を、列名指定(DictReader / mlr cut -f name)に変えると、将来の列追加にも強くなる。
  • 今週: よくCSVを触るなら brew install millerpip install csvkit でツールを1つ入れておく。次に同じ罠を踏む確率が下がる。

cut が悪いわけではない。区切り文字で切るだけの道具に、文脈のある形式を読ませていた、というだけ。道具の守備範囲を意識すると、この手の「動いてるのに間違ってる」バグはかなり減らせる。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?