cut -d, -f3 で住所の列を取り出したら、値が途中でぶつ切りになった。原因はデータではなく、cut と awk -F, が「クォートの中のカンマ」を区切り文字として数えてしまうこと。
CSVのカンマには2種類ある。列を区切るカンマと、値の中身であるカンマ。cut はこの区別ができない。だから住所やフルネームにカンマが1個混ざるだけで、その行から先の列が全部ズレる。
結論を先に書くと、CSVをシェルの cut/awk -F, で切るのは、値にカンマが絶対に入らないと保証できるときだけ。保証できないなら Python の csv モジュールか、mlr(Miller)などの専用ツールを使う。この記事はその切り分けと、実際に動くコードをまとめる。
この記事の対象読者
- CSVを
cut -d, -f2やawk -F,でサクッと集計しようとして、件数や列が合わなくてハマった人 - ログ集計・データ抽出のワンライナーをよく書く人
- 「なぜか特定の行だけ結果がおかしい」の原因を探している人
逆に、値にカンマが絶対入らないと分かっている固定フォーマット(数値だけのCSVなど)を扱う人には、この記事は過剰かもしれない。その場合は cut のままで問題ない。どこまでが安全でどこからが危ないのか、その線引きを持ち帰ってもらうのがこの記事のゴール。
検証環境は macOS(BSD系の cut/awk)と Python 3.12。GNU coreutils でも挙動の本質は同じ。
目次
- 「1行ズレただけ」が静かに事故になる話
- 再現:
cutとawk -F,で実際に何が起きるか - 原因: CSVのクォートは飾りではなく仕様
- 正しい切り方1: Python の
csvモジュール - 正しい切り方2: 専用CLI(Miller / csvkit)
- どうしても
awkで切りたい人へ(gawk のFPAT) - この方法でも壊れるケース(留意点)
- ビフォーアフター
- 今日・今週やること
- 参考リンク
シンドさの正体は「エラーが出ないこと」
このバグの一番たちが悪いところは、エラーで止まらないことにある。cut も awk も、カンマで区切れる限りは黙って結果を返す。列がズレていても、途中で切れた文字列でも、何食わぬ顔で出力する。
だから気づくのが遅れる。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, に変えても結果は同じ。区切りロジックが同じだから当然だ。
決定的なのは列数を数えてみたとき。awk の NF(フィールド数)を出す。
$ 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を正しく読むプログラムは、クォートが開いている間はカンマを区切りとして扱わない。これを「クォートの状態を持って読む」と言う。
ところが cut と awk -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 のcsvやmlrはちゃんと処理する。 -
値の中の改行: クォートで囲まれた値には改行を入れてよい。行単位で読む 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 millerかpip install csvkitでツールを1つ入れておく。次に同じ罠を踏む確率が下がる。
cut が悪いわけではない。区切り文字で切るだけの道具に、文脈のある形式を読ませていた、というだけ。道具の守備範囲を意識すると、この手の「動いてるのに間違ってる」バグはかなり減らせる。
参考リンク
- RFC 4180 - Common Format and MIME Type for CSV Files — クォートと埋め込みカンマのルールの一次情報
-
Python
csvモジュール公式ドキュメント —reader/DictReaderの使い方 - Miller(mlr)GitHub と 公式ドキュメント — CSVを名前付きフィールドで扱うCLI
-
csvkit ドキュメント —
csvcut/csvstatなどのツール群 - xsv / qsv — Rust製の高速CSVツール
-
gawk マニュアル: Splitting By Content(
FPAT) — awkでCSVを扱う簡易手法とその限界