Skip to content
Journal

Technology · Programming Languages

Python 3.15 UTF-8 Default: Audit Your open() Calls

Python 3.15 made UTF-8 the default encoding on every platform. Here is how to find the open() calls that change behavior, and fix them before upgrading.

Abhishek Gupta

Abhishek Gupta

7 min read

A bright brass key turning in a wooden door lock while an old rusted key and a pale key rest on a shelf

Sponsored

Share

Python 3.15 now reads and writes text files as UTF-8 by default, on every platform, whatever the system locale says. If you run Python on Linux or macOS you will probably notice nothing. If any of your code runs on Windows and calls open() without an encoding argument, you should audit it before you upgrade. This post shows how to find those calls and what to replace them with.

Python 3.15.0 was released on October 9, 2026. The release candidates slipped a week because of last-minute lazy-import blockers, which is why our earlier overview of Python 3.15’s headline features listed October 1. The encoding change, defined in PEP 686, is the one in that release most likely to change behavior for existing code.

What changed, exactly

Before 3.15, open("notes.txt") in text mode used the locale encoding. On a typical Linux box that is UTF-8. On an English-language Windows machine it is usually cp1252, and on other Windows installs it is a different legacy code page. The same script produced different bytes depending on where it ran.

Now UTF-8 mode is on by default, so the default is UTF-8 everywhere. Two things stay the same: locale.getencoding() still tells you the real locale encoding (it ignores UTF-8 mode), and encoding="locale" still means “use the locale encoding, even in UTF-8 mode”.

You can see the failure in two lines:

>>> "café".encode("utf-8").decode("cp1252")
'café'
>>> "café".encode("cp1252").decode("utf-8", errors="replace")
'caf�'

The first is the quiet one: a UTF-8 file read with a Windows code page turns é into é. The second is the 3.15 failure: a cp1252 file read as UTF-8. With the default strict error handling you get a UnicodeDecodeError rather than that replacement character, which is the better outcome, because you notice.

The PEP is candid about the risk. Code that relies on the default can hit UnicodeError, mojibake, or silent data corruption. Most Unix systems already use a UTF-8 locale, so the change mainly affects Windows.

Step 1: find every call that depends on the default

The built-in way is the encoding warning. Run your test suite on an interpreter where the old default still applies, with the warning on:

# Python 3.15 with UTF-8 mode off, so the old default is back
PYTHONUTF8=0 python -X warn_default_encoding -W error::EncodingWarning -m pytest

Every text-mode open() that omits encoding now raises, with a traceback pointing at the line. This only finds calls your tests actually execute, which is its weakness. Code paths that run once a month in production do not show up.

So pair it with a static scan. This script walks every .py file under a directory and reports text-mode open(), Path.read_text() and Path.write_text() calls with no encoding keyword:

import ast
import pathlib
import sys

TEXT_FUNCS = {"open", "read_text", "write_text"}

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

def is_binary_open(call):
    mode = None
    if len(call.args) > 1 and isinstance(call.args[1], ast.Constant):
        mode = call.args[1].value
    for k in call.keywords:
        if k.arg == "mode" and isinstance(k.value, ast.Constant):
            mode = k.value.value
    return isinstance(mode, str) and "b" in mode

def scan(path):
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        fn = node.func
        name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", None)
        if name not in TEXT_FUNCS or has_kw(node, "encoding"):
            continue
        if name == "open" and is_binary_open(node):
            continue
        yield node.lineno, name

if __name__ == "__main__":
    root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")
    for p in sorted(root.rglob("*.py")):
        for lineno, name in scan(p):
            print(f"{p}:{lineno}: {name}() with no encoding=")

It is deliberately conservative. A mode passed through a variable is not resolved, so some binary opens will show up as false positives. That is fine for a one-time audit. If you already use Ruff, its PLW1514 rule (unspecified-encoding) flags the same calls and can stay on in CI afterwards.

Also grep for locale.getpreferredencoding(). In UTF-8 mode it now reports UTF-8, so code that used it to guess the file encoding will quietly start returning a different answer. Use locale.getencoding() if you really want the locale, or just write "utf-8".

Step 2: decide per file, not per call

Adding encoding="utf-8" everywhere is correct for files your own code creates. It is wrong for files created by something else. Work out which kind each call touches:

File sourceWhat to passWhy
Written by your app, config, JSON, logsencoding="utf-8"You control the writer, so make it explicit
Exported from a legacy Windows tool or old Excel CSVencoding="cp1252" (or the real one)The bytes are what they are
Written by another local program in the user’s code pageencoding="locale"Matches the old behavior on purpose
Unknown user uploadsDetect or reject, then decode explicitlyNever guess silently

The case that surprises people is CSV from Excel. Excel on Windows has historically saved “CSV” in the local code page, while “CSV UTF-8” is a separate menu entry. If your importer read those files with the old default, it worked by accident on the machines where it was tested. On 3.15 it needs encoding="cp1252" or encoding="utf-8-sig" (the latter also strips the byte order mark that Excel adds to UTF-8 files), depending on which export your users pick.

Step 3: subprocess pipes

subprocess.run(..., text=True) decodes the child’s output using the default encoding. In UTF-8 mode that is UTF-8. If the child is a Windows console program that prints in an OEM code page, you will now get decode errors or garbage where the old default happened to match. The PEP’s recommendation is to pass encoding="utf-8" or encoding="locale" explicitly, or to disable UTF-8 mode. The right choice depends on what the child process actually emits, so check it rather than assuming.

result = subprocess.run(
    ["some-windows-tool.exe", "--list"],
    capture_output=True,
    text=True,
    encoding="locale",   # the tool prints in the user's code page
    errors="replace",    # decide on purpose how to treat bad bytes
)

Step 4: use the escape hatch, then remove it

If you find a hundred call sites and a release in two days, set PYTHONUTF8=0 (or start Python with -X utf8=0) in your service’s environment and ship. Then fix the calls over the next sprint and remove the variable. Leaving it set forever means your code behaves differently from everyone else’s Python, which is how the next upgrade surprise starts.

One more item for Windows developers: if you upgrade macOS or Windows tooling along with Python, test them separately. The 3.15 release notes list an unrelated known issue where IDLE and other tkinter apps can hang on macOS 27.0, so do not diagnose a hang there as an encoding problem.

Where to go from here

Run the warning-enabled test pass today, run the AST scan across the repo, and fix the calls by file source, not by blanket search and replace. Teams who maintain Python services for clients can pair this with the rest of the 3.14 to 3.15 upgrade work, and the uv workflow makes it cheap to test against both interpreters side by side. If you want a second pair of eyes on a migration, that is the kind of work we scope on our services page.

The short version: if you wrote encoding="utf-8" on every text file already, nothing changes. If you did not, your Windows users are the ones who will find out first.

Frequently asked questions

Is UTF-8 the default encoding in Python 3.15?
Yes. PEP 686 enables UTF-8 mode by default from Python 3.15, and 3.15.0 was released on October 9, 2026. When you call `open()` in text mode without an `encoding` argument, Python now uses UTF-8 regardless of the system locale.
How do I turn UTF-8 mode off in Python 3.15?
Set the environment variable `PYTHONUTF8=0` or start the interpreter with `-X utf8=0`. That restores the earlier behavior of using the locale encoding for text files. The Python docs recommend passing an explicit `encoding` instead for portable code.
Will my existing files break after upgrading to Python 3.15?
Only if they were written in a non-UTF-8 encoding and your code opens them without an `encoding` argument. On Windows that usually means files saved as cp1252. Reading them as UTF-8 raises `UnicodeDecodeError` or, for some byte sequences, silently produces wrong characters.
What is the difference between encoding='locale' and encoding='utf-8'?
`encoding="utf-8"` always uses UTF-8. `encoding="locale"` uses whatever the operating system locale says, which is the old default. Choose `locale` only when you are deliberately reading files produced by other local tools, such as a legacy Windows application.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored