家計簿CSVのテンプレートをUTF-8で書き出したら、BOMの有無で3つの確認が割れた

譌・莉�,鬆�逶ョ,驥鷹。�,繝。繝「
これは「日付,項目,金額,メモ」と書いたCSVの1行目です。UTF-8で保存したファイルを、Shift_JIS系のcp932として読み替えると、こう見えます。Excelの画面を撮ったものではありません。Pythonの出力です。
配布用のCSVテンプレートをUTF-8で書き出すと、BOMの有無で、読む側の結果が変わります。cp932として読むと上の文字列になり、BOMを付けるとgrepとDictReaderの確認が壊れました。
見本のCSVを3通りに保存する
架空の家計簿テンプレートで試しました。3行だけのCSVを、BOMなしのUTF-8、BOMありのUTF-8、cp932で書き出します。メモ欄には、波ダッシュの「〜」を入れました。
作業は空のディレクトリcsv-bom-testの中で行っています。
python3 --version
Python 3.13.5
make_csv.pyの全文
import csv
rows = [
["日付", "項目", "金額", "メモ"],
["2026-11-01", "食費", "1280", "毎月1〜3日にまとめて入力"],
["2026-11-02", "交通費", "420", "電車"],
]
for name, enc in [("plain.csv", "utf-8"), ("bom.csv", "utf-8-sig"), ("sjis.csv", "cp932")]:
try:
with open(name, "w", encoding=enc, newline="") as f:
csv.writer(f).writerows(rows)
print(name, "ok")
except UnicodeEncodeError as e:
print(name, "NG", e)
python3 make_csv.py
plain.csv ok
bom.csv ok
sjis.csv ok
3つとも書けました。cp932への書き出しでもUnicodeEncodeErrorは出ていません。理由は最後のほうで書きます。
先頭のバイトを見る
違いは先頭の数バイトにだけあります。
for f in bom.csv plain.csv sjis.csv; do head -c 16 $f | xxd; file $f; done
00000000: efbb bfe6 97a5 e4bb 982c e9a0 85e7 9bae .........,......
bom.csv: CSV Unicode text, UTF-8 (with BOM) text
00000000: e697 a5e4 bb98 2ce9 a085 e79b ae2c e987 ......,......,..
plain.csv: CSV Unicode text, UTF-8 text
00000000: 93fa 9574 2c8d 8096 da2c 8be0 8a7a 2c83 ...t,....,...z,.
sjis.csv: CSV Non-ISO extended-ASCII text
bom.csvだけ、先頭にef bb bfの3バイトが付いています。これがBOMです。見た目には何も表示されません。
cp932として読んだらどう見えるか
ここからは近似です。日本語版Windowsの古い取り込みが、BOMのないファイルをcp932と見なす、という前提で、同じバイト列をcp932として読み替えました。実際のExcelは使っていません。
read_as.pyの全文
import sys
for name in sys.argv[1:]:
raw = open(name, "rb").read()
for enc in ("cp932", "utf-8"):
try:
head = raw.decode(enc).splitlines()[0]
print(f"{name:10} as {enc:6}: {head!r}")
except UnicodeDecodeError as e:
print(f"{name:10} as {enc:6}: DecodeError at byte {e.start}")
python3 read_as.py plain.csv bom.csv sjis.csv
各ファイルをcp932とutf-8で順に読みます。厳密に読むと、次のようになりました。
plain.csv as cp932 : DecodeError at byte 5
plain.csv as utf-8 : '日付,項目,金額,メモ'
bom.csv as cp932 : DecodeError at byte 0
bom.csv as utf-8 : '\ufeff日付,項目,金額,メモ'
sjis.csv as cp932 : '日付,項目,金額,メモ'
sjis.csv as utf-8 : DecodeError at byte 0
エラーで止まるのは、Pythonのdecodeが、何も指定しないと厳密に読むためです。errors="replace"を指定すると、読めないバイトが別の文字に置き換わり、冒頭の文字列が出ます。実際のアプリが同じように置き換えて表示するかどうかは、Excelを含めて確かめていません。
python3 -c '
for name in ["plain.csv", "bom.csv"]:
raw = open(name, "rb").read()
print(name, repr(raw.decode("cp932", errors="replace").splitlines()[0]))
'
plain.csv '譌・莉�,鬆�逶ョ,驥鷹。�,繝。繝「'
bom.csv '�サソ譌・莉�,鬆�逶ョ,驥鷹。�,繝。繝「'
BOMを付けたファイルは、BOMを読み飛ばさない読み手だと、先頭に余計な文字が付いて見えます(上のPythonの結果)。Excelについては、MicrosoftのサポートにUTF-8のCSVはBOM付きなら通常どおり開け、そうでなければデータの取得などから開く、と書かれています。
BOMを付けたら、今度は自分の確認が壊れた
全部BOM付きで配る、という選択肢があります。そこで、手元の確認を2つ、BOM付きのファイルに当てました。
1つめはgrepです。1列目が「日付」で始まる行を数えます。
for f in plain.csv bom.csv sjis.csv; do printf '%s ' $f; grep -c '^日付' $f; done
plain.csv 1
bom.csv 0
sjis.csv 0
BOM付きは0件でした。行頭に見えないバイトがあるので、^日付に一致しません。cp932のほうは、バイト列が違うので当然0件です。
2つめは、Pythonで1行目をキーにして読む書き方です。csv.DictReaderで日付の値を取り出します。
header_key.pyの全文
import csv, sys
for name in sys.argv[1:]:
for enc in ("utf-8", "utf-8-sig"):
try:
with open(name, encoding=enc, newline="") as f:
first = next(csv.DictReader(f))
print(f"{name:10} {enc:9} keys={list(first)[:2]!r} 日付={first.get('日付')!r}")
except UnicodeDecodeError as e:
print(f"{name:10} {enc:9} DecodeError")
python3 header_key.py plain.csv bom.csv sjis.csv
plain.csv utf-8 keys=['日付', '項目'] 日付='2026-11-01'
plain.csv utf-8-sig keys=['日付', '項目'] 日付='2026-11-01'
bom.csv utf-8 keys=['\ufeff日付', '項目'] 日付=None
bom.csv utf-8-sig keys=['日付', '項目'] 日付='2026-11-01'
sjis.csv utf-8 DecodeError
sjis.csv utf-8-sig DecodeError
bom.csvを普通のutf-8で開くと、キーが'\ufeff日付'になり、日付で引くとNoneが返ります。エラーが出ないので、気づきにくい壊れ方です。utf-8-sigで開けば直ります。PythonのドキュメントにもBOMを読み飛ばすコーデックとして書かれています。
BOM付きはExcelで通常どおり開けるとMicrosoftが案内している一方で、Pythonではutf-8のままだとキー名にBOMが残り、grepでは行頭の一致が外れます。読む道具によって、BOMの効き方は変わります。
BOMの先頭判定だけの検査は、連結したファイルを通した
配布前に弾くための検査スクリプトです。最初の版は、先頭3バイトがBOMかどうかだけでBOMを判定します。
csv_check.pyの最初の版の全文
import sys
BOM = b"\xef\xbb\xbf"
def verdict(path):
raw = open(path, "rb").read()
if raw.startswith(BOM):
return "OK UTF-8 BOMあり"
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return "NG UTF-8ではない(cp932の可能性)"
if text.isascii():
return "OK ASCIIのみ"
return "NG UTF-8 BOMなしで日本語を含む"
for p in sys.argv[1:]:
print(f"{p:11} {verdict(p)}")
cat bom.csv bom.csv > merged.csv
python3 csv_check.py plain.csv bom.csv sjis.csv merged.csv
plain.csv NG UTF-8 BOMなしで日本語を含む
bom.csv OK UTF-8 BOMあり
sjis.csv NG UTF-8ではない(cp932の可能性)
merged.csv OK UTF-8 BOMあり
最後のmerged.csvは、catで2つのファイルをつないだものです。2つめのBOMが途中に残っているのに、OKと出ました。分割して作り、最後にcatでまとめる運用なら、現実に起きる形です。
BOMの出現回数も数える版が次です。
csv_check.pyの全文(直した版)
import sys
BOM = b"\xef\xbb\xbf"
def verdict(path):
raw = open(path, "rb").read()
if raw.count(BOM) > 1:
return f"NG BOMが{raw.count(BOM)}か所(途中に混入)"
if raw.startswith(BOM):
return "OK UTF-8 BOMあり"
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
return "NG UTF-8ではない(cp932の可能性)"
if text.isascii():
return "OK ASCIIのみ"
return "NG UTF-8 BOMなしで日本語を含む"
bad = 0
for p in sys.argv[1:]:
v = verdict(p)
bad += v.startswith("NG")
print(f"{p:11} {v}")
sys.exit(1 if bad else 0)
printf 'a,b\n1,2\n' > ascii.csv
python3 csv_check.py plain.csv bom.csv sjis.csv merged.csv ascii.csv; echo "exit=$?"
plain.csv NG UTF-8 BOMなしで日本語を含む
bom.csv OK UTF-8 BOMあり
sjis.csv NG UTF-8ではない(cp932の可能性)
merged.csv NG BOMが2か所(途中に混入)
ascii.csv OK ASCIIのみ
exit=1
plain.csvは、読み込んでutf-8-sigで書き直すと検査を通りました。
python3 -c '
import csv
with open("plain.csv", encoding="utf-8", newline="") as f:
rows = list(csv.reader(f))
with open("fixed.csv", "w", encoding="utf-8-sig", newline="") as f:
csv.writer(f).writerows(rows)
'
cmp <(tail -n +2 plain.csv) <(tail -n +2 fixed.csv) && echo "body same"
body same
本文の2行目以降はバイト単位で同じです。
python3 csv_check.py fixed.csv; echo "exit=$?"
fixed.csv OK UTF-8 BOMあり
exit=0
sjis.csvをNGにしたのは、cp932が悪いからではありません。今回は「UTF-8のBOM付きに統一する」という方針にしたので、方針外として止めています。
波ダッシュは書き出せた
冒頭で触れたUnicodeEncodeErrorの件です。メモ欄の「〜」は波ダッシュ(U+301C)です。cp932で書き出せるかを、1文字ずつ試しました。
python3 -c '
for ch in ["\u301c", "\uff5e", "\u2705"]:
try:
ch.encode("cp932"); r = "ok"
except UnicodeEncodeError:
r = "NG"
print(f"U+{ord(ch):04X}", ch, r)
'
U+301C 〜 ok
U+FF5E ~ ok
U+2705 ✅ NG
Python 3.13.5のcp932は、U+301Cを書き出せました。ただし、読み戻すと別の文字になっています。
python3 -c '
s = "1\u301c3日"
b = s.encode("cp932")
print(b.hex(), repr(b.decode("cp932")), hex(ord(b.decode("cp932")[1])))
'
3181603393fa '1~3日' 0xff5e
1〜3日は1~3日になり、U+FF5Eの全角チルダとして戻りました。エラーは出ません。気づかないまま、配布物の文字だけ置き換わる場合もあります。絵文字の✅(U+2705)は書き出せませんでした。
確認できていないこと
- 日本語版Windows、Mac、Excelの各版で、BOMなしのUTF-8を実際にダブルクリックして開いた画面は見ていません。この記事の化け方はPythonによる近似です
- Numbers、Googleスプレッドシート、LibreOfficeの挙動は試していません
- BOM付きにして困る環境が購入者にどれだけいるかは、データがありません
- 区切りが
,ではなくタブのTSV、改行コードがCRLFかLFかの影響は見ていません
BOM付きにすると、Excelでは通常どおり開けるとMicrosoftが案内しています。一方、Pythonではutf-8-sigで開かないとキー名にBOMが残り、grepの行頭一致も外れます。BOMなしにすると、Pythonとgrepは今回の見本で問題なく動きましたが、Excelではデータの取得などから開く必要がある、とされています。この整理はPythonでの結果とMicrosoftの案内を並べたもので、Excelの実機で確かめた結果ではありません。BOMなしのファイルをExcelの「データの取得」から開く手順も、実機では試していません。


