Back to the blog
Open source

Python 3.15 Shipped on October 9. On Windows, Files Opened Without an Encoding Now Default to UTF-8

October 19, 2026 6 min read

On Friday, October 9, Python 3.15.0 shipped. The release page counts 5,643 commits from 1,012 contributors. Eight days earlier, on October 1, Python 3.10 reached end of life: 3.10.22 was its final security release, and the branch is now frozen.

So for anyone still on 3.10, the question is no longer whether to upgrade. It is where to land, and what breaks on the way.

A lot of the glue around my work is Python: the scripts that publish these articles, the fact-check pass where Jev judges my drafts, a few scheduled jobs. So for 3.15, I read the bottom of the What's New page before the feature tour: the Removed and Porting sections. That is where the upgrade cost lives.

The default that changed under every script

The most consequential line is not in the Porting section. It sits under "Other language changes": Python now uses UTF-8 as the default encoding, "independent of the system's environment." A text-mode open() without an encoding argument now reads and writes UTF-8.

On Linux and macOS, that mostly changes nothing. PEP 686, which made the decision, says it plainly: "this change mostly affects Windows users." The Windows documentation explains why. Windows "still uses legacy encodings for the system encoding (the ANSI Code Page)", and until 3.15, that is what Python used for text files when no encoding was given.

The PEP is just as plain about the risk: when a program depends on the default encoding, the change "may cause UnicodeError, mojibake, or even silent data corruption."

Picture a script on a Windows PC at a small business, reading a CSV written by older software in the system code page, with accented names in it. On 3.14, it reads fine. On 3.15, the same open() raises a UnicodeDecodeError at the first "é", and any file Python writes back is now UTF-8, which the older tool may read as garbage. Nobody touched the code. The PEP also names text-mode subprocess pipes (text=True) among the cases to fix.

The fixes are all documented:

  • Pass encoding="utf-8" where you mean UTF-8, and encoding="locale" (supported since 3.10) where you really mean the machine's encoding. On Windows, the "mbcs" codec still gives you the ANSI code page.
  • To find every call that relies on the default, run your tests with -X warn_default_encoding or the PYTHONWARNDEFAULTENCODING environment variable. That warning has existed since 3.10, so you can run it on your current version, before you upgrade.
  • As an escape hatch, PYTHONUTF8=0 or -X utf8=0 restores the old behavior. I would treat it as a ticket, not a fix.

A date format that now raises

In the Removed section: datetime.strptime() now raises ValueError when the format contains %d without a year. It had been deprecated since 3.13.

The reason is a bug that only shows up on leap days. Without a year in the format, strptime fills in a default, 1900, which is not a leap year. Parse "29/02" with "%d/%m" and it fails, while every other day of the year goes through. The documented workaround is to always include a year, and if your strings have none, to add a leap year yourself before parsing.

If you parse dates like "14/07" out of documents, emails or filenames, grep your format strings for %d without %Y or %y. That code used to fail only on a leap day. On 3.15 it fails on every call, which is better, as long as you find it before production does. Look for %e while you are at it: without a year, it now emits a DeprecationWarning, with removal scheduled for 3.17.

The rest of the list I would grep for

Porting, Removed and the module notes are long, but a handful of lines touch ordinary application code:

  • sqlite3.connect() now accepts only the database path as a positional argument. sqlite3.connect("app.db", 10) stops working; write timeout=10.
  • In argparse, add_argument('-f', '-foo') now stores the value under foo, not f. Pass an explicit dest to keep the old name.
  • base64.urlsafe_b64decode() no longer requires padding. If your validation counted on it rejecting unpadded input, pass padded=True.
  • unittest's assertWarns() no longer swallows warnings that do not match. A suite that treats warnings as errors can go red without a code change.
  • importlib.metadata.metadata() raises MetadataNotFound when a distribution's metadata file is missing, where it used to return an empty object.

Then the removals, which mostly bite through old dependencies rather than your own code: the sre_compile, sre_constants and sre_parse modules, the load_module() method of import loaders (deprecated since 3.4), CGIHTTPRequestHandler and the --cgi flag of http.server, the keyword-argument syntax for NamedTuple. Your lockfile is where I would look first.

What pays for the move

Lazy imports are the headline, and rightly so. Write lazy import json and the module loads the first time the name is used, not at startup. PEP 810 says this "can reduce startup time by 50-70% in practice" for command-line tools, and that memory savings of 30 to 40 percent "have been observed in real workloads." Those are the PEP's numbers, not mine; -X importtime shows where your own startup time goes.

Three caveats from the spec. lazy only works at module scope: inside a function, a class body or a try/except block, it is a SyntaxError, so the classic optional-dependency fallback stays eager. A lazy import that fails now fails at first use, possibly deep inside a request, instead of at launch. And modules that register themselves on import, the plugin-registry pattern, will not register until something touches them.

Two quieter gains. The official Windows 64-bit binaries now use the tail-calling interpreter: the What's New page reports a 15 to 20 percent speedup on the pyperformance geometric mean on Windows x86-64 over the switch-case interpreter, measured on an AMD Ryzen 7 5800X, and observed gains from 14 to 40 percent depending on the workload. No code change required. And Tachyon, the new sampling profiler in profiling.sampling, attaches to a running process by PID, with a dump mode that prints the stack of every thread. For a job that hangs on a server in the middle of the night, that is the feature I would reach for first.

The JIT is upgraded but still experimental: 7 to 8 percent on the geometric mean on x86-64 Linux, with individual benchmarks ranging from about 15 percent slower to more than twice as fast. Not a reason to upgrade on its own.

The 3.14 lesson

One line in the gc section deserves more attention than it will get. Python 3.14.0 to 3.14.4 shipped a new incremental garbage collector. After "a number of reports of significant memory pressure in production environments," it was reverted to the 3.13 collector, which is what 3.14.5 and later, and 3.15, now use.

Five releases of a stable line ran with a collector that was later pulled back. That is not a reason to avoid 3.15. It is a reason to put it on one canary with real traffic and watch memory before rolling it everywhere.

What I would do this month

If anything still runs on 3.10, move it now, to 3.14 or 3.15. It gets no more security fixes.

Before touching 3.15, run the test suite on your current version with -X warn_default_encoding and fix every warning with an explicit encoding. Grep your strptime formats. Check the porting list above against your code and your lockfile.

Then one service, one canary, real traffic, a week of memory graphs. The first bugfix release, 3.15.1, is scheduled for December 1. After that, 3.15 gets bugfix releases for about two years and source-only security fixes until about October 2031, so there is no rush to be first, only a rush to get off 3.10.

The keyword everyone will talk about is lazy. The change most likely to break a working script is the one nobody typed: a default.

Sources

A project like this one?

I design and deploy products like this. Let's talk.

Let's talk