Python 3.15 Makes UTF-8 the Default: Check Your Integration Scripts Before You Upgrade

Python 3.15, released 9 October 2026, reads and writes text as UTF-8 by default. On Windows gateways and integration boxes that can mean garbled or silently corrupted data. Here's how to find the risk

Technical Solutions Architect
Published
Reading time
6 min read

Python 3.15.0 was released on 9 October 2026, a week later than planned. A third release candidate was added on 2 October before the final release went out. The headline features are the ones that get the conference talks: explicit lazy imports (PEP 810), a built-in frozendict (PEP 814), a new profiling package with a sampling profiler (PEP 799), and an upgraded experimental JIT.

The change I'd plan around is a quieter one. Under PEP 686, Python now uses UTF-8 as the default text encoding, whatever the operating system's locale says. In the words of the official What's New: "I/O operations without an explicit encoding, for example, open('flying-circus.txt'), will use UTF-8."

For most Linux and macOS deployments, nothing changes, because those systems already run with a UTF-8 locale. The PEP says plainly that the change "mostly affects Windows users". That's exactly where a lot of integration glue lives: scripts that read a CSV export from an ERP system, parse a log written by a PLC programming tool, or pick up a file dropped into a shared folder by a legacy application.

What actually changes

Before 3.15, when your code opened a text file without saying which encoding to use, Python asked the operating system. On a typical Western European Windows machine, that usually meant a legacy code page such as cp1252, not UTF-8. From 3.15, Python uses UTF-8 by default.

Concretely, PEP 686 says Python "consistently uses UTF-8 for default encoding of files, stdio, and pipes". The rule only applies when you haven't specified an encoding. Any call that already passes encoding=... behaves exactly as before.

The PEP is direct about what can go wrong when code relied on the old default: UnicodeError, mojibake (garbled characters), "or even silent data corruption".

The first two are annoying but obvious. The third is the one I'd worry about. Here's how it can happen:

  • A file written in cp1252 contains a character like é or °. Read as UTF-8, that byte usually isn't valid, so you get an exception. That's loud, and that's the good case.
  • Code that opens files with errors="replace" or errors="ignore" doesn't raise anything. It quietly swaps or drops the characters it can't decode, and the damaged text flows on into your database.
  • The reverse direction matters too. A script that writes a file for a downstream system that expects the old code page will now write UTF-8. The downstream system may decode °C as °C, or reject the row outright.

Picture a company where a small Windows service on a plant-floor PC reads a nightly CSV of batch records and pushes it to a cloud API. The unit column contains °C and some operator names contain accented characters. Nobody wrote encoding= in that script, because it never mattered. After a routine Python upgrade, it matters.

Why it's easy to miss

This is a change in default behaviour, not a removed API. Your test suite won't fail on an import, and your linter won't flag anything. If your tests run on Linux CI runners and only production runs on Windows, the tests will pass whether or not the bug is there.

It also tends to arrive without anyone deciding to upgrade. A base image moves to a new Python, someone reinstalls a gateway PC with the latest installer, or a packaging tool picks the newest interpreter it can find. In integration-heavy environments, the Python version on a given box is often something nobody has written down.

The opt-out exists, and it's a stopgap

If you need time, the old behaviour is one switch away. The What's New document says UTF-8 mode "may be disabled with the PYTHONUTF8=0 environment variable or the -X utf8=0 command-line option."

That's a reasonable emergency lever, and I'd keep it in your runbook. I wouldn't treat it as the fix, though. It's an environment-level setting that's easy to lose the next time someone rebuilds a machine or changes a service definition, and it hides the real problem: code that doesn't say what encoding its data is in.

The real fix is boring and permanent. Pass an explicit encoding on every text I/O call that touches data from outside your process. The What's New puts it the same way: "For best compatibility between versions of Python, ensure that an explicit encoding argument is always provided."

If what you genuinely mean is "whatever this machine's locale uses", say that too. encoding="locale" has been supported since Python 3.10 and keeps that meaning explicit.

Finding the affected code

Python has been able to point you to the risky call sites since 3.10. PEP 597 added an opt-in EncodingWarning, which you turn on with -X warn_default_encoding or the PYTHONWARNDEFAULTENCODING environment variable. When it's on, Python warns every time a text I/O call relies on the default encoding.

That means you can run the check today, on the Python version you already use, before anything gets upgraded. That's the approach PEP 686 itself recommends: find the affected code with EncodingWarning, add encoding="utf-8" or encoding="locale" as appropriate, and then test with UTF-8 mode on.

A checklist for this month

1. Inventory where Python runs, not just where it's developed. List every machine and container that runs Python in production, especially Windows hosts: gateway PCs, scheduled tasks, small services. Record the interpreter version and how it gets updated. If the answer is "whoever installs the latest one", that's the risk.

2. Pin the interpreter in anything you deploy. Container base images, CI runners and installers should name an exact Python minor version. Moving to 3.15 should be a decision someone makes, not something that happens to you.

3. Turn on EncodingWarning in CI and staging now. Set PYTHONWARNDEFAULTENCODING=1 and make the warnings visible, or turn them into errors in your test configuration. Fix call sites one module at a time. Prioritise code that reads or writes files exchanged with other systems: CSV, fixed-width exports, log files, serial-port captures saved to disk.

4. Decide the encoding per interface, and write it down. For each file or stream your systems exchange, record the encoding in the interface contract alongside the delimiter and the column list. Where you control both ends, choose UTF-8. Where you don't, record what the other system actually produces, and confirm it with a sample file that has non-ASCII characters in it, not just an assumption.

5. Add one test file with awkward characters. A fixture containing °, µ, é, ß and a few non-Latin characters, run through every import path, will catch most regressions. Run it on a Windows runner if production is Windows.

6. Search for errors="ignore" and errors="replace". Those are the call sites where an encoding mismatch becomes silent data loss instead of a visible error. Each one should have a reason written next to it.

7. Read the rest of the porting notes before upgrading. 3.15 also removes sre_compile, sre_constants and sre_parse, removes CGIHTTPRequestHandler from http.server, makes all sqlite3.connect() parameters except database keyword-only, and changes how argparse infers dest when you pass both a short option and a single-dash long option. None of these is dramatic. All of them break something somewhere.

The rest of the release, briefly

There's plenty here worth looking forward to once the encoding work is done:

  • Lazy imports. lazy import json defers loading a module until it's first used. For command-line tools and short-lived jobs on constrained edge hardware, startup time is a real cost, and this gives you a language-level way to cut it. Note that lazy is only allowed at module scope, and a missing module now fails at first use rather than at import time. Some error-handling assumptions will need revisiting.
  • Frame pointers by default (PEP 831). System-level profilers can now walk Python stacks more reliably, which helps observability on production hosts. Authors of C extensions built with custom build systems are asked to make sure the unwind chain stays intact.
  • JIT. The release page reports a 7–8% geometric mean improvement over the standard interpreter on x86-64 Linux. It's still experimental, so I'd measure on your own workload before relying on it.

On support: PEP 790 says 3.15 gets bugfix releases for about two years, then source-only security fixes until roughly October 2031, five years after 3.15.0. There's no rush to be first. There is a reason to have the encoding question answered before the upgrade finds you.

Sources


If you're not sure which of your interfaces quietly depend on a machine's locale, a two-week discovery and audit sprint will map them before the upgrade does. Book a discovery call.

Written by Navil Shah

I help teams that build connected products get their telemetry, edge and cloud layers into shape — including what the CRA and Data Act now expect of them.