Python 3.15 makes UTF-8 the default text encoding. A plain open("notes.txt") call now decodes the same way on every platform, including Windows. Earlier Python versions picked an encoding from your locale, so on Windows the same code that worked on Linux and macOS could produce garbage.
By the end of this tutorial, you’ll understand that:
- Python 3.15 enables UTF-8 as its default text encoding, so text I/O uses it when you omit the
encodingargument. - The previous encoding default came from your locale, which often meant
cp1252on Windows andutf-8on Linux and macOS. - Passing
encoding="utf-8"keeps your file I/O portable and safe across every Python version and platform. - The
flake8-encodingstool and the-X warn_default_encodingflag help you find code that leans on the implicit default. - Setting
PYTHONUTF8=0or passingencoding="locale"restores the locale-based behavior when you need it.
Here’s what the change looks like. Say that you save the text "Café ☕" to a file and read it back with a bare call to open() on Windows:
PS> py -3.14 -c "print(open('cafe.txt').read())"
Café ☕
In this example, the default decoder—often cp1252—misinterprets the UTF-8 bytes, so the code prints mojibake. Now look at how the same code behaves on Python 3.15:
PS> py -3.15 -c "print(open('cafe.txt').read())"
Café ☕
Same code, no encoding argument to open(), and the mojibake is gone because of the consistent UTF-8 default. In this tutorial, you’ll first see exactly what changes, then try the new default on a Python 3.15 pre-release. After that, you’ll learn how to keep your own code working the same way on every Python version and platform.
Get Your Code: Click here to download the free sample code you’ll use to find implicit-encoding calls and keep your file I/O portable across every platform.
Take the Quiz: Test your knowledge with our interactive “Python 3.15 Preview: UTF-8 by Default” quiz. You’ll receive a score upon completion to help you track your learning progress:
Interactive Quiz
Python 3.15 Preview: UTF-8 by DefaultTest your understanding of Python 3.15's UTF-8 default, from the origins of the old locale-based encoding to passing explicit encodings in your code.
Meet Python 3.15’s UTF-8 Default
It helps to understand why the default text encoding behavior was a problem in Python versions older than 3.15. Take the built-in open() function as a baseline. Whenever you omitted the encoding argument when opening a text file, Python fell back to whatever locale.getencoding() returned on your operating system.
Note: For a refresher on how text encodings work before you dig in, check out the Unicode & Character Encodings in Python: A Painless Guide tutorial.
On Windows, that fallback was the ANSI code page—the legacy character set that Windows selects from your system’s regional settings. Common examples are cp1252 in the Americas and Western Europe, cp1251 in Cyrillic-script regions, and cp932 in Japan.
On Unix, it was the codeset from your LC_CTYPE locale, which was utf-8 on a modern Linux or macOS system but plain ascii under a bare C or POSIX locale. The latter two are the minimal default locales that a system uses when nothing else is configured, which is often the case in containers and CI runners. You end up with the same code but different text encoding or decoding behaviors depending on where the code runs.
This inconsistent behavior can raise UnicodeDecodeError or UnicodeEncodeError when reading or writing text files, respectively. It can also produce mojibake, as you saw earlier, with no error at all.
Note: The inconsistent text encoding behavior reaches far beyond the built-in open() function. The same encoding rule applies to pathlib methods like Path.read_text() and Path.write_text(), the text modes of gzip.open(), bz2.open(), and lzma.open(), and subprocess calls that pass text=True.
It also affects the text-mode files from tempfile, configparser.ConfigParser.read(), and logging.FileHandler.
UTF-8 became the de facto standard everywhere long ago, except in Python’s own default. It’s the standard for source code files, JSON, TOML, and YAML, as well as on the web and in other programming languages like Go, Rust, and Java. On top of that, every mainstream editor, such as Visual Studio Code, uses it out of the box.
Python 3.15 closes that gap through PEP 686. Text input/output (I/O) without an explicit encoding now uses UTF-8 no matter which operating system or locale you’re using.
The table below summarizes the default text encoding that open() picks before and after Python 3.15:
| Scenario | Default up to 3.14 | Default in 3.15+ |
|---|---|---|
| Windows | ANSI code page (cp1252) |
utf-8 |
| Linux/macOS (UTF-8 locale) | utf-8 |
utf-8 |
Any OS under a C/POSIX locale |
ascii |
utf-8 |
encoding="<your_preferred_encoding>" |
as given | as given |
The rows that change are the ones that used to depend on the operating system or the locale. On a UTF-8 Linux or macOS system, your everyday runs look the same, but the C/POSIX row still applies to you, since containers, cron jobs, and CI runners often start under a bare C locale. Your code also has to decode correctly on other people’s machines. On Windows, this change removes a whole class of bugs.
A bit of history: This text encoding gap has a long history of tripping up real users. PEP 597 recounts the classic case from the days of setup.py scripts. Package authors often wrote long_description = open("README.md").read() in their setup.py file. This line worked fine on Linux and macOS, but it failed with a UnicodeDecodeError on Windows if the target README.md contained emojis or accented characters.
That specific example is dated now because modern Python projects rely on pyproject.toml instead of setup.py. As a result, this particular bug rarely bites anymore. Still, it captures the root problem perfectly: the same bare open() call decoding differently depending on where it runs. That’s exactly what the new default fixes.
If you want to go deeper into how character encodings work under the hood, Real Python’s video course on Unicode in Python: Working With Character Encodings covers code points, byte representations, and the built-in functions for converting between them.
Now that you understand the problem, it’s time to try out the new text encoding default on your own.
Try the Default UTF-8 Encoding on a Python 3.15 Pre-Release
To follow along, go ahead and install a Python 3.15 pre-release alongside your existing Python 3.14 or older version. Grab whichever pre-release is current when you read this, since every 3.15 beta and release candidate has UTF-8 mode on by default. If you’ve never set one up before, then the How Can You Install a Pre-Release Version of Python? guide walks through the options.
With both versions available, select your operating system below, then ask each interpreter whether UTF-8 mode is active by reading the sys.flags.utf8_mode flag:
The flag flips from 0 (off) in 3.14 to 1 (on) in 3.15, which confirms that UTF-8 mode is now on by default.
Now create a small text file named cafe.txt containing the non-ASCII text "Café ☕". Rather than relying on your editor’s encoding setting, let Python write the file as UTF-8 for you, then read it back with a bare open() call on each Python version:
On Windows, Python 3.14 prints the same mojibake you saw earlier, while Python 3.15 decodes the file as UTF-8 and shows the text you saved. You didn’t change the code, only the interpreter. On Linux and macOS, both versions already print Café ☕.
You don’t have to install both versions on every platform to see the pattern, though. The interactive figure below mirrors these commands. Choose a Python version and platform, then click Run to see what a bare open() prints:
Whichever combination you try, the same one-line fix makes the behavior predictable, and writing that fix is what you’ll do next.
Make Your Code Encoding-Safe Across Versions
Now that you’ve seen the new default encoding in action, you can make your own code behave safely on every Python version and platform. The durable fix hasn’t changed in years: pass an explicit encoding on every text-mode call.
The workflow has two steps:
- Find the spots that rely on the default text encoding.
- Pass an explicit encoding at each one.
Finally, if you write Python libraries, one extra practice applies, and you’ll get to it at the end of this section.
Find Code That Relies on the Default Encoding
You have two tools for tracking down implicit-encoding calls, and you should run both. Say that app.py reads a UTF-8 data.json file with a bare open() call as shown below:
app.py
import json
with open("data.json") as f:
data = json.load(f)
print(f"Loaded {len(data)} entries")
You can use the flake8-encodings plugin to flag every open(), pathlib.Path, and configparser.ConfigParser call that omits the encoding argument. Install it and run it against your code:
$ python -m pip install "flake8-encodings[classes]"
$ flake8 app.py
app.py:3:6: ENC001 no encoding specified for 'open'.
With this output, you know exactly which line to fix. The plugin also checks for open() calls that pass encoding=None, which is equivalent to omitting the argument.
Alternatively, you can rely on a runtime warning to catch the same calls as your code runs. Turn on EncodingWarning, introduced by PEP 597, by passing the -X warn_default_encoding flag to the python command. This way, each implicit-encoding call emits a warning at its own line:
$ python -X warn_default_encoding app.py
app.py:3: EncodingWarning: 'encoding' argument not specified
with open("data.json") as f:
Loaded 2 entries
Add -W error::EncodingWarning when you’d rather promote those warnings to hard errors. Your program then stops with a traceback at the first implicit-encoding call instead of running to completion.
If you can’t control how the python command is invoked, then set the PYTHONWARNDEFAULTENCODING environment variable to 1 instead. This setting turns on the same EncodingWarning without any command-line flags.
Between the linter and the runtime warning, you’ll surface every place your code depends on the default, whether or not those lines run during testing.
Pass an Explicit Encoding
Once you’ve found the implicit calls, you can fix each one with a single argument. Pass encoding="utf-8" for text you know is UTF-8. That’s an explicit choice that’s portable and reads clearly on every Python version.
If you want the locale encoding, then pass encoding="locale" instead, which is available on Python 3.10 and later. That value is a sentinel string rather than a codec name. It doubles as a way to silence the warning while documenting that the locale dependence is intentional:
import json
with open("data.json", encoding="utf-8") as f: # Portable and unambiguous
data = json.load(f)
with open("legacy.csv", encoding="locale") as f: # Intentional locale use
legacy = f.read()
In this example, the first open() reads data.json as UTF-8, so you get the same result on every platform. The second call opts into the locale encoding, making that older behavior a documented choice rather than an accident.
When several modules read or write text, define the encoding once as a project-wide constant so the choice lives in a single place instead of being scattered across string literals:
constants.py
ENCODING = "utf-8" # Single source of truth for text I/O
Then import that name wherever you open a file, and switching encodings later becomes a one-line edit:
app.py
import json
from constants import ENCODING
with open("data.json", encoding=ENCODING) as f:
data = json.load(f)
print(f"Loaded {len(data)} entries")
Here, app.py reads the encoding from the shared ENCODING constant instead of hard-coding "utf-8" inline. Every module that imports ENCODING now uses the same value.
When you need the real locale encoding, swap any call to locale.getpreferredencoding(False) for locale.getencoding(). The former now returns "utf-8" under UTF-8 mode and can no longer report the underlying locale:
The locale.getencoding() function, available on Python 3.11 and later, reports the real locale encoding regardless of UTF-8 mode. In other words, the value you see depends only on your platform and locale, not on whether UTF-8 mode is active.
When you’re not sure which option fits, the table below maps common situations to the right choice:
| Situation | Encoding Value |
|---|---|
| Reading or writing a UTF-8 file | encoding="utf-8" |
| Matching the user’s locale encoding | encoding="locale" |
| Reading a known legacy-encoded file | encoding="<your_codec>" |
Needing the locale encoding outside open() |
locale.getencoding() |
Almost every time, you’ll want encoding="utf-8". You only need the other rows when something specific rules it out.
Forward the Caller’s Encoding in Library Code
If you write libraries, then one more practice matters on top of the two steps above. When some of your functions accept encoding=None and forward it to open(), first wrap the value in a call to io.text_encoding(), which is available on Python 3.10 and later. That call resolves None to the active default encoding:
$ python3.15 -c "import io; print(io.text_encoding(None))"
utf-8
It also makes any EncodingWarning point at your caller rather than a line buried inside your package:
textlib.py
import io
def read_text(path, encoding=None):
encoding = io.text_encoding(encoding) # Points at the caller
with open(path, encoding=encoding) as f:
return f.read()
To watch that happen, call read_text() from a program that leaves out the encoding argument:
main.py
import textlib
textlib.read_text("notes.txt")
Run main.py with EncodingWarning enabled, and the warning lands on the call site, not the open() line inside your library:
$ python -X warn_default_encoding main.py
main.py:3: EncodingWarning: 'encoding' argument not specified
textlib.read_text("notes.txt")
Comment out the io.text_encoding() line, and that same warning fires inside textlib.py instead, right where the caller can’t act on it. Moving the warning out to the caller is the whole reason to use this technique.
Restore the Previous Behavior When You Need It
Up to this point, you’ve made your code encoding-safe by being explicit. Sometimes, though, you can’t move to UTF-8 at all. Perhaps your project uses a downstream tool or data file that still expects the old locale encoding. Python 3.15 keeps a couple of escape options that can help you manage those scenarios.
The broad one is a switch that turns UTF-8 mode back off. To do this, set the PYTHONUTF8 environment variable to 0 or pass -X utf8=0 when running the python command. This way, the Python interpreter restores the previous locale-based default behavior everywhere:
PS> $env:PYTHONUTF8="0"
PS> py -3.15 -c "import locale; print(locale.getpreferredencoding(False))"
cp1252
PS> py -3.15 -c "print(open('cafe.txt').read())"
Café ☕
Note that this switch revives the original problem. With PYTHONUTF8 set to 0, a bare open("cafe.txt") call on 3.15 behaves exactly as it did on earlier versions, mojibake and all.
The narrow one is the encoding="locale" argument value that you saw earlier, which you can use for a single read or write. It keeps that one call on the locale encoding without touching the rest of your program:
with open("legacy.csv", encoding="locale") as f:
legacy = f.read()
Use the global switch only while you migrate. You’re better off passing a specific value to encoding because that says what each file needs instead of reconfiguring the whole interpreter. An explicit encoding argument also travels with your code, while an environment variable can go missing on another machine or in another shell.
Conclusion
Python 3.15 makes UTF-8 the default for text I/O through PEP 686. This change ends a long-standing inconsistency where the same code could behave differently on Windows, Linux, or macOS. The new default only affects calls that omit encoding, so the safest move is to be explicit on every text-mode call.
In this tutorial, you’ve learned that:
- Python 3.15 turns on UTF-8 mode by default, so bare text I/O decodes as UTF-8 on every platform.
- The change only touches calls that omit the
encodingargument, and it leaves explicit encodings alone. - Adding
encoding="utf-8"keeps your file I/O portable across every Python version, not just 3.15. flake8-encodingsand-X warn_default_encodingsurface the calls that still rely on the old default.PYTHONUTF8=0andencoding="locale"bring back the locale-based behavior when you truly need it.
Audit your code with the linter and the warning on the Python you have today, then add explicit encodings. Running the updated code on a 3.15 pre-release confirms the fix and turns the upgrade into a non-event.
For a deeper look at how text and bytes fit together, work through Real Python’s Reading and Writing Files in Python, which moves from file modes to buffering, then revisit Unicode & Character Encodings in Python: A Painless Guide for the encode and decode round trip.
Get Your Code: Click here to download the free sample code you’ll use to find implicit-encoding calls and keep your file I/O portable across every platform.
Frequently Asked Questions
Now that you have some experience with Python 3.15’s UTF-8 default, you can use the questions and answers below to check your understanding and recap what you’ve learned.
These FAQs are related to the most important concepts you’ve covered in this tutorial. Click the Show/Hide toggle beside each question to reveal the answer.
Only if it relied on the platform default to decode a non-UTF-8 file, which is common on Windows. Add an explicit encoding, and it behaves the same everywhere.
Set PYTHONUTF8=0 (or -X utf8=0) for the whole process, or pass encoding="locale" on individual calls.
Call locale.getencoding(). It reports the locale encoding regardless of UTF-8 mode, whereas locale.getpreferredencoding(False) now returns "utf-8" under the new default.
Run with -X warn_default_encoding (or PYTHONWARNDEFAULTENCODING=1) to emit an EncodingWarning at each implicit-encoding call. This works on Python 3.10 and later.
When you pass encoding="utf-8", Python always decodes the file as UTF-8, so you get the same result on every machine. In contrast, encoding="locale" hands the decision to the locale encoding, which can differ from one machine to another. Reach for "utf-8" unless you specifically need to match the user’s system setting. The "locale" value is available on Python 3.10 and later.
Take the Quiz: Test your knowledge with our interactive “Python 3.15 Preview: UTF-8 by Default” quiz. You’ll receive a score upon completion to help you track your learning progress:
Interactive Quiz
Python 3.15 Preview: UTF-8 by DefaultTest your understanding of Python 3.15's UTF-8 default, from the origins of the old locale-based encoding to passing explicit encodings in your code.