Python 2026年10月9日

Python 3.15で既定の文字コードがUTF-8になる。日本語WindowsでRCを試したら、Excel用のCSVは1列に潰れ、コマンドの出力は例外もなくNoneで返ってきた

XECIN 文字コードWindows技術検証

10月2日に Python 3.15.0rc3 が出ました。3.15 の変更点の一覧を眺めていて、私が手を止めたのは新しい構文ではなく、「encoding を書かない入出力は、OS の設定に関係なく UTF-8 になる」という一行です(PEP 686)。

社内には、小さな Python スクリプトがたくさんあります。チケットの操作、図の書き出し、DB 定義の照合、検証用の使い捨て。そして、そのほとんどは誰かの日本語 Windows の上で動いています。日本語版 Windows の既定の文字コードは cp932 で、3.14 までの Python は encoding を省くとこれに従っていました。つまり 3.15 に上げた日に、同じスクリプトが別の文字コードで読み書きを始めることになります。

正式版が出てから慌てるより、RC のうちに何が変わるかを数えておこう。そう思って、RC3 が出たのを機に私の開発機で試しました。

最初のアプローチ: grep で探す

まず考えたのは、「encoding を書いていない open( を探せばいい」でした。正直なところ、これで十分だと思っていたんですよね。チケット操作や作図の補助、検証用など、社内で使っている自作スクリプト 25 本に対して、こう流しました。

# encoding を指定していない open( を探す(最初のアプローチ)
grep -n "open(" $(cat filelist.txt) | grep -v "encoding" | wc -l
# 実行結果: 11
# 内訳: urllib の urlopen( が 4 行、"rb" のバイナリ読み込みが 5 行、Pillow の Image.open( が 2 行

11 行出て、中身を見たら 11 行とも関係ありませんでした。urlopen( は文字列に open( を含むだけですし、"rb" で開いているものは文字コードと無関係です。

逆に、本当に影響を受ける書き方は 1 つも拾えていませんでした。後で分かったのですが、25 本の中で影響を受けるのは subprocess.run(..., text=True) の 1 か所で、この行には open( という文字がそもそも出てきません。Path.read_text() や write_text() も同じで、文字列検索では見えない書き方が、既定の文字コードに頼る入り口として残っていました。

改善後のアプローチ: 構文木で探す

そこで、文字列ではなく構文木(ast モジュール)で呼び出しを数えることにしました。見る対象は、encoding= を付けずに呼んでいる open()、.open()、read_text() / write_text()、それに text=True 付きの subprocess です。

import ast
import sys

SUBPROCESS_FUNCS = {"run", "Popen", "check_output", "call", "check_call"}


def has_kw(call, name):
    return any(k.arg == name for k in call.keywords)


def kw_is_true(call, name):
    return any(k.arg == name and getattr(k.value, "value", None) is True for k in call.keywords)


def text_mode(call, pos):
    # mode が文字列リテラルで 'b' を含むときだけバイナリとみなす
    args = [k.value for k in call.keywords if k.arg == "mode"] or call.args[pos:pos + 1]
    mode = getattr(args[0], "value", "r") if args else "r"
    return not (isinstance(mode, str) and "b" in mode)


def suspects(path):
    tree = ast.parse(open(path, encoding="utf-8-sig").read())
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call) or has_kw(node, "encoding"):
            continue
        fn = node.func
        if isinstance(fn, ast.Name) and fn.id == "open" and text_mode(node, 1):
            yield node.lineno, "open()"
        elif isinstance(fn, ast.Attribute):
            if fn.attr == "open" and text_mode(node, 0):
                yield node.lineno, ".open()"
            elif fn.attr in ("read_text", "write_text"):
                yield node.lineno, fn.attr + "()"
            elif fn.attr in SUBPROCESS_FUNCS and (kw_is_true(node, "text") or kw_is_true(node, "universal_newlines")):
                yield node.lineno, "subprocess text=True"


for path in sys.argv[1:]:
    for lineno, kind in suspects(path):
        print(f"{path}:{lineno}: {kind}")

25 本に流した結果は 3 件でした。うち 2 件は Pillow の Image.open() で、メソッド名だけで判定しているので誤って拾っています。残る 1 件が、外部コマンド(作図ツールの書き出し)を text=True で呼んでいる箇所でした。

このスクリプトには改善の余地があります。型を見ていないので Image.open() や webbrowser.open() も拾いますし、open() を包んだ自作関数の先や、mode を変数で渡している箇所は追えません。ここは好みが分かれるところですが、私は「誤検知は目で落とせる、見逃しは落とせない」と割り切って、拾いすぎる側に倒しています。

手元では何も変わらなかった

対象が 1 か所なら話は早い、と思って 3.15.0rc3 で実際に走らせてみたら、拍子抜けするほど何も起きませんでした。

理由を探して、しばらく悩みました。原因は私の開発機のユーザー環境変数に PYTHONUTF8=1 が入っていたことです。これは 3.14 以前でも UTF-8 モードを有効にする設定で、3.15 の既定と同じ状態です。いつ、なぜ入れたのかは記録が残っていませんでした。今思えば、どこかで cp932 の UnicodeEncodeError を避けるために入れたのだと思います。

つまり私の机の上では、何年も前から 3.15 と同じ挙動で動いていたことになります。差が出るのは、この変数が入っていない場所です。同僚の PC、新しく用意した PC、環境変数を引き継がない形で起動されるジョブ。ここを再現するため、以降の計測ではこの変数を外し、Python 公式の Windows 向け埋め込み版(3.14.8 と 3.15.0rc3)を並べて同じスクリプトを動かしました。OS は Windows 11 の日本語版(コードページ 932)、PowerShell は 5.1、Excel は 16.0 です。

3.14 と 3.15 を並べて測った

試したこと3.14.83.15.0rc3
locale.getpreferredencoding(False)cp932utf-8
locale.getencoding()cp932cp932(OS の設定はそのまま)
sys.getdefaultencoding()utf-8utf-8(どちらも同じなので手掛かりにならない)
Excel が保存した CSV(cp932)を open() で読む読めるUnicodeDecodeError(先頭バイト 0x8c)
UTF-8 で保存した「請求書の宛名」を open() で読む例外なしで「隲区アよ嶌縺ョ螳帛錐」になる読める
自社サイトの記事ファイル 83 本を open() で読む83 本とも UnicodeDecodeError76 本は正しく読める。7 本は先頭に BOM(U+FEFF)が残る
open(p, “w”) で書いた CSV を Excel で開くA1 に「顧客名」A1 に「鬘ァ螳「蜷・驥鷹。・」。2 列のはずが 1 列に潰れる
print(”✓ done”) をファイルへリダイレクトUnicodeEncodeError で終了コード 1UTF-8 で書ける
PowerShell 5.1 で $s = python -c … と出力を受ける「日本語」「譌・譛ャ隱・」
subprocess.run(text=True)で cmd の echo を受ける「日本語」stdout が None。終了コードは 0
同じく、UTF-8 で出力する node を受けるstdout が None「日本語」

表を作ってみて、「3.15 で壊れる」と一言では言えないことがよく分かりました。UTF-8 のファイルを読む、✓ を含む文字列を書き出す、node の出力を受ける。この 3 つは 3.14 のほうが壊れていて、3.15 で直ります。壊れるのは、相手が cp932 の場面です。Excel が保存した CSV、Excel に渡す CSV、cmd や PowerShell 5.1 とのやり取り。日本語版 Windows の業務スクリプトにとって、いちばん出番の多い場面ばかりなんですよね。

Excel の行は、見た目の化け以上に厄介でした。UTF-8 の「名」の最後のバイトと、続くカンマが cp932 の 2 バイト文字として一緒に読まれ、区切りが消えています。見出しが化けるだけなら人が気付きますが、列がずれた CSV を誰かがそのまま取り込むと、気付くのはずっと後になります。

想定外だったこと1: 例外ではなく None が返ってくる

表の中で、私がいちばん驚いたのは subprocess の行です。文字コードが合わなければ UnicodeDecodeError で止まる、と思っていました。ところが Windows では、止まりません。

import subprocess

# cmd.exe は日本語をコードページ932で書き出す
r = subprocess.run(["cmd", "/c", "echo", "日本語"], capture_output=True, text=True)
print("returncode:", r.returncode)
print("stdout:", repr(r.stdout))

# 実行結果(3.15.0rc3):
# Exception in thread Thread-1 (_readerthread):
#   ...
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0x93 in position 0: invalid start byte
# returncode: 0
# stdout: None

Windows 版の subprocess は、標準出力と標準エラーを別スレッドで読みます。文字コードの変換に失敗するのはそのスレッドの中なので、例外はスレッドの中で終わり、標準エラーに一度表示されるだけです。呼び出し側には、終了コード 0 と、stdout が None の結果が返ってきます。

ドキュメントには「text=True なら文字列で返る」と書いてあるけど、失敗したときに None になることまでは読み取れませんでした。念のため Linux(コンテナの python:3.14-slim)で同じ壊れ方をさせると、こちらは呼び出し側に UnicodeDecodeError が届きました。Windows で動かしたときだけの振る舞いです。

そして、grep では見えなかった社内の 1 か所が、まさにこの形でした。外部コマンドの標準エラーを r.stderr or "" で受けていたので、変換に失敗すると None が空文字に変わり、「失敗した理由」が空欄で報告されます。例外にもならず、ログにも理由が残らない。いちばん気付きにくい壊れ方です。

想定外だったこと2: 直ったはずの読み込みに BOM が残る

もう一つは、良い方向の変化の中にありました。自社サイトの記事ファイル 83 本を encoding なしで読むと、3.14 では 83 本とも UnicodeDecodeError で、3.15 では 83 本とも読めます。ここだけ見ると、3.15 で直ったように見えます。

ただ、83 本のうち 7 本は、先頭に BOM が付いています。3.15 の UTF-8 モードが使うのは utf-8 で、BOM を取り除く utf-8-sig ではありません。そのため 7 本は先頭に U+FEFF が残り、^--- で始まりを探すフロントマターの判定に 7 本とも失敗しました。私たちのチームでは、以前にも記事の集計で同じ取りこぼしをしたことがあります。3.15 にしても、この落とし穴は残ります。

ちなみに、「UTF-8 で読んでみて、失敗したら cp932 で読む」という自動判定は、今回は採りませんでした。表の「請求書の宛名」の行のとおり、短い UTF-8 の文字列は cp932 として例外なく読めてしまうことがあるからです。逆向きの順番なら防げる場面も多いのですが、最後は「誰が書いたファイルか」で決めるしかありません。

境界ごとに文字コードを決める

結局、3.15 の変更そのものより、「encoding を書かなければ誰かが決めてくれる」という前提を外すことが本題でした。書き方は、データの境界ごとに、文字コードを決めているのは誰かで分けています。

境界文字コードを決めているのは書き方
Excel が保存した CSV を読む保存した Excel(日本語版は cp932)encoding=“cp932”
Excel に渡す CSV を書く受け取る Excelencoding=“utf-8-sig”
自分たちの UTF-8 ファイルを読む自分たち(BOM の有無が混ざる)encoding=“utf-8-sig”
cmd や PowerShell 5.1 の出力を受けるその PC の設定encoding=“locale”
node など UTF-8 で出すコマンドを受けるコマンド側encoding=“utf-8”
Python の出力を PowerShell 5.1 で受ける呼ぶ側と呼ばれる側の両方呼ぶ側で PYTHONIOENCODING と出力の文字コードを揃える

Python 側は、こう書き直しました。encoding="locale" は 3.10 からある指定で、3.15 の UTF-8 モードの中でも OS の文字コード(日本語版なら cp932)を返します。「3.14 までの振る舞い」を明示的に書く方法として使えます。

import csv
import subprocess

# 1. Excel が保存した CSV は、作った側の文字コードで読む
with open("excel_cp932.csv", encoding="cp932", newline="") as f:
    rows = list(csv.reader(f))

# 2. Excel に渡す CSV は BOM 付き UTF-8 で書く
with open("for_excel.csv", "w", encoding="utf-8-sig", newline="") as f:
    csv.writer(f).writerows(rows)

# 3. 自分たちの UTF-8 ファイルは、BOM が付いていても読めるようにする
with open("bom_utf8.txt", encoding="utf-8-sig") as f:
    head = f.read(3)

# 4. Windows 標準のコマンドは、その PC の文字コード(日本語版なら cp932)で受ける
r = subprocess.run(["cmd", "/c", "echo", "日本語"], capture_output=True, encoding="locale")

print(rows[1][2], repr(head), r.stdout.strip())
# 実行結果(3.14.8 と 3.15.0rc3 で同じ): 髙橋様(①案件) '---' 日本語

PowerShell 5.1 から呼ぶ側は、最初は受け取り側の [Console]::OutputEncoding を UTF-8 に変えるだけで済ませようとしました。ところがそうすると、今度は 3.14 の出力が化けます。呼ばれる Python の版によって、呼ぶ側の正解が入れ替わってしまうんですね。そこで、出力の文字コードは呼ぶ側が決めて、Python にも伝える形にしました。

# PowerShell 5.1 の既定(日本語版)では、出力を shift_jis として受け取る
$s = & $py315 -c "print('日本語')"   # 3.15.0rc3 だと「譌・譛ャ隱・」

# 受け取り側だけ UTF-8 にすると、今度は 3.14.8 の出力が化ける
[Console]::OutputEncoding = [Text.Encoding]::UTF8

# 呼ぶ側で決めて、Python にも同じ文字コードで書かせる
$env:PYTHONIOENCODING = 'utf-8'
$s = & $py -c "print('日本語')"      # 3.14.8 でも 3.15.0rc3 でも「日本語」

洗い出しには、構文木の走査と合わせて -X warn_default_encoding(3.10 から)も使いました。encoding を省いた open() だけでなく、text=True の subprocess も呼び出し元の行番号付きで EncodingWarning が出ます。こちらは実際に通った行しか出ないので、構文木の走査で全体を、警告で実際の通り道を、という分担にしています。PYTHONUTF8=0 や -X utf8=0 で 3.15 でも元の振る舞いに戻せることは確かめましたが、これは上げるのを先送りする手段で、直す手段ではないと考えています。

振り返って

今回いちばん効いたのは、自分の机の上の結果を疑ったことでした。手元で何も起きなかったのは、ユーザー環境変数に入っていた 1 行のおかげで、その 1 行は誰の記録にも残っていませんでした。3.15 の変更は、言ってみれば「全員の PC にその 1 行を配る」のと同じです。配られる前に、配られていない場所で試す必要がありました。

今後は、社内スクリプトの点検に構文木の走査を入れて、encoding の無い入出力が増えたら気付けるようにしていくつもりです。あわせて、試すときの文字列には ✓ のような cp932 に無い文字と、BOM 付きのファイルを必ず混ぜる。日本語だけで試すと、cp932 でも UTF-8 でも通ってしまう場面が多いからです。正式版が出たら、もう一度同じ表を作り直してみます。