UTF-8 by Default Fixes a Silent Bug and Adds a Loud One

Under a Latin-1 locale, CPython 3.13 reads a UTF-8 file as mojibake and raises nothing. 3.15 reads it correctly and raises on the Latin-1 file 3.13 read fine.

Elliot Sayer5 min read

PEP 686 makes UTF-8 the default encoding in 3.15, and the argument for it is correctness: the same program should read the same file the same way on every machine. What that argument leaves out is which direction the errors run. The behaviour being removed fails silently and the behaviour replacing it fails loudly, and an upgrade turns one into the other.

Under a Latin-1 locale, CPython 3.13 reads a UTF-8 file and returns 'caf\xc3\xa9 na\xc3\xafve' without raising anything. 3.15 reads it correctly — and raises on the Latin-1 file that 3.13 handled without complaint.

What open() picks

$ python3 experiments/evalloop/encoding.py ~/.pyenv/versions/3.15.0rc1/bin/python3
platform darwin, Latin-1 locale en_US.ISO8859-1

what open() picks

python    locale               utf8_mode   locale.getencoding   open().encoding
3.13.2    UTF-8 (this shell)           0                UTF-8             UTF-8
3.13.2    en_US.ISO8859-1              0            ISO8859-1         ISO8859-1
3.15.0rc1 UTF-8 (this shell)           1                UTF-8             utf-8
3.15.0rc1 en_US.ISO8859-1              1            ISO8859-1             utf-8

the same two files, opened with no encoding= under en_US.ISO8859-1

  utf8.txt is 63 6166c3a9 206e61c3 af...
    3.13.2    ISO8859-1   ok      'caf\xc3\xa9 na\xc3\xafve'
    3.15.0rc1 utf-8       ok      'caf\xe9 na\xefve'

  latin1.txt is 636166 e9206e61 ef76650...
    3.13.2    ISO8859-1   ok      'caf\xe9 na\xefve'
    3.15.0rc1 utf-8       raised  'utf-8' codec can't decode byte 0xe9 in position 3: invalid continuation byte

read throughput, MB/s, 16 MB file, best of 5

python    file         binary    utf-8  latin-1    ascii
3.13.2    ascii.txt     28702    11950    10446    11528
3.13.2    mixed.txt     29960     2126    15015      859
3.15.0rc1 ascii.txt     29696    10334    10322    11888
3.15.0rc1 mixed.txt     29376     2938    15099     1369

every decode uses errors='replace', so the ascii column on mixed.txt is
the error handler running once per non-ASCII byte, not a decode rate

The first block is the whole change in one row. On 3.15 under a Latin-1 locale, locale.getencoding() still answers ISO8859-1 — the locale is real and Python knows what it is — and open() no longer asks. The flag that decides is sys.flags.utf8_mode, and it goes from 0 to 1 with no environment variable set.

The C locale cannot be used to show this. CPython has coerced it to UTF-8 since PEP 540, so under LC_ALL=C every version behaves like 3.15 and the comparison disappears. That is also why the shell’s own UTF-8 locale gives identical rows on both interpreters: on a machine that was already UTF-8 everywhere, PEP 686 changes nothing at all.

The two files that change

utf8.txt holds café naïve encoded as UTF-8, so é is the two bytes c3 a9. Read under a Latin-1 locale, each of those bytes becomes its own character, and the program gets 'caf\xc3\xa9 na\xc3\xafve' — twelve characters where the file held ten, and no exception. A length check passes, a startswith passes, and the string is wrong. Latin-1 has a character for all 256 byte values, so a Latin-1 decode cannot fail. That is the property that makes the old default dangerous: it does not misread loudly, it misreads quietly and hands the result on.

latin1.txt holds the same text encoded as Latin-1, where é is the single byte e9. 3.13 under that locale reads it correctly. 3.15 reads it as UTF-8, finds e9 where a multi-byte sequence should start, and raises UnicodeDecodeError at position 3.

Both rows are the same upgrade. Whether it fixes a bug or introduces one depends on which of the two files the codebase has been reading, and nothing in the code says which.

What a decode costs

The throughput block answers a question the correctness argument never raises. A 16 MB ASCII file, already in the page cache, reads at 28,702 MB/s as bytes and 11,950 MB/s as UTF-8 text. Decoding is 58% of the time the text read takes.

Put a handful of non-ASCII characters on every line and UTF-8 drops to 2,126 MB/s. The same codec, the same file size, 5.6 times slower — the ASCII-only fast path is gone and every multi-byte sequence is decoded individually. latin-1 sits at about 15,000 MB/s on both files because it has no multi-byte case to leave.

None of that is a reason to avoid UTF-8. It is a reason to know that “just read the file” has a per-megabyte price that varies by a factor of six with the content, and that a program parsing gigabytes of mostly-ASCII log lines is on the fast path only until someone writes a name in it.

When to write encoding= anyway

The default is now the right default, and it is still the wrong thing to rely on. Two cases:

A file whose encoding is a fact about the file, not about the machine — a fixture written in cp1252, an export from a system that emits Latin-1 — needs encoding= because the correct answer is not UTF-8 and never was. The new default does not fix those; it changes which machines they break on.

A file read on 3.15 and on an older interpreter, which is every library supporting more than one version. There the explicit argument is the only way to get one behaviour.

That is the second thing in this archive an upgrade changes about when code raises rather than how fast it runs. PEP 649 does it to annotations — the expression that ran at definition time stops running there — and PEP 686 does it to reads. Neither shows up in a benchmark.

Finding them is mechanical. -X warn_default_encoding raises an EncodingWarning reading 'encoding' argument not specified for every text-mode open() without one, and -W error::EncodingWarning turns them into failures. PYTHONUTF8=0 restores the old behaviour on 3.15, which is a way to ship on a deadline rather than a fix.

Where to stop

One machine, macOS on Apple silicon, 3.13.2 against 3.15.0rc1 built from source on 2 September 2026. The final release is dated 1 October and these are not its numbers.

Nothing here charges the encoding change against import cost either. -X importtime attributes by order rather than by work, so a locale change that moves where a decode happens moves the blame with it — which number that report is actually answering is its own entry.

Nothing here is a Windows measurement, and Windows is where PEP 686 does most of its work. The code page there is typically cp1252 or a regional variant, so the default differs from UTF-8 without anyone changing a locale — but that is the PEP’s claim, repeated, not this entry’s.

The throughput figures are reads of a cached file, so they measure decoding and memory rather than storage. Every decode in that table uses errors='replace', which is why the ascii column on the mixed file is 859 MB/s: that is an error handler running once per non-ASCII byte, not a decode rate.

Frequently asked

Does this affect Linux and macOS at all?

Only where the locale is not already UTF-8. On the machine measured here the shell locale is UTF-8, and 3.13 and 3.15 agree exactly until the locale is changed. PEP 686 is aimed mostly at Windows, where the code page is typically cp1252 or a regional variant and the default therefore differs from UTF-8 out of the box.

Why not demonstrate this with the C locale?

Because CPython coerces it. Under LC_ALL=C the interpreter enables UTF-8 mode on its own, which it has done since PEP 540, so every version shows the new behaviour and the comparison collapses. The demonstration needs a locale that is real and not UTF-8, which is why en_US.ISO8859-1 is used here.

How do I find the calls that will change?

Run the test suite with -X warn_default_encoding. Every text-mode open() that omits encoding= raises an EncodingWarning reading "encoding argument not specified", and adding -W error::EncodingWarning turns each one into a failure you can fix. That flag has existed since 3.10, so this work can be done before the upgrade rather than during it.

Should I set PYTHONUTF8=0 and move on?

As a way to unblock a deployment, yes. As a fix, no: it re-enables the behaviour where the same code reads a file differently on two machines, which is the thing being removed. The durable version is an explicit encoding= on the calls that need one.

Is the throughput difference worth designing around?

Rarely. At 2,126 MB/s the decode of a 16 MB file is 7.5 ms, and any program reading files that large is usually doing something more expensive with them afterwards. It matters where the file is enormous and the work per line is small, and there the answer is latin-1 or binary, chosen deliberately rather than inherited from a locale.

Where this came from

Run it yourself — every figure above came out of these:

  1. evalloop/encoding.py — What open() picks under a Latin-1 locale, and decode throughput

Read, rather than assumed:

  1. PEP 686 — Make UTF-8 mode default
  2. PEP 540 — Add a new UTF-8 Mode
Share

Arrow keys to move, Enter to open.