Python 3.15のUTF-8デフォルト化—cp932読み込みの移行対処法

Python 3.15のUTF-8デフォルト化—cp932読み込みの移行対処法 | mohablog

open("data.csv") の戻り値が Python 3.15 で変わります。ロケールを見ずに、常に UTF-8 で読むようになるため。3.15.0b33.14.6 を同じロケールで並べて、直る側と壊れる側を確認しました。

目次

3.14と3.15で同じスクリプトの結果が変わる

用意したのは utf8.txt。UTF-8 で保存した「東京都 気温28度」が入っています。読んで文字数とコードポイントを出すだけのスクリプト。

# read.py
with open("utf8.txt") as f:
    s = f.read()
print("chars:", len(s), "| first3:", [f"U+{ord(c):04X}" for c in s[:3]])

ロケールを ja_JP.SJIS に固定して、2つのバージョンで実行します。

$ LC_ALL=ja_JP.SJIS python3.14 read.py
chars: 16 | first3: ['U+8B5A', 'U+FF71', 'U+83A0']

$ LC_ALL=ja_JP.SJIS python3.15 read.py
chars: 10 | first3: ['U+6771', 'U+4EAC', 'U+90FD']

3.14 は16文字。先頭の U+8B5A は「譚」で、UTF-8 のバイト列を SJIS として解釈した結果です。3.15 は10文字U+6771(東) U+4EAC(京) U+90FD(都) と正しく並びます。同じファイル、同じコード、違う戻り値。

切り替えているのは sys.flags.utf8_mode

差分の出どころは起動時のフラグ1つです。

$ LC_ALL=ja_JP.SJIS python3.14 -c "import sys, locale; \
print('utf8_mode=', sys.flags.utf8_mode); \
print('getpreferredencoding=', locale.getpreferredencoding(False)); \
print('getencoding=', locale.getencoding()); \
print('stdout=', sys.stdout.encoding)"
utf8_mode= 0
getpreferredencoding= SJIS
getencoding= SJIS
stdout= shift_jis

$ LC_ALL=ja_JP.SJIS python3.15 -c "(同じコード)"
utf8_mode= 1
getpreferredencoding= utf-8
getencoding= SJIS
stdout= utf-8

3.7 以降 -X utf8PYTHONUTF8=1 で手動オンにできた UTF-8 Mode が、3.15 では最初から 1 で立っています。

locale.getencoding() だけは SJIS のまま

上の出力で注目したいのは3行目。getpreferredencoding()utf-8 に変わっても、getencoding()SJIS を返し続けます。前者は「Python がデフォルトで使うエンコーディング」、後者は「OS のロケールエンコーディング」。3.11 で追加された locale.getencoding() は、UTF-8 Mode の影響を受けない値を取るための API です。

ロケールの実値が要る箇所 – 外部コマンドの出力を復号する、OS 由来のファイル名を扱う – は locale.getencoding() に書き換えます。

直るのは UTF-8 を読む側、壊れるのは cp932 を読む側

「文字化けが直る」は片側だけの説明です。逆に、これまで読めていたものが読めなくなるコードがあります。ロケールが cp932 であることを前提に、encoding 無指定で cp932 ファイルを読んでいたコード

csv.reader が UnicodeDecodeError で止まる

cp932 で保存した CSV を、encoding を書かずに読むスクリプト。

# read_sjis.py
import csv

with open("sjis.csv", newline="") as f:
    for row in csv.reader(f):
        print(row)

3.14 なら通ります。標準出力だけは UTF-8 に固定して、open() の挙動を切り分けています。

$ LC_ALL=ja_JP.SJIS PYTHONIOENCODING=utf-8 python3.14 read_sjis.py
['部署', '人数']
['営業部', '12']
['開発部', '8']

3.15 で同じコマンドを打つと、1行目で落ちます。

$ LC_ALL=ja_JP.SJIS PYTHONIOENCODING=utf-8 python3.15 read_sjis.py
Traceback (most recent call last):
  File "/tmp/enc_demo/read_sjis.py", line 5, in <module>
    for row in csv.reader(f):
               ~~~~~~~~~~^^^
  File "<frozen codecs>", line 325, in decode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 0: invalid start byte

0x95 は「部」の cp932 表現 0x95 0x94 の先頭バイト。UTF-8 の開始バイトとして不正なので即座に例外になります。3.14 まで黙って通っていた処理が、例外で止まる。移行時に最初に踏むのはここです。

被弾しやすいのは Excel 由来の CSV と Windows のログ

cp932 のまま流通しているファイルには決まった出どころがあります。

  • Excel の「CSV (コンマ区切り)」で保存したファイル(「CSV UTF-8」は別項目)
  • Windows のバッチや PowerShell がリダイレクトで吐いたログ
  • 基幹システムから落とした固定長・TSV のデータ
  • SJIS で運用されている FTP 連携の受信ファイル

これらを読む箇所は、3.15 を待たずに encoding="cp932" を明示しておけば挙動が固定されます。3.14 でも 3.15 でも同じ結果になるため、バージョンを切り替えながらの検証も要りません。

端末の見た目だけで判断すると見落とす

検証中に引っかかった挙動を1つ。3.14 で SJIS ロケールのまま UTF-8 ファイルを読み、そのまま print() すると、端末には正しい日本語が出ます。open() が UTF-8 バイト列を SJIS として復号し、sys.stdout がそれを SJIS に再エンコードするので、バイト列が往復して元に戻るためです。len() を取ると16文字と10文字で食い違うのに、画面は同じ。エンコーディングの検証は、表示ではなく 文字数かコードポイント で確かめます。

PEP 686 が定義している範囲

PEP 686 の Status は Final、Python Version は 3.15。Abstract は2文しかありません。

This PEP proposes enabling UTF-8 mode by default. With this change, Python consistently uses UTF-8 for default encoding of files, stdio, and pipes.

新しいエンコーディング機構を足す提案ではなく、3.7 で入った UTF-8 Mode (PEP 540) を既定でオンにするだけ。Specification 配下の項目も “Enable UTF-8 mode by default”、”locale.getencoding()“、”Fixing encoding="locale" option” の3つで、追加 API は locale.getencoding() に限られます。

UTF-8 Mode が触る対象

何が UTF-8 に固定されるかは、os モジュールの “Python UTF-8 Mode” セクションに列挙されています。

  • ファイルシステムエンコーディングが UTF-8 になる
  • sys.getfilesystemencoding()'utf-8' を返す
  • locale.getpreferredencoding()'utf-8' を返す(do_setlocale 引数は無効化)
  • sys.stdin / sys.stdout / sys.stderr が UTF-8 になり、stdin と stdout は surrogateescape エラーハンドラ付き
  • Unix では os.device_encoding() がデバイスのエンコーディングではなく 'utf-8' を返す

この結果として open()io.open()codecs.open()、コマンドライン引数・環境変数・ファイル名の復号、os.fsdecode() / os.fsencode() が UTF-8 で動きます。pathlib.Path.read_text() は内部で open() を呼ぶので同じ扱い。

encoding=”locale” は逃げ道として残る

PEP 686 が “Fixing encoding="locale" option” を項目に立てているとおり、ロケールエンコーディングを明示的に使う手段は残ります。3.10 で入った書き方です。

import csv

with open("sjis.csv", newline="", encoding="locale") as f:
    print(next(csv.reader(f)))
$ LC_ALL=ja_JP.SJIS PYTHONIOENCODING=utf-8 python3.15 read_locale.py
['部署', '人数']

実行環境のロケールに追従させたいときはこれ。ただし読むファイルのエンコーディングが決まっているなら、encoding="cp932" と直接書くほうが再現性が高くなります。

設定が競合したときの優先順位

LC_ALL=ja_JP.SJIS を固定したまま、環境変数と起動オプションを変えて 3.15.0b3 で実測した結果。

起動条件sys.flags.utf8_modeopen() の既定sys.stdout.encoding
指定なし1utf-8utf-8
PYTHONUTF8=00SJISshift_jis
-X utf8=00SJISshift_jis
PYTHONIOENCODING=cp9321utf-8cp932

最終行が要注意。PYTHONIOENCODING は標準入出力だけを上書きし、open() の既定には効きません。標準出力の文字化けと、ファイル読み込みの例外は別々に切り分けます。

subprocess の text=True も巻き込む

subprocess.run(..., text=True) の復号にも locale.getpreferredencoding() が使われるので、この変更の射程に入ります。子プロセスが UTF-8 を吐き、親が SJIS で復号していた構成は 3.15 で揃います。

$ LC_ALL=ja_JP.SJIS PYTHONIOENCODING=utf-8 python3.14 mixed.py
subprocess text=True: '譚ア莠ャ\n'

$ LC_ALL=ja_JP.SJIS PYTHONIOENCODING=utf-8 python3.15 mixed.py
subprocess text=True: '東京\n'

逆に cp932 を吐く Windows のコマンド(dirnetstat など)を text=True で拾っていたコードは、3.15 で復号に失敗します。encoding="cp932"subprocess.run() の引数に渡して固定するのが確実です。

移行前に encoding 無指定の open を洗い出す

置き換え対象を目視で探す必要はありません。-X warn_default_encoding を付けると、encoding を省略した open() がその場で警告になります。PEP 597 の EncodingWarning です。

-X warn_default_encoding で場所を特定する

$ LC_ALL=ja_JP.SJIS PYTHONIOENCODING=utf-8 python3.15 -X warn_default_encoding read.py
/tmp/enc_demo/read.py:1: EncodingWarning: 'encoding' argument not specified
  with open("utf8.txt") as f:
chars: 10 | first3: ['U+6771', 'U+4EAC', 'U+90FD']

環境変数 PYTHONWARNDEFAULTENCODING=1 でも同じ警告が出ます。行番号とソース行が付くので、grep -rn 'open(' の結果から encoding 済みの行を目視で外す手間が要りません。pathlib.Path.read_text() も同様に拾えます。

$ LC_ALL=ja_JP.SJIS PYTHONIOENCODING=utf-8 python3.15 -X warn_default_encoding pl.py
/tmp/enc_demo/pl.py:2: EncodingWarning: 'encoding' argument not specified
  print(pathlib.Path("utf8.txt").read_text()[:3])
東京都

CI では -W error::EncodingWarning で落とす

警告のままだと見逃すので、CI では例外に昇格させます。

$ LC_ALL=ja_JP.SJIS python3.15 -X warn_default_encoding -W error::EncodingWarning read.py
Traceback (most recent call last):
  File "/tmp/enc_demo/read.py", line 1, in <module>
    with open("utf8.txt") as f:
         ~~~~^^^^^^^^^^^^
EncodingWarning: 'encoding' argument not specified
$ echo $?
1

終了コード 1 なので、そのままジョブが赤くなります。pytest なら pyproject.toml に書けます。

[tool.pytest.ini_options]
filterwarnings = ["error::EncodingWarning"]

ただし -X warn_default_encoding は起動オプションなので、PYTHONWARNDEFAULTENCODING=1 pytest のように環境変数側で渡します。警告の発生源が依存ライブラリの内部だと自分では直せないため、まずは自プロジェクトのファイルに絞って潰すのが現実的です。Pythonのruff入門 – pyproject.toml設定とCI組み込みの書き方と同じく、先に CI へ組み込んでから件数を減らす順で進めます。

PYTHONUTF8=0 で戻すか、encoding を書くか

移行の逃げ道として PYTHONUTF8=0 が用意されていますが、使える場面は限られます。

アプリケーションなら PYTHONUTF8=0 が効く

自分で起動方法を制御できるバッチやサービスなら、Dockerfile や systemd の unit に PYTHONUTF8=0 を入れれば 3.14 までと同じ挙動で動きます。3.15 へのアップグレードと encoding の書き足しを、別のタイミングに分けられます。

ただし恒久策にはなりません。PYTHONUTF8=0 が入った環境では、UTF-8 前提で書いた新しいコードのほうが今度は動かなくなります。

ライブラリでは呼び出し側に効かない

PyPI に出すライブラリ側では、この変数は無意味です。値を決めるのは利用者のプロセス。ライブラリ内部の open() が encoding 無指定なら、利用者の環境変数次第で読めたり読めなかったりします。配布物では encoding= を全箇所に明示する以外の選択肢がない。

起動を誰が握るかで分かれます。

  • 自分で起動を握れる → PYTHONUTF8=0 で時間を買い、並行して encoding を書く
  • 他人が起動する(ライブラリ・CLI 配布物)→ 最初から encoding を全箇所に明示

まとめ

Python 3.15.0 の正式リリースは 2026年10月1日予定。3.15.0b3 で確認した内容を整理します。

  • PEP 686 は UTF-8 Mode を既定でオンにするだけの変更。Status は Final
  • UTF-8 ファイルを非 UTF-8 ロケールで読んでいた箇所は直る
  • cp932 ファイルを encoding 無指定で読んでいた箇所は UnicodeDecodeError で止まる
  • locale.getpreferredencoding()utf-8 に変わるが locale.getencoding() は SJIS のまま
  • PYTHONIOENCODING は標準入出力のみ上書きし、open() の既定には効かない
  • subprocess(text=True) の復号も同じ既定に従う
  • 洗い出しは -X warn_default_encoding、CI は -W error::EncodingWarning で終了コード1
  • PYTHONUTF8=0 はアプリの一時退避には使えるが、ライブラリでは効かない

同じ 3.15 の変更ではPython 3.15のlazy importを実測 – PEP 810の構文と制約も挙動が大きく変わる項目です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次