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" なら ゆい&#128077; になります。会計データならエラーで止めるのが正解、表示名なら置換でもよい、というように意識して選びます。

よくある間違い:「とりあえず全部 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で作り、バイト数を数えました。

棒グラフ(KB):英語 UTF-8 276.5・cp1252 255.8、スペイン語 UTF-8 309.8・cp1252 279.7、日本語 UTF-8 298.6・cp932 221.9、韓国語 UTF-8 322.0・cp949 245.4。
同じ1万行の CSV(id・name・city・note)の言語別・文字コード別サイズ。シード42、単位 KB(1 KB=1,000バイト)。ラテン文字では UTF-8 の差は小さく、日本語と韓国語では約3割増える。BOM は3バイト。

日本語では UTF-8 が CP932 より35%、韓国語では31%大きくなりました。かな・漢字・ハングルが UTF-8 では3バイト、CP932・CP949 では2バイトだからです。英語とスペイン語は8%と11%でした。ただし gzip(レベル9、mtime=0)をかけると差はほぼ消え、日本語でも2.9%(35,043バイト対34,045バイト)でした。転送や保存のコストは、問い合わせ1件の対応よりずっと安いはずです。

どの文字コードで書くか:判断表

判断図。問い:その CSV を誰が開くか。人が Excel でダブルクリックするなら BOM 付き UTF-8。自分のコード・DB への取り込み・Unix のツールが読むなら BOM なし UTF-8。コードページが決まっている古いシステムなら、cp932・cp949・cp1252 などそのコードページで書き、変換できない文字はエラーにするか置き換えるかを決める。
CSV の文字コードは習慣ではなく「誰が開くか」で決める。何で書く場合でも、受け取る CSV はまず utf-8-sig で読み、だめなら既知のレガシーなコードページ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 でのみ確認しました。

練習問題

  1. 取引先の customers.csv を Python の encoding="utf-8" で読むと、print(reader.fieldnames) には ['id', ...] と出るのに KeyError: 'id' になる。原因と、1語で済む修正は?
  2. 基幹システム向けに CP932 で出力する必要があり、顧客名に 髙橋 と ゆい👍 がある。それぞれどうなり、どう対処しますか。

解答

  1. ファイルに UTF-8 の BOM があり、1つ目の見出しが 'id' になっています。repr(reader.fieldnames[0]) で確認できます。修正は encoding="utf-8-sig" です。
  2. 髙橋 は 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 のまま渡す、のどれかを意識して選びます。

  • #CSV
  • #UTF-8
  • #BOM
  • #Shift_JIS
  • #CP932
  • #Excel
  • #文字化け
  • #中級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑