CSVの文字コード入門:UTF-8・BOM・Shift_JIS(CP932)とExcel
同じCSVをUTF-8・BOM付き・CP932で書いてバイト列を確認。Python・PHP・sqlite3で読み戻し、BOMの副作用、Shift_JISで消える髙・①、波ダッシュ、絵文字のエラーを実測し、Excel向けの判断表にまとめます。
シリーズ:文字コード講座 4/6(中級)。 CSV を出力・取り込みしていて、取引先や経理から「文字化けしています」と言われる人向けの回です。同じデータを複数の文字コードで書いてバイト列を確認し、3つのツールで読み戻し、「誰が開くか」から文字コードを選ぶ判断表を作ります。
「Excel で開いたら 鬮呎ゥ� のようになった」「Shift_JIS で読んだら髙橋さんが ?橋 さんになった」「見出しに name とあるのに KeyError: 'name' で取り込みが落ちる」「夜間バッチが1人の絵文字で止まった」。どれかに覚えがあれば、この記事はその原因を扱います。CSV には文字コードを書いておく場所がないので、すべてのプログラムが推測し、その推測の跡がバイト列に残ります。ここでは各パターンの正確なバイト列を Python で作り、Python の csv、PHP の fgetcsv、sqlite3 の .import で読み、BOM の影響を測ります。Excel と Windows はここにないので、その挙動は Microsoft のドキュメントに基づく説明で、「ここでは未実行」と明記します。
前回:講座3:Unicode 正規化と濁点分離。全体像はプログラミング学習ロードマップへ。
検証環境: macOS 26.6.2、Apple Silicon Mac(M1 Pro)、Python 3.9.6(csv・codecs)、PHP 8.5.6(mbstring)、sqlite3 3.51.0、macOS 付属の iconv と file、xxd。パスは ~/demo に短縮しています。
同じ行を3つの文字コードで書く
BOM(バイトオーダーマーク)はファイルの先頭に置く U+FEFF という文字で、UTF-8 では EF BB BF の3バイトです。UTF-8 にはバイト順がないので、ここでは「このファイルは UTF-8」という目印としてだけ働きます。CP932 は Windows の日本語版が使うコードページで、Shift_JIS に NEC・IBM の拡張文字(①、㈱、髙など)を足したものです。Python では cp932、PHP では SJIS-win または CP932 と書きます。
# write_csv.py - 同じ行を3つの文字コードで書く
import csv, sys
ROWS = {
"en": [["name", "city", "note"], ["Zoë", "São Paulo", "it’s café"]],
"ja": [["name", "city", "note"], ["髙橋", "東京", "①~③"]],
"ko": [["name", "city", "note"], ["김똠", "서울", "한글"]],
"es": [["name", "city", "note"], ["Peña", "Bogotá", "¿Qué tal?"]],
}
LEGACY = {"en": "cp1252", "ja": "cp932", "ko": "cp949", "es": "cp1252"}
lang = sys.argv[1]
for enc in ("utf-8", "utf-8-sig", LEGACY[lang]):
with open(f"{lang}_{enc}.csv", "w", encoding=enc, newline="") as f:
csv.writer(f).writerows(ROWS[lang])
newline="" は大事です。csv モジュールは自分で \r\n を書く(下の 0d 0a)ので、これがないと改行がさらに変換されます。日本語の行を3つの文字コードで見ます。
~/demo % xxd ja_utf-8.csv
00000000: 6e61 6d65 2c63 6974 792c 6e6f 7465 0d0a name,city,note..
00000010: e9ab 99e6 a98b 2ce6 9db1 e4ba ac2c e291 ......,......,..
00000020: a0ef bd9e e291 a20d 0a .........
~/demo % xxd ja_utf-8-sig.csv | head -1
00000000: efbb bf6e 616d 652c 6369 7479 2c6e 6f74 ...name,city,not
~/demo % xxd ja_cp932.csv
00000000: 6e61 6d65 2c63 6974 792c 6e6f 7465 0d0a name,city,note..
00000010: eee0 8bb4 2c93 8c8b 9e2c 8740 8160 8742 ....,....,.@.`.B
00000020: 0d0a ..
漢字・かなは UTF-8 で3バイト、CP932 で2バイトです。サイズは 41・44・34 バイトでした。file コマンドは CP932 を判別できず、charset=unknown-8bit と答えました。
Shift_JIS と CP932 は同じではない
各文字が文字コードごとにどうなるかを str.encode で調べました(ERROR は変換できない文字)。
| 文字 | cp932 | shift_jis | 備考 |
|---|---|---|---|
文字化け |
95b6 8e9a 89bb 82af |
同じ | JIS X 0208 の範囲 |
髙(はしごだか) |
eee0 |
ERROR | IBM 拡張文字 |
① |
8740 |
ERROR | NEC 特殊文字 |
㈱ |
878a |
ERROR | NEC 特殊文字 |
~(U+FF5E) |
8160 |
ERROR | 全角チルダ |
〜(U+301C) |
8160 |
8160 |
波ダッシュ |
カナ(半角) |
b6c5 |
b6c5 |
1バイト |
👍 |
ERROR | ERROR | どちらにもない |
ここに日本特有の罠が3つあります。
1. Shift_JIS として読むと機種依存文字が消える。 CP932 で書いたファイルを PHP で読むと、指定した名前で結果が変わりました。
~/demo % php read2.php ja_cp932.csv SJIS
name | city | note
?橋 | 東京 | ?〜?
~/demo % php read2.php ja_cp932.csv SJIS-win
name | city | note
髙橋 | 東京 | ①~③
SJIS では髙と①③が ? になり、~(U+FF5E)が〜(U+301C)に化けました。Python で shift_jis を指定すると、UnicodeDecodeError: 'shift_jis' codec can't decode byte 0xee in position 16: illegal multibyte sequence で止まります。Windows で作られた「Shift_JIS の CSV」は、ほぼ必ず CP932 として読むべきです。
2. 波ダッシュは往復で変わる。 Mac の日本語入力で打った「〜」(U+301C)も、Windows で打った「~」(U+FF5E)も、Python の cp932 では同じ 81 60 になりました。読み戻すと cp932 では U+FF5E、shift_jis では U+301C になります。
U+301C cp932 -> 8160 -> cp932 decode U+FF5E, shift_jis decode U+301C
U+FF5E cp932 -> 8160 -> cp932 decode U+FF5E, shift_jis decode U+301C
つまり、Mac で入力した「10時〜12時」を CP932 の CSV で往復させると、文字列比較では別物になります。取り込み側で U+301C と U+FF5E を1つにそろえる置換を入れておくのが現実的です。
3. 同じ「髙」でもバイト列が違う。 Python の cp932 は髙を EE E0(NEC 選定 IBM 拡張)で書きましたが、PHP の SJIS-win と macOS の iconv -t CP932 は FB FC(IBM 拡張)で書きました。どちらも CP932 として正しく「髙」に戻ります。しかし「同じデータなのにファイルのハッシュが違う」「diff が出る」原因になります。
読み戻す:Python・PHP・sqlite3
文字コードを間違えた Python の csv
# read_csv.py - 指定した文字コードで CSV を読み、見出しを表示する
import csv, sys
path, enc = sys.argv[1], sys.argv[2]
with open(path, encoding=enc, newline="") as f:
rows = list(csv.DictReader(f))
print(enc, list(rows[0].keys()))
print("row['name'] =", rows[0].get("name"))
~/demo % python3 read_csv.py ja_cp932.csv utf-8
Traceback (most recent call last):
...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xee in position 16: invalid continuation byte
16バイト目は髙の ee です。厳格なデコーダーが大きな声で失敗するのは良いことで、間違ったデータは1文字も入っていません。
BOM の副作用を測る
次は BOM 付きのファイルを普通の utf-8 で読みます。
~/demo % python3 read_csv.py ja_utf-8-sig.csv utf-8
utf-8 ['name', 'city', 'note']
row['name'] = None
~/demo % python3 read_csv.py ja_utf-8-sig.csv utf-8-sig
utf-8-sig ['name', 'city', 'note']
row['name'] = 髙橋
~/demo % python3 read_csv.py ja_utf-8.csv utf-8-sig
utf-8-sig ['name', 'city', 'note']
row['name'] = 髙橋
1つ目の見出しが name になりました。ターミナルではたいてい name と表示されるので、バグが隠れます。row.get("name") は None、row["name"] なら KeyError です。大事なのは最後の結果で、utf-8-sig は BOM のないファイルも読めます。読むときの既定値はこれにしておけば安全です。
PHP の fgetcsv も BOM を取り除きません。
<?php
// read.php - fgetcsv と BOM
$f = fopen($argv[1], 'r');
$head = fgetcsv($f, null, ',', '"', '');
$row = fgetcsv($f, null, ',', '"', '');
$rec = array_combine($head, $row);
var_dump($head[0], strlen($head[0]), isset($rec['name']));
echo bin2hex($head[0]), "\n";
~/demo % php read.php ja_utf-8-sig.csv
string(7) "name"
int(7)
bool(false)
efbbbf6e616d65
4文字の見出しが7バイトなら BOM です。5番目の引数 '' にも注意してください。省略すると PHP 8.5 は Deprecated: fgetcsv(): the $escape parameter must be provided as its default value will change を出します。BOM を取り、既知のレガシー文字コードを変換する小さな読み込み関数です。
<?php
// read2.php - BOM を取り除き、レガシーな文字コードを UTF-8 に変換する
function open_csv(string $path, string $from = 'UTF-8') {
$data = file_get_contents($path);
if (str_starts_with($data, "\xEF\xBB\xBF")) {
$data = substr($data, 3);
} elseif ($from !== 'UTF-8') {
$data = mb_convert_encoding($data, 'UTF-8', $from);
}
$f = fopen('php://memory', 'r+');
fwrite($f, $data);
rewind($f);
return $f;
}
$f = open_csv($argv[1], $argv[2] ?? 'UTF-8');
while (($r = fgetcsv($f, null, ',', '"', '')) !== false) {
echo implode(' | ', $r), "\n";
}
sqlite3 の .import
sqlite3 3.51.0 は BOM を自動で処理しました。一方、レガシーなバイト列は何も言わずにそのまま保存しました。
~/demo % sqlite3 :memory: ".import --csv ja_utf-8-sig.csv t" "select hex(name) from t limit 1"
E9AB99E6A98B
~/demo % sqlite3 :memory: ".import --csv ko_cp949.csv t" "select name, hex(name), length(name) from t;"
��c|B1E88C63|3
2つ目のテーブルには UTF-8 として不正なバイト列が TEXT 列に入り、length も間違っています。sqlite3 は変換しないので、取り込む前に iconv -f CP932 -t UTF-8 などで変換します。
Excel は何をするか(説明のみ、ここでは未実行)
Microsoft のドキュメントやサポート記事によると、Windows の Excel は、ダブルクリックで開いた .csv を、先頭に UTF-8 の BOM がない限りシステムの「ANSI」コードページで読みます。日本語版 Windows なら CP932 です。保存時には「CSV UTF-8(コンマ区切り)」があり、これは BOM 付きで書きます。「データ > テキストまたは CSV から」を使えば文字コードを手で選べます。ここでは Excel を動かせませんが、UTF-8 のバイト列を CP932 でデコードすれば、そういう読み方をした結果を正確に再現できます。
ja cp932 -> 鬮呎ゥ�,譚ア莠ャ,竭�ス樞造
BOM bytes as cp932 -> '�サソ'
「髙橋,東京,①~③」が 鬮呎ゥ�,譚ア莠ャ,竭�ス樞造 になりました。譚ア莠ャ のように半角カナが混じるのが、UTF-8 を CP932 で読んだときの典型的な文字化けです。
変換できない文字
レガシーなコードページは小さく、ユーザー名の絵文字1つで出力が壊れます。
# emoji.py
import csv
with open("out.csv", "w", encoding="cp932", newline="") as f:
csv.writer(f).writerows([["id", "nickname"], ["1", "ゆい👍"]])
~/demo % python3 emoji.py
Traceback (most recent call last):
File "~/demo/emoji.py", line 3, in <module>
csv.writer(f).writerows([["id", "nickname"], ["1", "ゆい👍"]])
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f44d' in position 4: illegal multibyte sequence
~/demo % xxd out.csv
00000000: 6964 2c6e 6963 6b6e 616d 650d 0a id,nickname..
最悪なのはファイルの中身です。例外が起きた時点で out.csv にはヘッダー行(13バイト)が書かれていたので、cron のジョブは「正しそうに見える、途中で切れた CSV」を残します。
PHP は逆に、黙って置き換えます。
~/demo % php write.php SJIS-win 'ゆい👍' | xxd
00000000: 6964 2c6e 6963 6b6e 616d 650d 0a31 2c82 id,nickname..1,.
00000010: e482 a23f 0d0a ...?..
絵文字は警告なしに 3f(?)になりました。Python でも errors="replace" なら ゆい?、errors="xmlcharrefreplace" なら ゆい👍 になります。会計データならエラーで止めるのが正解、表示名なら置換でもよい、というように意識して選びます。
よくある間違い:「とりあえず全部 BOM を付ける」
間違い。 Excel の文字化けの苦情が1件来たので、社内サービスが読む CSV も含めて全部に BOM を付ける。
実際に起きること。 普通の utf-8 で読む側では name(Python)や7バイトの name(PHP)になり、1列目が黙って引けなくなります。シェルのツールにも見えます。cut -d, -f1 ja_utf-8-sig.csv | head -1 | xxd は efbb bf6e 616d 65 を出し、cat a.csv a.csv を xxd で見るとデータの途中に 0aef bbbf 6e61 6d65 が残りました。
直し方。 ファイルごとではなく、読む相手ごとに決めます。BOM を付けるのは人が Excel で開くファイルだけにし、受け取る CSV はすべて utf-8-sig で読んで、だめなら既知のレガシー文字コード1つに切り替えます。
# read_any.py - UTF-8(BOM の有無を問わず)か、既知のレガシー文字コード1つ
import csv, io
def read_rows(path, legacy):
data = open(path, "rb").read()
try:
text, used = data.decode("utf-8-sig"), "utf-8-sig"
except UnicodeDecodeError:
text, used = data.decode(legacy), legacy # strict のまま:失敗は大きく
return used, list(csv.DictReader(io.StringIO(text, newline="")))
ja_utf-8-sig.csv utf-8-sig {'name': '髙橋', 'city': '東京', 'note': '①~③'}
ja_cp932.csv cp932 {'name': '髙橋', 'city': '東京', 'note': '①~③'}
シェルでの BOM の付け外しはバイト操作です。{ printf '\xEF\xBB\xBF'; cat in.csv; } > out.csv で付け、tail -c +4 で外せます。どちらも Python の出力とバイト単位で一致しました(cmp は何も出力せず)。
サイズ:UTF-8 は大きくなる?
「UTF-8 にするとファイルが大きくなる」という反対意見はよく聞きます。各言語のサンプル語から1万行の CSV(id・name・city・note)をシード42で作り、バイト数を数えました。
日本語では UTF-8 が CP932 より35%、韓国語では31%大きくなりました。かな・漢字・ハングルが UTF-8 では3バイト、CP932・CP949 では2バイトだからです。英語とスペイン語は8%と11%でした。ただし gzip(レベル9、mtime=0)をかけると差はほぼ消え、日本語でも2.9%(35,043バイト対34,045バイト)でした。転送や保存のコストは、問い合わせ1件の対応よりずっと安いはずです。
どの文字コードで書くか:判断表
| 開く人・システム | 書く文字コード | 注意 |
|---|---|---|
| Windows の Excel でダブルクリックする人 | BOM 付き UTF-8(utf-8-sig) |
Excel の挙動はドキュメントによる説明で未実行 |
| 自社のコード、DB 取り込み、API | BOM なし UTF-8 | 読む側は utf-8-sig で |
Unix のツール(cut・sort・awk・cat) |
BOM なし UTF-8 | BOM が1列目にくっつく |
| 「Shift_JIS で」と指定された古いシステム | CP932(cp932・SJIS-win) |
絵文字などはエラーか置換かを決める。波ダッシュに注意 |
| 相手が分からない・混在 | BOM 付き UTF-8+取り込み手順のメモ | 元の UTF-8 も保管する |
正直な限界とバージョンの注意
- Excel・Windows・Google スプレッドシートは実行していません。Excel の挙動は Microsoft のドキュメントとサポート記事によるもので、Microsoft 365 の新しい版では CSV の開き方が変わる可能性があります。
- Python の cp932 と PHP・iconv で、同じ文字のバイト列が違う例(髙)を確認しました。他の重複コード(NEC と IBM の両方にある文字)も同様の可能性がありますが、全部は調べていません。
str_starts_withは PHP 8.0 以降、fputcsvの改行引数は PHP 8.1 以降です。$escapeの非推奨警告は PHP 8.5.6 で確認しました。- sqlite3 の BOM の扱いは 3.51.0 でのみ確認しました。
練習問題
- 取引先の
customers.csvを Python のencoding="utf-8"で読むと、print(reader.fieldnames)には['id', ...]と出るのにKeyError: 'id'になる。原因と、1語で済む修正は? - 基幹システム向けに CP932 で出力する必要があり、顧客名に
髙橋とゆい👍がある。それぞれどうなり、どう対処しますか。
解答
- ファイルに UTF-8 の BOM があり、1つ目の見出しが
'id'になっています。repr(reader.fieldnames[0])で確認できます。修正はencoding="utf-8-sig"です。 髙橋は CP932 なら書けます(Shift_JIS 指定のシステムなら読めない可能性があるので仕様を確認)。ゆい👍は書けないので、既定のerrors="strict"でエラーにして一時ファイルに書き、成功したときだけリネームする(途中で切れたファイルを残さない)か、errors="replace"で?にして、どの行を変えたかを記録します。黙って行ごと落とすのはだめです。
前回: 講座3:Unicode 正規化と濁点分離。次回: 講座5:文字列の長さ・絵文字・書記素。関連:シェルのパイプとテキスト処理、SQL の基本、HTTP ヘッダーと charset。
よくある質問
CSVの1列目の名前が \ufeffname になるのはなぜ?
ファイルの先頭に UTF-8 の BOM(EF BB BF)があり、それを普通の utf-8 で読んだので BOM が1つ目の見出しに混ざりました。実測では Python の DictReader が '\ufeffname' を返して row['name'] は None、PHP の fgetcsv は7バイトの 'name' を返しました。Python なら encoding='utf-8-sig'、PHP なら先頭3バイトを取り除きます。
Shift_JIS で読んだら髙や①が ? になった。CP932 と何が違う?
Shift_JIS(JIS X 0208)には髙(はしごだか)や①などの機種依存文字がありません。Windows の CP932 は NEC・IBM の拡張文字を含みます。PHP で CP932 のファイルを SJIS として読むと「?橋」「?〜?」になり、SJIS-win(CP932)で読むと正しく「髙橋」「①~③」になりました。Python なら cp932 を指定します。
CP932 で CSV を書いたら絵文字で落ちた。どうする?
CP932 には 👍 がないので、Python は UnicodeEncodeError で止まり、ヘッダー行だけの13バイトのファイルが残りました。PHP の mb_convert_encoding は黙って ? にしました。エラーにする・置き換える・UTF-8 のまま渡す、のどれかを意識して選びます。