open("data.csv") の戻り値が Python 3.15 で変わります。ロケールを見ずに、常に UTF-8 で読むようになるため。3.15.0b3 と 3.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 utf8 や PYTHONUTF8=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_mode | open() の既定 | sys.stdout.encoding |
|---|---|---|---|
| 指定なし | 1 | utf-8 | utf-8 |
PYTHONUTF8=0 | 0 | SJIS | shift_jis |
-X utf8=0 | 0 | SJIS | shift_jis |
PYTHONIOENCODING=cp932 | 1 | utf-8 | cp932 |
最終行が要注意。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 のコマンド(dir、netstat など)を 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の構文と制約も挙動が大きく変わる項目です。

