Python 3.15 makes UTF-8 the default. What breaks?
By Nihar Ranjan Das · Sun Oct 18 2026 · 10 min read · 0 views
View as a Web StorySoftware#python#python 3.15#upgrade#utf-8#lazy imports

Python 3.15 changes one default that can quietly alter how your scripts read files. Calling open() without an encoding argument now means UTF-8 on every system. For most code that is a fix. For code that reads Latin-1 or other legacy text on a machine whose locale says otherwise, it is a new UnicodeDecodeError. We ran Python 3.13.5 and 3.15.0 side by side, scanned real packages for the risky pattern, and probed every removed API. This guide shows what changes, how to find your exposure in ten minutes, and what is safe to ignore.
What does Python 3.15 change about the default encoding?
Python 3.15 is the release of the Python language that shipped on October 9, 2026. It uses UTF-8 as the default encoding for any input or output call that does not name one, "independent of the system's environment," according to the Python 3.15 documentation.
The rule applies only when you leave encoding out. Code that already says open(path, encoding="utf-8") behaves the same on every version. The change comes from PEP 686, which Inada Naoki wrote and Adam Turner implemented, as the release notes credit.
UTF-8 mode is the interpreter setting that forces UTF-8 for text I/O. Python 3.15 turns it on by default. You can still turn it off with PYTHONUTF8=0 or -X utf8=0, which the release notes list as the way to keep the previous behavior.
Does it break my existing scripts?
It breaks a script only when that script reads or writes non-UTF-8 text without naming the encoding. A UTF-8 file reads the same on both versions. A Latin-1 file does not.
Our test setup was one macOS machine running Python 3.13.5 and 3.15.0 from Homebrew. We created utf8.txt holding "café" in UTF-8 and latin1.txt holding "café naïve" in Latin-1. Then we set the locale to ISO 8859-1 and ran the same open() call on both interpreters.

| Test | Python 3.13.5 | Python 3.15.0 |
|---|---|---|
Read utf8.txt, Latin-1 locale |
reads café |
reads café |
Read latin1.txt, Latin-1 locale |
reads café naïve (decoded as Latin-1) |
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 |
Child process prints café, Latin-1 locale |
one Latin-1 byte | UTF-8 bytes |
Same Latin-1 test with PYTHONUTF8=0 |
café naïve |
café naïve |
The bottom line: the 3.13 row only read a Latin-1 file because the locale happened to match. On 3.15 that coincidence no longer decides the result. We did not test a Windows code page, so the Windows result is the documented behavior and not our measurement.
The third row deserves attention because it fails quietly. A child process that prints non-ASCII text now emits UTF-8 even under a Latin-1 locale. A parent process that still decodes the pipe with the locale will garble the text without raising anything. Logs, CSV exports and shell pipelines are the usual places this shows up.
How exposed is the average codebase?
More exposed through its dependencies than through its own code. We wrote a small ast scanner that counts text-mode open(), read_text() and write_text() calls and flags the ones with no encoding argument. We pointed it at two targets and skipped test directories.
Advertisement

| Target | Text-mode file calls | Without encoding |
|---|---|---|
| Python 3.13 standard library (573 files) | 176 | 120 |
| 15 popular packages in a venv (1,211 files) | 295 | 205 |
The 15 packages included requests, urllib3, setuptools, nltk, tiktoken, beautifulsoup4 and click. Counts are a lower bound, because the scanner misses aliased open calls. It also cannot tell whether a given file is ASCII-only, in which case the call is harmless on every version.
Two conclusions follow. First, an unprotected open() is the norm, not the exception, so a large share of code relies on the locale default today. Second, you cannot fix the problem with a grep of your own repository alone. A library that reads a Latin-1 data file without an encoding will start failing when you move to 3.15, and only the library's maintainer can fix it.
Most of those calls read ASCII config or UTF-8 data, so they behave the same under UTF-8. The ones to worry about read legacy data: old CSV exports, Windows-1252 text, log files written by other tools, and corpora such as the ones nltk ships.
How do I find the code that will break?
Run your test suite with the opt-in encoding warning. The release notes name this exact tool: "The opt-in encoding warning can be used to identify code that may be affected by this change."
- Run
python -X warn_default_encoding -W error -m pyteston your current Python. - Read each
EncodingWarning: 'encoding' argument not specifiedit raises. - Add
encoding="utf-8"where the file is UTF-8. Addencoding="locale"where you truly need the machine's code page. - Re-run on 3.15 and confirm no new
UnicodeDecodeErrorappears.
We confirmed step 2 on 3.15. The command python -X warn_default_encoding -W error -c "open('utf8.txt').read()" raised EncodingWarning: 'encoding' argument not specified. The encoding="locale" value has been supported since Python 3.10, per the same notes.
Because -W error turns the warning into an exception, the first unprotected call stops the run. That is useful for a fast first pass. For a full inventory, drop -W error and collect the warnings instead, then sort by file.
For dependencies, set PYTHONWARNINGS=default::EncodingWarning and run your integration tests. Warnings that name a path under site-packages tell you which library to upgrade, pin or report. Check each library's release notes for a 3.15 statement before you move.
How do I restore the old behavior?
Set PYTHONUTF8=0 in the environment, or pass -X utf8=0 on the command line. Both appear in the 3.15 notes as the way to keep the previous behavior. Our fourth test row confirms it: with PYTHONUTF8=0, 3.15 decoded the Latin-1 file again.
Treat that as a bridge, not a fix. A global switch hides the real defect, which is a missing encoding argument. It also changes behavior for every library in the process, including ones that already assume UTF-8.
If only one file needs the old behavior, pass encoding="locale" for that call instead. That keeps the rest of the process on UTF-8 and documents the decision in code.
What does the fix look like in code?
Most fixes are one added argument. The patterns below cover the calls our scan found most often. Each one names the encoding instead of trusting the machine.
# before: depends on the locale on 3.14 and earlier
text = open("notes.txt").read()
Path("out.txt").write_text(text)
rows = list(csv.reader(open("export.csv")))
out = subprocess.run(cmd, capture_output=True, text=True).stdout
# after: same result on every version and every OS
text = open("notes.txt", encoding="utf-8").read()
Path("out.txt").write_text(text, encoding="utf-8")
rows = list(csv.reader(open("export.csv", encoding="utf-8", newline="")))
out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8").stdout
For a legacy file you cannot convert, name its real encoding, such as encoding="cp1252" or encoding="latin-1". For example, a Windows-1252 export from an old accounting tool should say so in code. Then the file reads the same on a Mac, a Linux runner and Windows.
Use this quick guide to decide how much effort each situation deserves:
| Situation | Risk on 3.15 | What to do |
|---|---|---|
| Source code and config you wrote in UTF-8 | none | add encoding="utf-8" when you next touch the file |
| ASCII-only data files | none | no urgency |
| Exports from Excel or old Windows tools | high | name cp1252 or convert the file once |
| Pipes to or from other programs | medium | pass encoding= to subprocess and decode explicitly |
| Third-party library reading its own data files | depends | upgrade, or report the missing encoding upstream |
Which removed APIs break on Python 3.15?
Six calls that worked on Python 3.13 fail on 3.15. We probed each one on both interpreters, using the removal list in the 3.15 release notes.

| Call | 3.13.5 | 3.15.0 | Replace with |
|---|---|---|---|
glob.glob0 / glob.glob1 |
works | AttributeError |
glob.glob |
pathlib.PurePath.is_reserved() |
works | AttributeError |
os.path.isreserved() on Windows |
platform.java_ver() |
works | AttributeError |
none, the function is gone |
import sre_compile / sre_parse |
works | ModuleNotFoundError |
re |
http.server.CGIHTTPRequestHandler |
works | AttributeError |
a WSGI app or a framework |
NamedTuple("P", x=int) keyword form |
works | TypeError |
NamedTuple("P", [("x", int)]) |
Two similar-looking items did not fail. typing.ByteString and collections.abc.ByteString were removed from __all__ only, and both still import. The notes schedule their real removal for Python 3.17, so you have two releases to move to bytes.
Check your dependencies too. A library that imports sre_parse fails at import time on 3.15 even if your own code never touches it. Run python -W error -c "import yourpackage" on 3.15 in a clean venv to surface those at install time instead of in production.
Are lazy imports worth adopting?
Yes for command-line tools and anything that starts often; no for long-running servers. A lazy import is an import statement that defers loading a module until the name is first used. Python 3.15 adds it as the lazy soft keyword under PEP 810, according to the release notes.
We measured the effect with 16 standard-library modules, including asyncio, sqlite3, ssl and unittest. Each variant was launched 25 times and we took the median wall time.

| Variant | Median startup |
|---|---|
Bare python3.15 -c pass |
20.6 ms |
| 16 eager imports | 87.2 ms |
The same 16 as lazy import |
18.9 ms |
That is 68 ms saved per launch, and the lazy version started as fast as an empty interpreter. A CLI tool run a thousand times a day in CI saves about a minute. A web server that boots once saves nothing you will notice.
There is one behavior change to plan for. A failing lazy import raises at first use, not at the import line. In our test, lazy import not_a_real_module printed "reached line 2 without error" and only failed on the later attribute access. The traceback still includes the original import line, as the notes promise, so you can find the cause.
The real risk is hidden import-time side effects. A module that registers plugins, patches a library or starts a thread on import will not run until you touch it. Keep those imports eager. You can also force the behavior globally: -X lazy_imports=all or PYTHON_LAZY_IMPORTS=all makes every import lazy, and normal is the default.
What else is new that is worth knowing?
Three additions matter for planning, even though none of them breaks code. The release notes list frozendict and sentinel as new builtins, unpacking inside comprehensions, and a stable ABI for free-threaded builds. Phoronix reports the experimental JIT runs about 7 to 8 percent faster on Linux x86-64 than the standard interpreter, a figure we did not reproduce on a Mac.
Treat the JIT figure as a benchmark summary, not a promise. It is a geometric mean over a suite, so your workload may differ. The -X warn_default_encoding pass and the removed-API grep will save you more time than any speed claim.
Should I upgrade to Python 3.15 now?
Wait for your dependencies, but start the encoding audit today. The audit costs nothing on your current Python and finds the problem before the upgrade does.
Use this order:
- Run the
-X warn_default_encodingpass on your current version and fix the warnings in your own code. - Grep your code and pinned dependencies for the six removed calls above.
- Add a 3.15 job to CI and mark it allowed-to-fail.
- Move production once your main libraries publish 3.15 wheels and the warnings from step 1 are clean.
If your files are all UTF-8 and you use none of the removed calls, the upgrade is boring. That is the best result a language release can have. If you maintain a library, add explicit encoding arguments now. Your users on 3.15 will otherwise discover the gap before you do.
Advertisement
FAQ
Does Python 3.15 use UTF-8 by default on Windows?
Yes. The release notes say Python 3.15 uses UTF-8 as the default encoding independent of the system's environment, which includes Windows. Reads that omit the encoding argument no longer follow the legacy code page. Pass encoding="locale" to get the old behavior for a specific file.
How do I turn off UTF-8 mode in Python 3.15?
Set the environment variable PYTHONUTF8=0 or start Python with -X utf8=0. Both restore the previous locale-based default. Use this only as a short bridge while you add explicit encoding arguments to your open() calls, because it hides the underlying bug.
What is the Python 3.15 lazy import keyword?
The statement lazy import json defers loading the json module until the name is first used. It is a soft keyword from PEP 810. In our test it cut startup for 16 standard-library modules from 87 ms to 19 ms.
Which Python 3.15 removals break code most often?
The sre_compile and sre_parse modules, glob.glob0, pathlib.PurePath.is_reserved() and the NamedTuple keyword form. The first two usually break through third-party libraries rather than your own code, so scan your dependencies before upgrading.
How do I find open() calls that will change behavior?
Run your tests with python -X warn_default_encoding. Each open() call without an explicit encoding raises an EncodingWarning. Add -W error for a fast first pass, or drop it to collect every warning. Fix each by passing encoding="utf-8" or encoding="locale".
Comments
Loading…
Sign in to join the conversation.
Related posts

Vite 8 upgrade: one build error and one silent console leak
Upgrading from Vite 7 to Vite 8 can end in a loud failure or a quiet one. The loud one is a build that stops with TypeError: manualChunks is not a function. The quiet one is worse: esbuild: { drop:
Sun Oct 18 2026 · 10 min read · 0 views

Windows 11 24H2 is unsupported. Move to 25H2 or 26H2?
Windows 11 version 24H2 Home and Pro stopped receiving security updates on October 13, 2026. If your PC still says 24H2, it has already missed its last protection. The good news is that the fix is
Sun Oct 18 2026 · 9 min read · 0 views

Go 1.27 refuses to build with removed GODEBUG settings
Go 1.27 fails with 'removed GODEBUG set to old value'. We tested every removed setting on Go 1.26.9 and 1.27.2: where it fails, the fix, and one silent timer change.
Sat Oct 17 2026 · 10 min read · 1 views