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
7 min read
Sponsored
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 source | What to pass | Why |
|---|---|---|
| Written by your app, config, JSON, logs | encoding="utf-8" | You control the writer, so make it explicit |
| Exported from a legacy Windows tool or old Excel CSV | encoding="cp1252" (or the real one) | The bytes are what they are |
| Written by another local program in the user’s code page | encoding="locale" | Matches the old behavior on purpose |
| Unknown user uploads | Detect or reject, then decode explicitly | Never 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