文字化けの仕組みと直し方:縺ゅ・譁�の正体をPythonで再現

縺ゅj縺後→や�はなぜ出るのか。UTF-8とShift_JIS(CP932)を取り違えて文字化けを再現し、パターンの読み方、波ダッシュと機種依存文字の問題、errors引数の違い、戻せる化けと戻せない化けを1,000件で実測します。

シリーズ:文字コード講座 2/6(初級)。 アプリや DB、メールで「縺ゅj縺後→」や「�」を見て、何が起きたのか、元に戻せるのかを知りたい人向けです。読み終えると、文字化けをわざと再現し、よくあるパターンを見ただけで原因を言い当て、二重エンコードを安全に直し、二度と戻らないケースを見分けられるようになります。

古い基幹システムが吐く Shift_JIS の CSV、取引先から届いた ISO-2022-JP のメール、UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 で止まったバッチ処理。こうした「文字化け」の問い合わせを受ける人がこの講座の読者です。文字化けとは、正しいバイト列を間違った文字コードでデコードした結果です。そしてここが重要ですが、日本語の文字化けは英語やスペイン語の文字化けに比べて「化けた文字列から」は戻しにくく、元のバイト列を残しておくことが何より大事です。以下はすべて Apple Silicon Mac(M1 Pro)上の Python 3.9.6、PHP 8.5.6、macOS の iconv で実行した結果で、トレースバックのパスは ~/demo/ に置き換えています。

バイト・コードポイント・UTF-8 が初めての人は、先に第1回を読んでください。この回は e3 81 82 を3バイトとして読めることを前提にしています。

文字化けの仕組み:バイトは正しく、デコーダが違う

向きは2つしかなく、それぞれ見た目が違います。

UTF-8 のバイト列をレガシー文字コード(CP932 など)で読む。 1文字3バイトが2バイトずつに切られて、見慣れない漢字が並びます。「縺」「繧」「繝」「譁」が多いのが特徴です。

レガシーのバイト列を UTF-8 で読む。 Shift_JIS のバイト列はほとんどが UTF-8 として不正なので、厳密なデコーダは UnicodeDecodeError で止まり、寛容なデコーダは �(U+FFFD、REPLACEMENT CHARACTER)を表示します。

2行で両方を作れます。

print("ありがとう".encode("utf-8").decode("cp932", "replace"))   # 縺ゅj縺後→縺�
print("テスト".encode("cp932").decode("utf-8", "replace"))        # �e�X�g

なぜ「縺」なのか。ひらがなの UTF-8 は e3 81 xx か e3 82 xx で始まります。e3 81 を CP932 の2バイト文字として読むと「縺」、e3 82 は「繧」、カタカナの e3 83 は「繝」です。だから「縺」が並んでいたら元はひらがな、「繝」「繧」が多ければカタカナです。逆向きの �e�X�g では、Shift_JIS の2バイト目が ASCII 範囲(65 = e、58 = X、67 = g)にあるため、英字だけが生き残っています。

判断の図。文字化けした文字列から始める。問い1:縺・繧・譁 のような余計な文字が混じっているか。はいなら UTF-8 を cp932 などで読んだもので、元のバイト列が残っていれば UTF-8 で読み直せる。いいえなら問い2:�や UnicodeDecodeError が出ているか。はいなら Shift_JIS のバイト列を UTF-8 で読んだもので、元のバイト列を cp932 で読み直す。�や ? がすでに保存されていれば元に戻せない。
文字化けの読み方。余計な文字が増えていれば UTF-8 をレガシー文字コードで読んだもので、たいてい元に戻せる。�やエラーならレガシーのバイト列を UTF-8 で読んだもので、元のバイト列から読み直す。

同じ仕組みは言語を問わず起きます。このサイトの読者がよく出会う組み合わせを実行した結果です。

元の文字列 保存 読み込み 見える文字列
ありがとう UTF-8 cp932 縺ゅj縺後→縺�(errors="replace")
文字化け UTF-8 cp932 譁�蟄怜喧縺�
テスト UTF-8 cp932 繝�繧ケ繝�
文字化け cp932 UTF-8 ��������
文字化け cp932 cp1252 •¶Žš‰»‚¯
文字化け ISO-2022-JP UTF-8 $BJ8;z2=$1(B(エスケープ文字は見えない)
カナ cp932 cp1252 ¶Å
don’t UTF-8 cp1252 don’t

最後の ISO-2022-JP は昔のメールで標準だった文字コードです。ESC $ B で漢字モードに入り ESC ( B で ASCII に戻る方式なので、UTF-8 で読むと $B と (B に挟まれた英数字の並びになります。メールのソースでこれを見たら ISO-2022-JP です。

最初の5分:本物のエラーメッセージを読む

古い Windows のプログラムと同じように Shift_JIS でファイルを書き、それを何も指定せずに開きます。

# make_old.py
open("old.txt", "wb").write("文字化け\n".encode("cp932"))
# readfile.py   python3 readfile.py old.txt
import sys
with open(sys.argv[1]) as f:     # encoding= なし → macOS・Linux では UTF-8
    print(f.read())
Traceback (most recent call last):
  File "~/demo/readfile.py", line 3, in <module>
    print(f.read())
  File ".../lib/python3.9/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 0: invalid start byte

最終行を探偵のように読みます。位置0の 0x95 は「文」の Shift_JIS の1バイト目(95 b6)です。invalid start byte は、UTF-8 で文字の先頭が来るべき場所に 0x80〜0xBF のバイトが来たという意味。0x81〜0x9F の後に 0x40 以上が続く並びは Shift_JIS の典型です。直し方は、ファイルの正体を宣言するだけです。open("old.txt", encoding="cp932") で 文字化け と表示されました。

iconv で確かめる場合も同じです。iconv -f SHIFT_JIS -t UTF-8 old.txt は 文字化け を出し、逆に UTF-8 のファイルを -f SHIFT_JIS で読ませると、縺ゅj縺後→縺 まで出力したところで iconv: iconv(): Illegal byte sequence を出し、終了コード1で止まりました。

Shift_JIS と CP932 は同じではない:波ダッシュと機種依存文字

日本語特有の落とし穴が、「Shift_JIS」と呼ばれるものが実は複数あることです。JIS 規格どおりの Shift_JIS と、Windows が使う CP932(Windows-31J、MS932 とも)は、大部分は同じですが一部の対応が違います。

def t(s, enc):
    try: return s.encode(enc).hex(" ")
    except UnicodeEncodeError as e: return "ERR " + str(e)
for s in ["〜", "~", "①", "㈱", "髙", "カナ"]:
    print(repr(s), f"U+{ord(s[0]):04X}", "shift_jis:", t(s, "shift_jis"), "| cp932:", t(s, "cp932"))
'〜' U+301C shift_jis: 81 60 | cp932: 81 60
'~' U+FF5E shift_jis: ERR 'shift_jis' codec can't encode character '~' in position 0: illegal multibyte sequence | cp932: 81 60
'①' U+2460 shift_jis: ERR 'shift_jis' codec can't encode character '①' in position 0: illegal multibyte sequence | cp932: 87 40
'㈱' U+3231 shift_jis: ERR 'shift_jis' codec can't encode character '㈱' in position 0: illegal multibyte sequence | cp932: 87 8a
'髙' U+9AD9 shift_jis: ERR 'shift_jis' codec can't encode character '髙' in position 0: illegal multibyte sequence | cp932: ee e0
'カナ' U+FF76 shift_jis: b6 c5 | cp932: b6 c5
  • 機種依存文字:① や ㈱、はしご高の 髙 は CP932 の拡張領域(NEC 特殊文字・IBM 拡張文字)にしかありません。shift_jis で書こうとするとエラーになります。逆に CP932 で書いたバイト列を shift_jis で読むと、①㈱髙 は '�@�基�' になりました。髙 のバイト列の一部が別の漢字「基」として読まれている点に注意してください。名前の漢字が別の字に化けるのは、� よりも気づきにくい事故です。
  • 波ダッシュ問題:バイト 81 60 は、JIS の Shift_JIS では WAVE DASH(U+301C、〜)、CP932 では FULLWIDTH TILDE(U+FF5E、~)に対応します。Python の cp932 は 〜 を 81 60 にエンコードしますが、デコードすると ~ が返ってきます。"10時〜12時" を cp932 で往復させると 10時~12時 になり、元の文字列と == で比較すると False でした。

ツールによって振る舞いが違うのも厄介です。macOS の iconv で 10時〜12時 を変換すると、-t SHIFT_JIS は 81 60 を出しましたが、-t CP932 と -t SJIS-WIN は 〜 のところで Illegal byte sequence になりました。PHP 8.5 の mb_convert_encoding は SJIS-win でも CP932 でも 〜 を 81 60 にし、① は SJIS 指定だと 3f(?)に置き換えました。Mac で入力した「〜」(U+301C)と Windows で入力した「~」(U+FF5E)が同じデータに混ざるのはよくあることなので、CSV を Shift_JIS 系で書き出す前に、どちらに揃えるかを決めておく必要があります。

パターンの見分け方

慣れると、化けた文字列が原因を教えてくれます。上の実行結果からまとめた表です。

見えるもの よくある原因 対処
縺、繧、繝、譁 が並ぶ UTF-8 を cp932 で読んだ 元のバイト列を UTF-8 で読み直す
�e�X�g のように英字だけ残る Shift_JIS を UTF-8 で読んだ 元のバイト列を cp932 で読み直す
•¶Žš‰»‚¯ Shift_JIS を cp1252(欧米向け Windows)で読んだ s.encode("cp1252").decode("cp932")
文字化ã�‘ UTF-8 を cp1252 で読んだ s.encode("cp1252").decode("utf-8")(�がなければ)
$B...(B ISO-2022-JP(メール)を UTF-8 で読んだ iso2022_jp で読み直す
¶Å のような1文字ずつの記号 半角カナ(1バイト)を cp1252 で読んだ s.encode("cp1252").decode("cp932")

二重エンコードを直す

UTF-8 を cp1252 として読み、それをまた UTF-8 で保存した「二重エンコード」は、バイトが失われていないので逆順にたどれば戻ります。英語圏のデータでよく見る形です。

print("don’t".encode("cp1252").decode("utf-8"))   # don’t
print("テスト".encode("cp1252").decode("utf-8"))  # テスト

ところが cp932 で化けた日本語は、化けた文字列からはほとんど戻せません。UTF-8 の3バイト単位と CP932 の2バイト単位がずれるため、どこかで CP932 として不正な並びに当たり、そこに � が入るからです。

r = "ありがとう".encode("utf-8").decode("cp932", "replace")
print(repr(r))                                     # '縺ゅj縺後→縺�'
print(repr(r.encode("cp932", "replace").decode("utf-8", "replace")))   # 'ありがと�?'

「ありがと」までは戻りましたが、最後の「う」は失われました。だから、文字化けの報告を受けたら、まず化ける前のファイルやバイト列が残っていないかを探します。

PHP でもこのバグは1行で作れます。すでに UTF-8 の文字列に対して mb_convert_encoding($s, "UTF-8", "SJIS-win") を呼ぶと二重変換になります。正しくは、外から来たバイト列にだけ1回変換をかけることです。mb_check_encoding($bytes, "UTF-8") で Shift_JIS のファイルを調べると false が返りました。

strict・replace・ignore・surrogateescape

Python のデコードには errors 引数があります。文字化け を cp932 で保存したバイト列(95 b6 8e 9a 89 bb 82 af)に全部試しました。

errors= 結果 元に戻せるか
strict(既定) UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 0: invalid start byte 何もデコードされない
replace '��������' 戻せない
ignore ''(空文字列!) 戻せない、しかも無言
backslashreplace '\\x95\\xb6\\x8e\\x9a\\x89\\xbb\\x82\\xaf' 手作業なら
surrogateescape '\udc95\udcb6...' 戻せる:.encode("utf-8", "surrogateescape") で元のバイト列に一致

ignore では4文字がまるごと消えて空文字列になりました。日本語のバイトはほぼすべて 0x80 以上なので、ignore は日本語を全部捨てるのと同じです。

エンコード側も同じです。"10時〜12時 ①👍" を shift_jis で errors="replace" にすると b'10\x8e\x9e\x81`12\x8e\x9e ??' になり、① と絵文字が本物の ? と区別できない 3f になりました。

よくある間違い:replace や ignore でエラーを「直す」

間違い。 夜間バッチが UnicodeDecodeError で落ちたので、errors="ignore"(または iconv -c)を付けて緑にする。

実際に起きること。 エラーは消えますが、データは黙って消えます。上で見たとおり、Shift_JIS の日本語を UTF-8 として ignore で読むと文字列ごと空になります。iconv -c も変換できない文字を捨て、iconv: warning: invalid characters: 1 という警告を出すだけで続行します。

PHP には別の静かな罠があります。不正な UTF-8(ここでは Shift_JIS のファイル)を htmlspecialchars($s, ENT_QUOTES) に渡すと、PHP 8.5 で空文字列が返りました。PHP 8.1 以降の既定フラグには ENT_SUBSTITUTE が含まれていて、既定のままなら1バイトごとに � になります。PHP 8.1 より前に書かれたコードは ENT_QUOTES だけを渡していることが多く、不正なバイトが1つあるだけで項目全体が消えます。

直し方。 バイト列の正体を調べ(xxd、上のパターン表、判定は第6回)、その文字コードでデコードします。正体の分からないバイトを通すしかないなら、往復できる surrogateescape を使います。replace は表示用に限り、保存するデータには使いません。なお、日本で定番の nkf はこの Mac には入っていないため、ここでは試していません。

戻せない文字化け:実測

どの文字化けが元に戻せるのかを測りました。言語ごとにサンプル文の文字からランダムに20文字を選んだ文字列を1,000個(random.Random(42))作り、UTF-8 にしてから間違った文字コードでデコードし、修復を試みます。修復関数は「間違えた文字コードで再エンコードして UTF-8 で読む」だけのものです。

rng = random.Random(42)
for _ in range(1000):
    s = "".join(rng.choice(POOL) for _ in range(20))
    raw = s.encode("utf-8")
    try:
        wrong = raw.decode(codec)              # そもそも間違ったデコードが成功するか
    except UnicodeDecodeError:
        wrong = raw.decode(codec, handler)     # 寛容なツールが表示したであろう文字列
    fixed += unmojibake(wrong, codec) == s
ja read as cp932   (replace): decoded without error   49/1000, repaired   49/1000
ko read as cp949   (replace): decoded without error    0/1000, repaired    0/1000
es read as cp1252  (replace): decoded without error  584/1000, repaired  584/1000
es read as cp1252  (c1     ): decoded without error  584/1000, repaired 1000/1000
es read as latin-1 (strict ): decoded without error 1000/1000, repaired 1000/1000
en read as cp1252  (replace): decoded without error  688/1000, repaired  688/1000
en read as cp1252  (c1     ): decoded without error  688/1000, repaired 1000/1000
1,000件中の修復成功数の棒グラフ。日本語を cp932 で置換付きデコード:49。韓国語を cp949 で置換付き:0。スペイン語を cp1252 で置換付き:584。スペイン語を cp1252 で未定義バイトを残して:1,000。スペイン語を Latin-1 で:1,000。英語を cp1252 で置換付き:688。英語を cp1252 で未定義バイトを残して:1,000。
各行1,000個のランダムな20文字の文字列(seed 42)を UTF-8 にし、間違った文字コードでデコードしてから修復した件数。すべてのバイトを残すデコードだけが完全に戻せる。Python 3.9。

日本語は1,000件中49件しか � なしでデコードできず、修復できたのもその49件だけでした。欧米の言語では、すべてのバイトを残すデコード(Latin-1 や、ブラウザと同じく未定義バイトを残す c1)なら1,000件すべてが戻ります。つまり「化けた文字列から直す」作戦は欧米語では通用しても、日本語ではほぼ通用しません。

戻せないケースをまとめると次のとおりです。

  1. 保存された �。その裏のバイトはもうありません。
  2. エンコーダが errors="replace" で書いた ?(① を Shift_JIS に、絵文字を Shift_JIS に)。
  3. ignore や iconv -c で捨てられた文字。
  4. 波ダッシュのように、往復で別のコードポイントに変わった文字(見た目はほぼ同じでも == は False)。

正直な限界とバージョンの注記

  • Python 3.9.6、PHP 8.5.6(mbstring)、macOS 26 の iconv(--version がなく GNU libiconv とは別物で、メッセージも Linux と違います)。Linux と Windows では実行していません。
  • Windows 上の Python は 3.15 より前だと、encoding= を省略したとき日本語版 Windows では cp932 でファイルを開きます。PEP 686 により 3.15 から UTF-8 が既定になります。これは PEP の記述によるもので、ここでは実行していません。
  • nkf と ftfy はインストールされておらず(何もインストールしない方針)、比較していません。
  • 1,000件の実験は短いサンプル文の文字から選んでいるので、実データの分布とは違います。自分のデータで測ってから「直せます」と約束してください。

練習問題

  1. 顧客データに 繝�繧ケ繝� という文字列が見つかりました。元はひらがなとカタカナのどちらだった可能性が高いですか。
  2. ファイルの先頭が 95 b6 8e 9a です。どの文字コードで、何と書いてありますか。
  3. "10時〜12時" を cp932 で保存して読み直した文字列が、元の文字列と == にならないのはなぜですか。

解答

  1. カタカナです。カタカナの UTF-8 は e3 82 xx・e3 83 xx で始まり、cp932 で読むと「繧」「繝」になります。ひらがななら「縺」が多くなります。
  2. Shift_JIS(CP932)の「文字」です。95 b6 が「文」、8e 9a が「字」です。UTF-8 として読むと位置0の 0x95 で invalid start byte になります。
  3. Python の cp932 は 〜(U+301C)を 81 60 にエンコードし、81 60 を ~(U+FF5E)にデコードするためです。見た目はほぼ同じでもコードポイントが違います。

次回

両側ともバイト列は正しい UTF-8 なのに、文字列が一致しないことがあります。それが Unicode 正規化です。

よくある質問

「縺ゅj縺後→縺」のような文字化けは何が原因ですか?

UTF-8 で保存された日本語を Shift_JIS(CP932)として読んだときの典型的なパターンです。ひらがなの UTF-8 は E3 81 xx で始まり、E3 81 を CP932 で読むと「縺」になります。元のバイト列が残っていれば UTF-8 として読み直せば戻ります。

化けた文字列をそのまま元に戻せますか?

日本語の場合はほとんど戻せません。ランダムな20文字の文字列1,000件を UTF-8→cp932 で化けさせたところ、�なしでデコードできたのは49件だけで、修復できたのもその49件でした。�が入った時点で元のバイトは失われます。元ファイルから読み直すのが正解です。

「〜」が「~」に変わったり、変換エラーになったりするのはなぜ?

WAVE DASH(U+301C)と FULLWIDTH TILDE(U+FF5E)の対応が Shift_JIS と CP932 で違うためです。Python の cp932 は U+301C を 81 60 にエンコードしますが、81 60 をデコードすると U+FF5E になり、往復で別の文字に変わりました。macOS の iconv の CP932 は U+301C を変換エラーにしました。

  • #文字コード
  • #初級
  • #文字化け
  • #Shift_JIS
  • #CP932
  • #Python

t.laboratory

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

運営者情報 · アプリ

↑