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

家計簿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の「データの取得」から開く手順も、実機では試していません。

出典

この記事をシェア