Python UTF-8 Default Encoding: What Changes in Python 3.15

Python 3.15 makes UTF-8 the default encoding of open() on every platform. What breaks with cp1252 files on Windows, and the six steps to migrate safely.

Flowchart showing open() without an encoding argument using utf-8 when UTF-8 mode is on and locale.getencoding() when it is off, with the default of UTF-8 mode per Python version

The Python UTF-8 default encoding refers to the encoding that open() uses when we do not pass an encoding argument. Python 3.15, released on October 9, 2026, makes that default UTF-8 on every operating system (PEP 686; a PEP, or Python Enhancement Proposal, is the document Python uses to record a change). Before 3.15, the default on Windows was the encoding of the system locale, which is the language and region setting of the operating system, and in the United States and Western Europe that meant cp1252, an older encoding with one byte per character.

The one rule that makes your code safe on every version: pass encoding=”utf-8″, or the real encoding of the file, to every open() call. A call with an explicit encoding behaves the same on Python 3.14 and 3.15, on Windows and on Linux. A call without one now reads UTF-8 files correctly on Windows, and raises UnicodeDecodeError on the cp1252 files that used to work.

The following script runs on Python 3.15 in a cp1252 locale, which is the Windows default for Western languages. It writes the same title into two files, one in each encoding, and then opens them with and without the argument:

from pathlib import Path
import locale
import sys

Path("bookshelf_utf8.txt").write_bytes("Les Misérables - Victor Hugo\n".encode("utf-8"))
Path("bookshelf_cp1252.txt").write_bytes("Les Misérables - Victor Hugo\n".encode("cp1252"))

utf8_mode = sys.flags.utf8_mode       # 1 (0 on Python 3.14 in a cp1252 locale)
locale_name = locale.getencoding()    # CP1252 on a cp1252 locale (ignores UTF-8 mode)

with open("bookshelf_utf8.txt") as f:
    default_name = f.encoding         # utf-8 (the locale encoding before Python 3.15)

with open("bookshelf_utf8.txt", encoding="utf-8") as f:
    title = f.readline().strip()      # Les Misérables - Victor Hugo

with open("bookshelf_cp1252.txt", encoding="cp1252") as f:
    legacy_title = f.readline().strip()    # Les Misérables - Victor Hugo

with open("bookshelf_cp1252.txt", encoding="utf-8", errors="replace") as f:
    damaged_title = f.readline().strip()   # Les Mis\ufffdrables - Victor Hugo

with open("bookshelf_utf8.txt", encoding="locale") as f:
    locale_title = f.readline().strip()    # Les Misérables - Victor Hugo

The two lines that matter are the first open(), which picks utf-8 even though the locale still says CP1252, and the last one, where encoding=”locale” brings the old behavior back for one file. The \ufffd in the middle is the Unicode replacement character, the mark Python leaves for a byte it could not decode, and the garbled Misérables at the end is what the old default produced on Windows.

This article covers what an encoding is, what open() did before 3.15 and does now, what breaks in each direction, and the six steps that make a code base independent of the change. Section 1 explains the words, section 2 the mechanism, sections 3 and 4 show the change with a small example on both versions, and section 6 is the migration, one section per step.

1. Text, Bytes and Encodings

A computer stores and sends only bytes, which are numbers from 0 to 255. Text is a sequence of characters. A character encoding is the rule that turns characters into bytes when a program writes a file, and bytes back into characters when it reads one. So every read and every write uses such a rule, whether we name it or not.

Unicode gives every character a number called a code point, for example U+00E9 for the e with an acute accent. UTF-8 is an encoding of Unicode that writes each code point as one to four bytes. Plain ASCII characters, the English letters and digits, take one byte, so ASCII-only text has the same bytes in UTF-8 and in cp1252. cp1252 is the Windows code page for Western European languages; it has one byte per character, so it can hold at most 256 characters.

Python keeps the two kinds of data in two types. A str holds text and a bytes object holds raw bytes. str.encode() turns text into bytes and bytes.decode() turns bytes back into text, and both have defaulted to utf-8 in every Python 3 version, so neither one changes in 3.15:

word = "é"

utf8_bytes = word.encode("utf-8")        # b'\xc3\xa9' (2 bytes)
cp1252_bytes = word.encode("cp1252")     # b'\xe9' (1 byte)
wrong_text = utf8_bytes.decode("cp1252") # é (2 characters)
code_point = ord(word)                   # 233

try:
    ascii_bytes = word.encode("ascii")   # UnicodeEncodeError
except UnicodeEncodeError as error:
    print(error)
Diagram showing the character é encoded to the UTF-8 bytes C3 A9 and to the cp1252 byte E9, and the bytes C3 A9 decoded with cp1252 into the two characters é
The same character has different bytes in each encoding, so a reader must know which encoding wrote the file.

The third line is the whole problem in one call. Decoding bytes with a different encoding than the one that wrote them gives either an exception or wrong text. Wrong text with no exception is called mojibake, and it is the worse of the two, because nothing fails and the damage turns up in a report or a database weeks later.

Each encoding also covers its own set of characters. The Polish letter l with a stroke, U+0142, has no byte in cp1252 at all, so writing it with that encoding raises UnicodeEncodeError. The table has the bytes of five characters in both encodings, and the last row is the one cp1252 cannot write.

CharacterCode pointUTF-8 bytescp1252 bytes
AU+00414141
éU+00E9C3 A9E9
ñU+00F1C3 B1F1
€U+20ACE2 82 AC80
łU+0142C5 82not available

2. What open() Does Without an Encoding

When we call open() in text mode without an encoding argument, Python has to pick one. The value it picks is the default encoding. In Python 3.15 it is UTF-8 on Windows, Linux and macOS alike.

Python implements that default through UTF-8 mode, a startup setting that PEP 540 added in Python 3.7 and that Python 3.15 turns on by default. We can read the setting from sys.flags.utf8_mode. Either the mode is on and open() uses utf-8, or the mode is off and open() asks the operating system, through locale.getencoding(), which is the old behavior.

Flowchart showing open() without an encoding argument using utf-8 when UTF-8 mode is on and locale.getencoding() when it is off, with the default of UTF-8 mode per Python version
Python 3.15 turns UTF-8 mode on by default, so open() reaches locale.getencoding() only when we switch the mode off.
Python versionSettingUTF-8 modeDefault encoding of open()
3.7 to 3.14Normal localeoffLocale encoding
3.7 to 3.14C or POSIX localeonutf-8
3.15Normal localeonutf-8
3.15PYTHONUTF8=0offLocale encoding

2.1. Where the Old Default Came From

Before 3.15, the default depended on the machine that ran the script, not on the script. The table has the source of it on each platform.

PlatformSource of the defaultTypical value
WindowsANSI code pagecp1252 (Western Europe and the United States), cp1251 (Cyrillic), cp932 (Japanese)
Linux and macOSLC_CTYPE locale (the locale category for character handling)UTF-8 on most systems
C or POSIX localeASCII localeASCII, but Python switches UTF-8 mode on

Let’s say a library app has a script that reads bookshelf.txt. On the developer’s Linux laptop the locale is UTF-8, and on a colleague’s Windows PC it is cp1252. The file is saved as UTF-8 and holds an accented title. The Linux run prints the title correctly, and the Windows run prints garbled text with no error at all. That is the situation Python 3.15 ends.

2.2. What Else UTF-8 Mode Changes

UTF-8 mode reaches further than open(). The Python documentation lists seven interfaces it changes, and the table has what each one does with the mode on.

InterfaceBehavior with UTF-8 mode on
open(), io.open(), codecs.open(), TextIOWrapper (the class behind open())Default encoding is utf-8, and the error handler stays strict
pathlib.Path.read_text() and write_text()Follow the default of open()
subprocess with text=TrueDecodes the output of the child process as utf-8
locale.getpreferredencoding()Returns utf-8
sys.getfilesystemencoding()Returns utf-8
sys.stdin and sys.stdoututf-8 with the surrogateescape error handler
sys.stderrutf-8 with the backslashreplace error handler

The two error handlers in the last rows are explained in section 6.2. Four things stay exactly as they were, so code that uses them needs no change.

  • Binary mode (rb and wb) uses no encoding, so it is not affected.
  • A call that already passes an explicit encoding is not affected.
  • str.encode() and bytes.decode() defaulted to utf-8 long before this change.
  • locale.getencoding() still reports the real locale encoding, because it ignores UTF-8 mode.

3. The Example Project

The example is a small bookshelf app that reads and writes a text file of accented book titles. The folder is in the python-utf8-default-encoding folder on GitHub, and we run it on Python 3.14.8 and Python 3.15.0, both in a cp1252 locale, so every difference in the output comes from the Python version.

  • make_files.py writes the same three titles twice, as UTF-8 bytes into bookshelf_utf8.txt and as cp1252 bytes into bookshelf_cp1252.txt.
  • read_default.py opens the UTF-8 file without an encoding argument and prints which encoding Python picked.
  • other_apis.py does the same through pathlib and subprocess.

Install both Pythons next to each other; the installers from python.org do that on Windows and macOS, and uv python install 3.14 3.15 does it in one command with the uv tool. The project needs no packages, so there is no virtual environment to create.

The cp1252 locale is a Windows thing, and the runs below were made on Linux. The folder builds that locale with localedef, a Linux command that compiles a locale definition, so we can reproduce the Windows default without a Windows machine. localedef needs the locale sources, which the locales package provides on Debian and Ubuntu. On a real Windows PC you skip this and the results are the same.

from pathlib import Path

titles = [
    "Les Misérables - Victor Hugo",
    "Cien años de soledad - Gabriel García Márquez",
    "Götterdämmerung - Richard Wagner",
]
text = "\n".join(titles) + "\n"

Path("bookshelf_utf8.txt").write_bytes(text.encode("utf-8"))       # 114 bytes
Path("bookshelf_cp1252.txt").write_bytes(text.encode("cp1252"))   # 108 bytes
import locale
import sys

print("Python", sys.version.split()[0])
print("utf8_mode", sys.flags.utf8_mode)
print("locale.getencoding()", locale.getencoding())

with open("bookshelf_utf8.txt") as f:
    first_line = f.readline().strip()
    print("encoding used", f.encoding)
    print(first_line)

4. What You See on Python 3.14 and on 3.15

After this section you have seen all three ways the change shows up: a UTF-8 file that reads correctly now, a cp1252 file that fails now, and a write that succeeds now. Each one is the same script run on both versions.

4.1. Reading a UTF-8 File Without an Encoding

We run read_default.py three times in the cp1252 locale: on Python 3.14, on Python 3.15, and on Python 3.15 with UTF-8 mode switched off through PYTHONUTF8=0. The variable PYTHONIOENCODING=utf-8 only keeps print() from garbling the terminal; it changes nothing about what open() decodes.

mkdir -p /tmp/loc
localedef -i en_US -f CP1252 /tmp/loc/en_US.cp1252
export LOCPATH=/tmp/loc PYTHONIOENCODING=utf-8

LC_ALL=en_US.cp1252 python3.14 read_default.py
LC_ALL=en_US.cp1252 python3.15 read_default.py
LC_ALL=en_US.cp1252 PYTHONUTF8=0 python3.15 read_default.py
Python 3.14.8
utf8_mode 0
locale.getencoding() CP1252
encoding used CP1252
Les Misérables - Victor Hugo

Python 3.15.0
utf8_mode 1
locale.getencoding() CP1252
encoding used utf-8
Les Misérables - Victor Hugo

Python 3.15.0
utf8_mode 0
locale.getencoding() CP1252
encoding used CP1252
Les Misérables - Victor Hugo

Python 3.14 decodes the two UTF-8 bytes C3 A9 with cp1252, so the one accented character becomes two, and that is the garbled title in the first run. Python 3.15 reads the same two bytes as the one character that was written. The third run is the proof that UTF-8 mode is the cause: switching it off on 3.15 brings the 3.14 result back.

4.2. Reading a cp1252 File Without an Encoding

This is the case that breaks working Windows code. A file saved as cp1252 holds the single byte E9 for the accented e, and that byte on its own is not valid UTF-8. So Python 3.15 raises an exception where Python 3.14 in a cp1252 locale returned the right text:

import sys

print("Python", sys.version.split()[0])
with open("bookshelf_cp1252.txt") as f:
    print(f.encoding)
    print(f.readline().strip())
Python 3.14.8
CP1252
Les Misérables - Victor Hugo

Python 3.15.0
utf-8
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 7: invalid continuation byte

Both files on both versions give six results, one row per run and one column per file. The last row, Python 3.15 with PYTHONUTF8=0, matches the Python 3.14 row cell for cell.

Run (cp1252 locale)bookshelf_utf8.txtbookshelf_cp1252.txt
Python 3.14Les Misérables, wrong text and no errorLes Misérables
Python 3.15Les MisérablesUnicodeDecodeError
Python 3.15 with PYTHONUTF8=0Les Misérables, wrong text and no errorLes Misérables
Table of results when open() reads a UTF-8 file and a cp1252 file without an encoding on a cp1252 locale, on Python 3.14 and Python 3.15
On a cp1252 locale, Python 3.14 reads a UTF-8 file as wrong text without an error, and Python 3.15 raises an error for a cp1252 file.

4.3. Writing Text Without an Encoding

Writing changes in the other direction. In the cp1252 locale, Python 3.14 cannot store the Polish l with a stroke, so the write fails. Python 3.15 writes it as the two UTF-8 bytes C5 82 and the call succeeds:

with open("polish.txt", "w") as f:
    f.write("Lalka - Bolesław Prus\n")
    # Python 3.14 in a cp1252 locale raises UnicodeEncodeError
    # Python 3.15 writes 23 bytes of UTF-8 on Linux (24 on Windows, where \n becomes \r\n)
UnicodeEncodeError: 'charmap' codec can't encode character 'ł' in position 13: character maps to <undefined>

The problem with that success is that other programs reading the file may still expect the old bytes. An old Windows tool that reads cp1252 now gets UTF-8 and shows garbled text, and nothing in our script reports it. So when another program needs the ANSI code page, we pass encoding=”cp1252″, or encoding=”locale” when the file has to follow whatever the system is set to.

4.4. pathlib and subprocess

Two APIs that never mention an encoding in our code follow the new default too, because they call open() and locale.getpreferredencoding() internally: pathlib.Path and subprocess.run(). In other_apis.py, a child process writes UTF-8 bytes and the parent reads them with text=True:

from pathlib import Path
import subprocess
import sys

text = Path("bookshelf_utf8.txt").read_text().splitlines()[0]
# Python 3.14 in a cp1252 locale gives Les Misérables - Victor Hugo
# Python 3.15 gives Les Misérables - Victor Hugo

code = "import sys; sys.stdout.buffer.write('G\\u00f6tterd\\u00e4mmerung'.encode('utf-8'))"
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
output = result.stdout.strip()
# Python 3.14 in a cp1252 locale gives Götterdämmerung
# Python 3.15 gives Götterdämmerung

A real child process often does not print UTF-8. Older command line tools print in the code page of the system, and with text=True Python 3.15 raises UnicodeDecodeError on their cp1252 output. For those we pass encoding=”locale” to subprocess.run() instead of text=True:

legacy_code = "import sys; sys.stdout.buffer.write('G\\u00f6tterd\\u00e4mmerung'.encode('cp1252'))"
legacy = subprocess.run([sys.executable, "-c", legacy_code], capture_output=True, encoding="locale")
legacy_output = legacy.stdout.strip()   # Götterdämmerung on a cp1252 locale

failed = subprocess.run([sys.executable, "-c", legacy_code], capture_output=True, text=True)
# Python 3.15 raises UnicodeDecodeError, 'utf-8' codec can't decode byte 0xf6 in position 1

5. The encoding Argument of open()

Every fix so far has been the same argument. The encoding argument names the codec, which is the encoder and decoder pair for one encoding, that converts between the bytes in the file and the str values in our program. It works in text mode only; binary mode raises ValueError when we pass it. On Python 3.15 we could rely on the default, and an explicit value is what keeps the code right on Python 3.14 and older as well.

ValueMeaning
“utf-8”UTF-8, the default in Python 3.15
“utf-8-sig”UTF-8 that skips a byte order mark (BOM, three marker bytes at the start of a file) on reading and writes one on writing
“cp1252”Windows code page for Western European languages
“locale”The locale encoding, which is the old default. Python 3.10 and later accept this value

We pass encoding=”utf-8″ for every file we create, so the same bytes land on disk on every platform and every Python version. We use encoding=”locale” only when a file has to follow the system settings of the user, such as the input of an older tool on the same machine. The same rule holds when we read a file line by line in a loop.

with open("bookshelf_utf8.txt", encoding="utf-8") as f:
    utf8_title = f.readline().strip()     # Les Misérables - Victor Hugo

with open("bookshelf_cp1252.txt", encoding="cp1252") as f:
    cp1252_title = f.readline().strip()   # Les Misérables - Victor Hugo

with open("bookshelf_utf8.txt", "rb") as f:
    raw_start = f.read(7)                 # b'Les Mis' (binary mode takes no encoding)

6. The Six Migration Steps

Migration means every text read and write in the code base states its encoding, so the result stops depending on the Python version and the platform. Each step has a command or an edit and a result, and the step is done when you have the result. The first step comes first because it tells you how much work the other five are.

Six step migration flow from finding open() calls without encoding to passing an explicit encoding, handling legacy files and testing with UTF-8 mode on and off
Explicit encodings come first, and switching UTF-8 mode off is only a stopgap.
StepWhat to doWhat you have afterwards
1. Find the callsRun the tests with -X warn_default_encoding (section 6.1)A list of every open(), read_text() and subprocess call that relies on the default, with file and line
2. Decide each file’s encodingFor each call, find out what wrote the file (section 6.2)Each call labelled utf-8, a legacy encoding such as cp1252, or locale
3. Pass the encodingAdd the argument to every call on the list (section 6.3)A test run with -X warn_default_encoding that prints no warning
4. Convert legacy files onceRewrite the files you own from their old encoding to UTF-8 (section 6.4)Fewer calls that need a legacy encoding, and files other tools can read
5. Test with UTF-8 mode on and offRun the suite with PYTHONUTF8=1 and again with PYTHONUTF8=0 (section 6.5)Identical results in both runs, which means no call depends on the default any more
6. Remove the stopgapDelete any PYTHONUTF8=0 you set to keep production running (section 6.6)The code gives the same answer on Python 3.15 and on every older version it supports

6.1. Step 1. Find Every Call That Uses the Default

This step ends with a list of the calls, by file and line. A text search for open( finds the calls in our own files and misses every call inside a library, so we let Python report them at runtime instead. PEP 597 added that report in Python 3.10 as the warning category EncodingWarning. It fires whenever text I/O falls back to the default encoding, and it is off by default. Two switches turn it on, and adding -W error::EncodingWarning turns each warning into an exception:

python -X warn_default_encoding read_default.py
PYTHONWARNDEFAULTENCODING=1 python read_default.py
python -X warn_default_encoding -W error::EncodingWarning read_default.py

The first two commands print the warning and the script keeps running. The third stops at the same line with an exception:

read_default.py:8: EncodingWarning: 'encoding' argument not specified
  with open("bookshelf_utf8.txt") as f:

EncodingWarning: 'encoding' argument not specified

Note that the warning still appears on Python 3.15, where the default is already UTF-8. That is the point of it: those are exactly the calls that behave differently on Python 3.14 and older.

The warning has one blind spot. A function of our own that takes an encoding argument and forwards it to open() hides the original call, so the warning names our function instead of the caller who left the argument out. The function io.text_encoding() fixes that. It returns the value it is given, and for None it returns “utf-8” with UTF-8 mode on or “locale” with the mode off, and with the switch set it raises EncodingWarning for whoever called our function without an encoding:

import io

def read_text(path, encoding=None):
    encoding = io.text_encoding(encoding)
    with open(path, encoding=encoding) as f:
        return f.read()

default_text = read_text("bookshelf_utf8.txt")                    # EncodingWarning when -X warn_default_encoding is set
explicit_text = read_text("bookshelf_utf8.txt", encoding="utf-8")   # no warning

6.2. Step 2. Decide the Real Encoding of Each File

This step ends with a label on each call from step 1. Files that our own code creates are UTF-8. Files from older Windows tools and other legacy sources have their own encoding, usually the code page of the machine that wrote them. When nobody knows it, we ask whoever produced the file, or let a detection library such as charset-normalizer estimate it, which gives a guess and not a certainty.

While deciding, the errors argument of open() is the tool for looking at a file whose encoding is unknown. It says what happens to the bytes the codec cannot decode, and its default strict raises UnicodeDecodeError at the first one. The codecs documentation lists every handler. We ran four of them against bookshelf_cp1252.txt read as UTF-8, where the byte E9 is always invalid, and printed each result with ascii() so the escapes stay visible:

path = "bookshelf_cp1252.txt"

with open(path, encoding="cp1252") as f:
    first = f.readline().strip()   # Les Misérables - Victor Hugo

for handler in ("replace", "ignore", "backslashreplace", "surrogateescape"):
    with open(path, encoding="utf-8", errors=handler) as f:
        line = f.readline().strip()
    print(handler, ascii(line))
errors valueResult for the first lineWhat happens to the bad byte
“strict” (default)UnicodeDecodeErrorReading stops
“replace”Les Mis\ufffdrables – Victor HugoReplaced with the character U+FFFD
“ignore”Les Misrables – Victor HugoDropped, so data is lost
“backslashreplace”Les Mis\\xe9rables – Victor HugoKept as the text \xe9
“surrogateescape”Les Mis\udce9rables – Victor HugoKept as a surrogate code (a placeholder character from U+DC80 to U+DCFF) that writes back as the same byte

Three handlers are not in the table because they do not apply here: xmlcharrefreplace and namereplace work only when writing, and surrogatepass works only with the Unicode codecs. A handler is for looking, not for fixing. For a file whose encoding we know, we pass that encoding, because a handler only hides the mismatch.

6.3. Step 3. Pass the Encoding in Every Call

This step ends with a test run under -X warn_default_encoding that prints no warning. We add the label from step 2 to every open(), Path.read_text(), Path.write_text() and subprocess.run() call on the list, using encoding=”locale” only where the file or the child process has to follow the system settings (section 4.4 has the subprocess form). In our own library functions that accept encoding=None, we route the value through io.text_encoding() as in section 6.1.

Some of these calls exist only in newer Python versions, which matters when the same code still has to run on Python 3.9 or older. There, encoding=”locale” raises LookupError, because locale is not a registered codec name, and -X warn_default_encoding does not exist. The table has the first version of each one.

FeatureFirst Python version
encoding=”utf-8″ in open()Every Python 3 version
PYTHONUTF8 and -X utf83.7
encoding=”locale”, EncodingWarning, -X warn_default_encoding and io.text_encoding()3.10
locale.getencoding()3.11
UTF-8 mode on by default3.15

6.4. Step 4. Convert the Legacy Files You Own

This step ends with fewer calls that need a legacy encoding. Declaring cp1252 in every script that touches a file gets tedious, and the one script that forgets it hits the exception again, so for the files we own we convert once. We read with the real encoding and write back as UTF-8; the text is identical and only the bytes change:

from pathlib import Path

source = Path("bookshelf_cp1252.txt")
target = Path("bookshelf_converted.txt")

text = source.read_text(encoding="cp1252")
target.write_text(text, encoding="utf-8", newline="\n")

size = len(target.read_bytes())                                  # 114
second = target.read_text(encoding="utf-8").splitlines()[1]     # Cien años de soledad - Gabriel García Márquez

One kind of file needs a slightly different UTF-8. A byte order mark, or BOM, is the three bytes EF BB BF at the start of a UTF-8 file, and Excel uses it to recognize that a CSV file is UTF-8. The codec utf-8 keeps the BOM as the character U+FEFF in the first value we read, and utf-8-sig strips it on reading and writes it on writing. So the codec matters as soon as we read and write CSV files that Excel has to open:

import csv

rows = [["title", "author"], ["Les Misérables", "Victor Hugo"]]

with open("bookshelf_excel.csv", "w", encoding="utf-8-sig", newline="") as f:
    csv.writer(f).writerows(rows)

with open("bookshelf_excel.csv", "rb") as f:
    start = f.read(3)                      # b'\xef\xbb\xbf'

with open("bookshelf_excel.csv", encoding="utf-8") as f:
    plain_first = f.readline().strip()     # \ufefftitle,author (the BOM stays in the text)

with open("bookshelf_excel.csv", encoding="utf-8-sig") as f:
    clean_first = f.readline().strip()     # title,author

6.5. Step 5. Run the Tests With UTF-8 Mode On and Off

This step ends with two identical test runs, which proves that no call depends on the default any more. UTF-8 mode is a startup setting, so we cannot change it from inside a running script; setting os.environ[“PYTHONUTF8”] in code does nothing. The variable belongs in the environment that starts Python, such as the shell, the environment field of an IDE run configuration, or the kernel spec of a Jupyter kernel.

SettingEffect
PYTHONUTF8=1 or -X utf8Turns UTF-8 mode on. Python 3.7 and later accept it, so older versions can preview the new default
PYTHONUTF8=0 or -X utf8=0Turns UTF-8 mode off, so open() uses the locale encoding again
PYTHONIOENCODINGOverrides the encoding of sys.stdin, sys.stdout and sys.stderr

Note that any other non-empty value of PYTHONUTF8 stops the interpreter at startup with invalid PYTHONUTF8 environment variable value. The three lines set the variable for the current session in the Windows command prompt, in PowerShell and in a Linux or macOS shell:

set PYTHONUTF8=0
$env:PYTHONUTF8 = "0"
export PYTHONUTF8=0

In the cp1252 locale of our example, the four combinations of version and setting give these results, and after step 3 the tests should pass on all four rows.

Runsys.flags.utf8_modeEncoding used by open()
Python 3.140CP1252
Python 3.14 with PYTHONUTF8=11utf-8
Python 3.151utf-8
Python 3.15 with PYTHONUTF8=00CP1252

Two functions of the locale module answer different questions, and a test that compares them finds code that reads the wrong one. locale.getencoding(), added in Python 3.11, returns the encoding of the locale and ignores UTF-8 mode, so on Windows it returns the ANSI code page. Its older sibling locale.getpreferredencoding(False) follows UTF-8 mode, so it returns utf-8 on Python 3.15. Code that wants the real locale encoding uses the first one:

import locale

locale_name = locale.getencoding()                   # CP1252 (ignores UTF-8 mode)
preferred_name = locale.getpreferredencoding(False)  # utf-8 on Python 3.15, CP1252 on Python 3.14 and older

One more case shows up in this step on servers and in containers. With UTF-8 mode off and the plain C locale in place, the default encoding is ASCII rather than a code page. Python normally upgrades the C locale to a UTF-8 one on its own; PYTHONCOERCECLOCALE=0 switches that upgrade off, so the command below forces the case, and the read fails at the first non-ASCII byte:

LC_ALL=C PYTHONUTF8=0 PYTHONCOERCECLOCALE=0 python3.15 read_default.py
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 7: ordinal not in range(128)

6.6. Step 6. Remove the Stopgap

This step ends with no PYTHONUTF8=0 anywhere in the deployment. Setting it is the quick way to keep a production system on the old behavior while steps 1 to 5 run, and it is only that. Once step 5 passes with the mode on and off, the variable does nothing for the code, and leaving it in place means the next person to read the deployment config has to work out why it is there.

7. Python UTF-8 Default Encoding FAQs

7.1. Which Python Version Made UTF-8 the Default Encoding?

Python 3.15, released on October 9, 2026. PEP 686 describes the change, and the What’s New in Python 3.15 page lists it among the language changes.

7.2. Does Python 3.15 Break Existing Code?

Only code that relies on the default encoding to read or write data that is not UTF-8, and that code is mostly on Windows. Linux and macOS machines are on a UTF-8 locale in most cases, so their results do not change. Code that already passes an explicit encoding is not affected at all.

7.3. How Do We Keep the Old Locale Behavior?

Either set PYTHONUTF8=0 or -X utf8=0 for the whole process, or pass encoding=”locale” for one file, as section 6.5 shows.

7.4. How Do We Get the Real Locale Encoding?

Call locale.getencoding(), which ignores UTF-8 mode and needs Python 3.11 or later. Section 6.5 compares it with locale.getpreferredencoding(False).

7.5. Does the New Default Affect Binary Files?

No. Binary mode (rb and wb) reads and writes bytes and uses no encoding at all, and passing encoding together with a binary mode raises ValueError.

7.6. Should We Use utf-8 or locale as the Encoding?

utf-8 for the files we create or exchange, and locale only for the files that another program on the same machine writes in the system encoding. Section 5 has the full list of values.

8. Conclusion

Python 3.15 turns UTF-8 mode on by default, so open() without an encoding argument reads and writes UTF-8 on Windows, Linux and macOS alike. UTF-8 files that Windows used to decode as cp1252 now come out right, and cp1252 files now need an explicit encoding=”cp1252″, or Python raises UnicodeDecodeError at the first accented character.

The migration is six steps and most of the work is in the first three: find the calls with -X warn_default_encoding, decide what wrote each file, and pass the encoding. In our example, 108 bytes of cp1252 became 114 bytes of UTF-8 and the text did not change. One thing to carry away: the EncodingWarning still fires on Python 3.15, and that is the signal to act on, because every call it names will behave differently on the older Python your colleague is still running.

9. References

Drop me your questions related to the Python UTF-8 default encoding in the comments.

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.