Skip to content
System nominalUTC --:--:--Articles 080
Developer Tools 8 min read

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.

Cover graphic for: Python 3.15 Makes UTF-8 the Default, and Breaks What Assumed Otherwise

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