Python 3.15 Makes UTF-8 the Default, and Breaks What Assumed Otherwise
UTF-8 is now the default text encoding, so code that relied on the locale encoding will read or write different bytes than it did on 3.14.
Python 3.15 is out, and the change most likely to break working code is one line of
behaviour: text I/O now uses UTF-8 instead of the locale's preferred encoding. Every
open() without an explicit encoding=, every
subprocess call that decodes text, and everything built on the defaults
now reads and writes UTF-8 on machines where it used to use something else — most
visibly US-ASCII under the C locale, and the various single-byte code pages on Windows.
The release was published on 9 October 2026 and is a stable release, not a prerelease, with the release page recording 5,643 commits from 1,012 contributors. The encoding change is PEP 686, which makes UTF-8 mode the default. If your code has been assuming that the default encoding matches wherever the process happens to be running, this is the release that stops being true.
What breaks, concretely
The failure is not subtle. Under the C locale, the default encoding was ASCII, and writing a non-ASCII character raised an error. That is a test you can run on an older interpreter by clearing the locale:
import locale
# Python 3.14 under LC_ALL=C: locale.getpreferredencoding(False) == 'US-ASCII'
with open("report.txt", "w") as handle:
handle.write("café\n")
# UnicodeEncodeError: 'ascii' codec can't encode character '\xe9'
# Python 3.15, and 3.14 with PYTHONUTF8=1: the same call writes b"caf\xc3\xa9\n"
That example flips from an exception to working, which looks like an improvement.
The breaking direction is the other one. A process that used ASCII as its default was
strict: it refused to produce bytes it could not represent. Now the same process
happily writes UTF-8, and every downstream reader that was decoding with the old
assumption gets either mojibake or a UnicodeDecodeError on a file it
previously handled without complaint.
The read path is where this bites hardest, because the bytes are usually not yours. A log file produced by a service on another host, a CSV export from a database, a config file with a name in it, a fixture checked into the repository — none of those were written by your interpreter, and none of them changed when you upgraded. What changed is the decoder your code applies to them by default. Something that was read as ASCII under the C locale is now read as UTF-8, and a file that contains a Latin-1 name will decode incorrectly or raise.
# A fixture written by a tool that used the locale encoding, not UTF-8.
with open("customers.csv") as handle: # 3.14: locale encoding, 3.15: UTF-8
rows = handle.read().splitlines()
# 3.15 raises UnicodeDecodeError here instead of handing you mojibake,
# which is louder but still a change in behaviour you have to handle.
There is a second class of failure that produces no exception at all. A caller that counted bytes, hashed them, or compared a file length before and after a write will get a different number, because UTF-8 uses more bytes for anything outside ASCII. A test that asserts on file size, a cache key derived from content, a delta sync that assumes one byte per character — those do not raise. They fail a comparison somewhere else, on a machine you are not looking at, and the error message points at the wrong layer.
The third class is the one that catches people who thought they had already dealt
with this. Code that opens a file in binary mode and then decodes it by hand with the
right codec is safe, and code that passes encoding= everywhere is safe.
Everything in between is not. A library on your dependency list that calls
open() without an encoding is now reading UTF-8 where it used to read
ASCII, and the traceback will name the library rather than the decision that made it
strict. That is worth remembering when the first failure after upgrade arrives from a
package you did not write and do not control: the fix may be to upgrade that package
rather than to change your own code.
Virtual environments and containers make this harder to see rather than easier. A container image built on a minimal base often runs with the C locale, which is exactly the configuration where the default used to be ASCII and the change is largest. If your continuous integration runs on such an image and your development machines do not, you can have a suite that is green locally and wrong in the pipeline, or the reverse. The locale in the image is part of your encoding configuration whether or not anybody wrote it down, and it now applies to fewer decisions than it used to.
How to find out whether you are exposed
The cheap check is to run your test suite under the C locale. That is the configuration where the old behaviour was ASCII, and it is the one where the difference is largest, so a suite that passes under your normal environment and fails here is telling you something real:
# From a POSIX shell: the strictest version of the old default.
# LC_ALL=C PYTHONCOERCECLOCALE=0 PYTHONUTF8=0 python -m pytest
# Then run the same suite with 3.15's new default and compare the two runs.
# Any difference is a place where your code depended on the locale.
Windows deserves a second pass rather than a shared flag. There the locale encoding
was a code page, and the set of characters that used to round-trip through a default
open() is different from the set that round-trips through UTF-8. If you
write files with default encoding and read them with an old, unrelated tool, the file
that worked last week can now be unreadable by that tool even though Python and your
tests are happy.
The fix is not to pin the old behaviour and move on. It is to make the encoding
explicit at every boundary, which is what you would have written if the default had
never been doing you a favour. open(path, encoding="utf-8") for files you
own and know the encoding of. encoding="utf-8", errors="replace" where you
are reading somebody else's output and would rather have a character wrong than an
exception. And an explicit encoding= on any subprocess call
that returns text, so the decoding decision is written down rather than inherited.
The other changes worth knowing about
Two more things in this release have consequences that reach outside the interpreter. The first is the stable ABI for free-threaded builds, which is what third-party C extensions have been waiting on to ship binary wheels for no-GIL Python. Until extensions can ship those wheels, the no-GIL build stays something you compile your own dependencies for; after it, the barrier drops to installing them. That is the difference between a free-threaded interpreter being a research setup and being a deployment option.
The second is performance. The release notes describe the JIT as giving a 7–8% geometric mean improvement on x86-64 Linux over the standard interpreter, and 11–12% on AArch64 macOS over the tail-calling interpreter. Both figures are the project's own measurements against its own baselines, not a promise about your workload, and they are geometric means across a benchmark set rather than a claim about any single script.
There is one known issue that should change somebody's upgrade plan. IDLE and tkinter can hang on macOS 27.0, tracked as cpython issue 158053, and the release page suggests deferring that operating system upgrade if you depend on Tk applications. If nobody on your team runs a Tk application, this is a footnote. If your internal tooling has one, it is the reason to schedule the OS upgrade and the Python upgrade separately.
Beyond those two headlines, the release carries a long list of language changes that
each matter to a smaller group. A built-in sentinel object (PEP 661) gives
code that writes MISSING = object() a standard replacement, which is the
kind of change that only shows up when a library and your own code start disagreeing
about which sentinel means absent. frozendict (PEP 814) is a hashable,
immutable mapping, useful precisely where a mutable default is a bug waiting to happen.
Explicit lazy imports (PEP 810) let a module defer an import deliberately instead of
relying on the import system's own timing, and package startup configuration files
(PEP 829) give a package a place to declare how it wants to be started.
Two typing changes are worth a mention for teams that maintain libraries rather than applications: PEP 728 and PEP 747 extend the type system, and PEP 800 does something similar for the typing specification itself. If your package ships type information to other people, those are the entries on the list most likely to require a decision rather than just an upgrade. The profiler change is easier to judge: PEP 799 adds a dedicated profiling package and the Tachyon high-frequency sampling profiler, which gives Python a first-party answer to the question of what a program is doing when a regular profiler's overhead changes the thing it is measuring.
Frame pointers are on by default now (PEP 831), which is a change most people will meet through somebody else's tooling. It makes native stack traces more reliable in profilers and crash reporters at a small cost that the project has decided is worth paying. On Windows, 64-bit binaries now use the tail-calling interpreter, and macOS binaries install free-threading support by default. None of those change what your code does. All of them change what your tooling can tell you about it.
What to do before you upgrade
Pin your deployment to a specific version and read what you are moving off. The release that matters here is 3.15.0, and the encoding default is the first thing to test rather than the last. Run the suite under the C locale, fix what it finds by making encodings explicit, and only then treat the upgrade as routine.
The order matters because the two failure modes need different work. An exception is
a bug report with a line number attached, and you can fix it as it arrives. A silent
change in the bytes on disk is not, because nothing tells you it happened until
something downstream reads the file and gets a different answer. That is why the
encoding audit belongs in the upgrade itself rather than in the incident that follows
it, and why the useful output of the audit is a list of open() calls and
subprocess invocations rather than a passing test run.
One practical shortcut: the places worth auditing first are the ones that cross a process or machine boundary. Anything that writes a file another service reads, anything that reads a file another service wrote, and anything that parses the output of a command-line tool are all boundaries where the encoding is a contract rather than an implementation detail. Code that reads and writes within a single process is far less likely to be affected, because both ends of that conversation changed together.
If you cannot do that work immediately, you have an escape hatch and it is worth
naming. UTF-8 mode can be switched off with PYTHONUTF8=0 or the
-X utf8=0 flag, which restores the locale default for an interpreter that
would otherwise use UTF-8. That is a stopgap for an emergency, not a migration plan:
it keeps the old ambiguity in place and it will be inherited by everyone who runs your
code after you have forgotten why the flag is there. Note it in the ticket, set a date
to remove it, and spend the interval adding the explicit encodings instead.
Primary sources
More from the wire
GitHub Copilot Retires Three Models on 16 November
Claude Opus 4.8, Claude Opus 5 and GPT-5.6 Terra stop working in Copilot on 16 November, so pinned prompts and agent configs must move first.
GitHub's Agentic Autofix Is Now Generally Available, and Metered
Agentic autofix is GA and metered: each fix draws AI Credits from the Code Security budget plus Actions minutes, replacing the free Generate Fix.
Interop 2027 Opens, and the 2026 Numbers Say the Web Is Converging
Chrome and Edge pass 98 percent of the Interop 2026 tests, Firefox 96 and Safari 92, and that gap names where cross-browser work still sits.