<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>windows | Mike Nguyen</title><link>https://mikenguyen.netlify.app/tag/windows/</link><atom:link href="https://mikenguyen.netlify.app/tag/windows/index.xml" rel="self" type="application/rss+xml"/><description>windows</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>© Mike Nguyen 2026</copyright><lastBuildDate>Tue, 10 Nov 2026 00:00:00 +0000</lastBuildDate><image><url>https://mikenguyen.netlify.app/media/social_sharing_image.png</url><title>windows</title><link>https://mikenguyen.netlify.app/tag/windows/</link></image><item><title>Python 3.15 Made UTF-8 the Default on Windows, and 82% of Characters Come Back Wrong in 3.14 With No Error</title><link>https://mikenguyen.netlify.app/post/python-3-15-utf-8-windows/</link><pubDate>Tue, 10 Nov 2026 00:00:00 +0000</pubDate><guid>https://mikenguyen.netlify.app/post/python-3-15-utf-8-windows/</guid><description>
&lt;p>Python 3.15 came out on 9 October, and on Windows it changes what &lt;code>open()&lt;/code> does when you do not
name an encoding. Python 3.14 and earlier use the ANSI code page, which is cp1252 on a US or
Western European machine. Python 3.15 uses UTF-8 on every platform
(&lt;a href="https://peps.python.org/pep-0686/">PEP 686&lt;/a>). R made the same move in R 4.2.0, in April 2022
(&lt;a href="https://blog.r-project.org/2022/11/07/issues-while-switching-r-to-utf-8-and-ucrt-on-windows/">Kalibera&lt;/a>).
So I wrote six names from R on Windows 11, one per CSV file, and read them back with Python’s
&lt;code>csv&lt;/code> module and no encoding argument, under 3.14.10 and 3.15.2, the
current release of each.&lt;/p>
&lt;pre class="r">&lt;code>people &amp;lt;- c(&amp;quot;Nguyễn Thị Minh Khai&amp;quot;, &amp;quot;Zoë O’Brien&amp;quot;, &amp;quot;Łukasz Wójcik&amp;quot;,
&amp;quot;Müller&amp;quot;, &amp;quot;東京&amp;quot;, &amp;quot;José Álvarez&amp;quot;)
for (i in seq_along(people)) {
write.csv(data.frame(id = i, name = people[i]), sprintf(&amp;quot;person_%d.csv&amp;quot;, i),
row.names = FALSE)
}&lt;/code>&lt;/pre>
&lt;p>Under Python 3.14:&lt;/p>
&lt;pre class="python">&lt;code>import csv
for i in range(1, 7):
try:
with open(f&amp;quot;person_{i}.csv&amp;quot;, newline=&amp;quot;&amp;quot;) as f: # no encoding argument
print([row[&amp;quot;name&amp;quot;] for row in csv.DictReader(f)][0])
except UnicodeDecodeError as e:
print(&amp;quot;UnicodeDecodeError on byte&amp;quot;, hex(e.object[e.start]))
## Nguyá»…n Thá»‹ Minh Khai
## ZoÃ« Oâ€™Brien
## UnicodeDecodeError on byte 0x81
## MÃ¼ller
## UnicodeDecodeError on byte 0x9d
## UnicodeDecodeError on byte 0x81&lt;/code>&lt;/pre>
&lt;p>The same code under Python 3.15:&lt;/p>
&lt;pre>&lt;code>## Nguyễn Thị Minh Khai
## Zoë O’Brien
## Łukasz Wójcik
## Müller
## 東京
## José Álvarez&lt;/code>&lt;/pre>
&lt;p>Python 3.14 returned 3 names wrong and raised on the other
3. Nguyễn, the most common Vietnamese surname and mine, came back as
Nguyá»…n. Python 3.15 read all 6 correctly.&lt;/p>
&lt;div id="the-error-was-luck" class="section level2">
&lt;h2>The error was luck&lt;/h2>
&lt;p>The three names that raised have one thing in common. Their UTF-8 bytes include one of the five
byte values that cp1252 leaves undefined. Every other byte maps to some character, so cp1252
reads almost anything without complaint.&lt;/p>
&lt;pre class="python">&lt;code>undefined = []
for b in range(256):
try:
bytes([b]).decode(&amp;quot;cp1252&amp;quot;)
except UnicodeDecodeError:
undefined.append(hex(b))
print(undefined)
for ch in [&amp;quot;Á&amp;quot;, &amp;quot;á&amp;quot;, &amp;quot;”&amp;quot;, &amp;quot;’&amp;quot;]:
b = ch.encode(&amp;quot;utf-8&amp;quot;)
try:
shown = b.decode(&amp;quot;cp1252&amp;quot;)
except UnicodeDecodeError:
shown = &amp;quot;raises&amp;quot;
print(ch, b.hex(&amp;quot; &amp;quot;), &amp;quot;-&amp;gt;&amp;quot;, shown)
## [&amp;#39;0x81&amp;#39;, &amp;#39;0x8d&amp;#39;, &amp;#39;0x8f&amp;#39;, &amp;#39;0x90&amp;#39;, &amp;#39;0x9d&amp;#39;]
## Á c3 81 -&amp;gt; raises
## á c3 a1 -&amp;gt; Ã¡
## ” e2 80 9d -&amp;gt; raises
## ’ e2 80 99 -&amp;gt; â€™&lt;/code>&lt;/pre>
&lt;p>Capital Á ends in 0x81 and raises. Lowercase á becomes Ã¡ and does not. The closing curly quote
ends in 0x9D and raises, while the apostrophe becomes â€™. So whether Python 3.14 warns you
depends on which letters happen to be in the file.&lt;/p>
&lt;p>To see how often the error comes, I sent every assigned non-ASCII character in Unicode
17.0.0, all 159,706 of them, through Python 3.15’s default writer and Python
3.14’s default reader. Not one came back right. 82% came back wrong without an
error, and the other 18% raised.&lt;/p>
&lt;table>
&lt;colgroup>
&lt;col width="58%" />
&lt;col width="9%" />
&lt;col width="21%" />
&lt;col width="9%" />
&lt;/colgroup>
&lt;thead>
&lt;tr class="header">
&lt;th align="left">Characters&lt;/th>
&lt;th align="right">Count&lt;/th>
&lt;th align="right">Wrong, no error&lt;/th>
&lt;th align="right">Raises&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr class="odd">
&lt;td align="left">Latin-1 Supplement (é, ü, ñ)&lt;/td>
&lt;td align="right">96&lt;/td>
&lt;td align="right">95%&lt;/td>
&lt;td align="right">5%&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">Latin Extended-A (Ł, ő, š)&lt;/td>
&lt;td align="right">128&lt;/td>
&lt;td align="right">92%&lt;/td>
&lt;td align="right">8%&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">Vietnamese (Latin Extended Additional)&lt;/td>
&lt;td align="right">256&lt;/td>
&lt;td align="right">92%&lt;/td>
&lt;td align="right">8%&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">Greek&lt;/td>
&lt;td align="right">135&lt;/td>
&lt;td align="right">94%&lt;/td>
&lt;td align="right">6%&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">Cyrillic&lt;/td>
&lt;td align="right">256&lt;/td>
&lt;td align="right">92%&lt;/td>
&lt;td align="right">8%&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">Arabic&lt;/td>
&lt;td align="right">256&lt;/td>
&lt;td align="right">92%&lt;/td>
&lt;td align="right">8%&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">General Punctuation (curly quotes, dashes)&lt;/td>
&lt;td align="right">111&lt;/td>
&lt;td align="right">53%&lt;/td>
&lt;td align="right">47%&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">CJK Unified Ideographs&lt;/td>
&lt;td align="right">20,992&lt;/td>
&lt;td align="right">85%&lt;/td>
&lt;td align="right">15%&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">Hangul Syllables&lt;/td>
&lt;td align="right">11,172&lt;/td>
&lt;td align="right">84%&lt;/td>
&lt;td align="right">16%&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">Emoji (U+1F300 to U+1FAFF)&lt;/td>
&lt;td align="right">1,895&lt;/td>
&lt;td align="right">80%&lt;/td>
&lt;td align="right">20%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The other direction is the mirror image. Python 3.14 can write only 123 of those
characters, the non-ASCII part of cp1252, and raises &lt;code>UnicodeEncodeError&lt;/code> on the rest. Python
3.15 raised &lt;code>UnicodeDecodeError&lt;/code> on all 123 of the ones it could write. A cp1252 byte
next to plain ASCII is never valid UTF-8, so the only escape is two or more non-ASCII characters in a
row that happen to spell a UTF-8 sequence, such as Ã followed by ©, which reads as é.&lt;/p>
&lt;p>Old files fail loudly in new Python. New files fail quietly in old Python.&lt;/p>
&lt;/div>
&lt;div id="where-r-and-python-meet" class="section level2">
&lt;h2>Where R and Python meet&lt;/h2>
&lt;p>R has written UTF-8 on Windows since 4.2.0, so most of what 3.15 changes is Python catching up
with R. I ran the common handoffs both ways, with the same names, under both versions.&lt;/p>
&lt;table>
&lt;colgroup>
&lt;col width="39%" />
&lt;col width="15%" />
&lt;col width="30%" />
&lt;col width="13%" />
&lt;/colgroup>
&lt;thead>
&lt;tr class="header">
&lt;th align="left">Path&lt;/th>
&lt;th align="left">Names&lt;/th>
&lt;th align="left">Python 3.14.10&lt;/th>
&lt;th align="left">Python 3.15.2&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr class="odd">
&lt;td align="left">R &lt;code>write.csv()&lt;/code> to Python &lt;code>csv&lt;/code> module&lt;/td>
&lt;td align="left">all six&lt;/td>
&lt;td align="left">UnicodeDecodeError&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">R &lt;code>write.csv()&lt;/code> to Python &lt;code>csv&lt;/code> module&lt;/td>
&lt;td align="left">three cp1252 can hold&lt;/td>
&lt;td align="left">wrong, no error&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">Python 3.15 &lt;code>csv.writer&lt;/code> to Python &lt;code>csv&lt;/code> module&lt;/td>
&lt;td align="left">three cp1252 can hold&lt;/td>
&lt;td align="left">wrong, no error&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">R &lt;code>write.csv()&lt;/code> to &lt;code>pandas.read_csv()&lt;/code>&lt;/td>
&lt;td align="left">all six&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">Python &lt;code>csv.writer&lt;/code> to R &lt;code>read.csv()&lt;/code>&lt;/td>
&lt;td align="left">all six&lt;/td>
&lt;td align="left">UnicodeEncodeError, file holds 0 of 6 rows&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">Python &lt;code>csv.writer&lt;/code> to R &lt;code>read.csv()&lt;/code>&lt;/td>
&lt;td align="left">three cp1252 can hold&lt;/td>
&lt;td align="left">wrong, no error&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">Python &lt;code>print()&lt;/code> to R &lt;code>system2()&lt;/code>&lt;/td>
&lt;td align="left">all six&lt;/td>
&lt;td align="left">UnicodeEncodeError&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">R &lt;code>writeLines()&lt;/code> to Python &lt;code>subprocess.run(text=True)&lt;/code>&lt;/td>
&lt;td align="left">all six&lt;/td>
&lt;td align="left">stdout is None&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">cp1252 file to Python &lt;code>csv&lt;/code> module&lt;/td>
&lt;td align="left">three cp1252 can hold&lt;/td>
&lt;td align="left">right&lt;/td>
&lt;td align="left">UnicodeDecodeError&lt;/td>
&lt;/tr>
&lt;tr class="even">
&lt;td align="left">cp1252 file to &lt;code>pandas.read_csv()&lt;/code>&lt;/td>
&lt;td align="left">three cp1252 can hold&lt;/td>
&lt;td align="left">UnicodeDecodeError&lt;/td>
&lt;td align="left">UnicodeDecodeError&lt;/td>
&lt;/tr>
&lt;tr class="odd">
&lt;td align="left">UTF-8 file with a BOM to &lt;code>csv.DictReader&lt;/code>&lt;/td>
&lt;td align="left">three cp1252 can hold&lt;/td>
&lt;td align="left">KeyError&lt;/td>
&lt;td align="left">KeyError&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Python 3.15 fixed every handoff with R. Three rows are worth a second look.&lt;/p>
&lt;p>&lt;strong>pandas did not change.&lt;/strong> &lt;code>read_csv()&lt;/code> has always assumed UTF-8, whatever the locale, so it
read R’s file correctly under both versions and has been raising on cp1252 files for years.&lt;/p>
&lt;p>&lt;strong>The subprocess failure hides.&lt;/strong> On Windows, &lt;code>subprocess.run()&lt;/code> decodes the child’s output in a
background thread. When the decode fails, that thread prints a traceback, &lt;code>run()&lt;/code> returns
normally, and &lt;code>stdout&lt;/code> is &lt;code>None&lt;/code>
(&lt;a href="https://github.com/python/cpython/blob/v3.14.10/Lib/subprocess.py#L1682">line 1682 of &lt;code>subprocess.py&lt;/code> in 3.14.10&lt;/a>).
&lt;code>check=True&lt;/code> does not catch it, because R exited cleanly. The same line is still in 3.15.2,
which works here only because its decode succeeds.&lt;/p>
&lt;p>&lt;strong>3.15 does not fix the BOM.&lt;/strong> Files saved with the “UTF-8 with BOM” option start with three
marker bytes. Plain UTF-8 keeps them as an invisible character on the first column name, so
&lt;code>row["name"]&lt;/code> raises &lt;code>KeyError&lt;/code> under both versions. Naming the encoding fixes this row and the
cp1252 one:&lt;/p>
&lt;pre class="python">&lt;code>for path, enc in [(&amp;quot;bom.csv&amp;quot;, &amp;quot;utf-8&amp;quot;), (&amp;quot;bom.csv&amp;quot;, &amp;quot;utf-8-sig&amp;quot;), (&amp;quot;ansi.csv&amp;quot;, &amp;quot;cp1252&amp;quot;)]:
with open(path, newline=&amp;quot;&amp;quot;, encoding=enc) as f:
header, first = f.readline(), f.readline()
print(f&amp;quot;{path:9} {enc:10}&amp;quot;, repr(header.split(&amp;quot;,&amp;quot;)[0]), first.split(&amp;quot;,&amp;quot;)[0])
## bom.csv utf-8 &amp;#39;\ufeffname&amp;#39; Zoë O’Brien
## bom.csv utf-8-sig &amp;#39;name&amp;#39; Zoë O’Brien
## ansi.csv cp1252 &amp;#39;name&amp;#39; Zoë O’Brien&lt;/code>&lt;/pre>
&lt;/div>
&lt;div id="what-to-change" class="section level2">
&lt;h2>What to change&lt;/h2>
&lt;p>Name the encoding everywhere. &lt;code>open(path, encoding="utf-8")&lt;/code> and
&lt;code>subprocess.run(..., encoding="utf-8")&lt;/code> behave the same on every version and every machine.
Python 3.10 and later can flag each call that leaves it out, as the call runs:&lt;/p>
&lt;pre class="python">&lt;code>with open(&amp;quot;person_1.csv&amp;quot;) as f:
f.read()
with open(&amp;quot;person_1.csv&amp;quot;, encoding=&amp;quot;utf-8&amp;quot;) as f:
f.read()
## warn.py:1: EncodingWarning: &amp;#39;encoding&amp;#39; argument not specified
## with open(&amp;quot;person_1.csv&amp;quot;) as f:&lt;/code>&lt;/pre>
&lt;p>Only the first &lt;code>open()&lt;/code> is flagged. If you share files with someone still on 3.14, the switch
that gives 3.14 the new behavior has existed since 3.7. &lt;code>PYTHONUTF8=1&lt;/code> in the environment, or
&lt;code>-X utf8&lt;/code> on the command line:&lt;/p>
&lt;pre>&lt;code>## Nguyễn Thị Minh Khai
## Zoë O’Brien
## Łukasz Wójcik
## Müller
## 東京
## José Álvarez&lt;/code>&lt;/pre>
&lt;p>That is Python 3.14 reading the same six files with &lt;code>-X utf8&lt;/code>.&lt;/p>
&lt;/div>
&lt;div id="what-this-does-not-show" class="section level2">
&lt;h2>What this does not show&lt;/h2>
&lt;ul>
&lt;li>One Windows 11 machine with cp1252 as its ANSI code page. Machines set to Japanese, Chinese,
or other code pages fail in different ways, and I did not test them.&lt;/li>
&lt;li>I wrote the cp1252 and BOM files as bytes rather than saving them from Excel or Notepad.&lt;/li>
&lt;li>The large table counts single characters. A file raises if any one character raises, so long
Chinese or Korean text raises more often than its row suggests, and a short Western name list
raises less.&lt;/li>
&lt;/ul>
&lt;/div>
&lt;div id="what-to-do-on-monday" class="section level2">
&lt;h2>What to do on Monday&lt;/h2>
&lt;ol style="list-style-type: decimal">
&lt;li>Search for &lt;code>open(&lt;/code> without &lt;code>encoding=&lt;/code>, or run your tests once with
&lt;code>-X warn_default_encoding&lt;/code>.&lt;/li>
&lt;li>Pass &lt;code>encoding="utf-8"&lt;/code> to &lt;code>open()&lt;/code>, &lt;code>Path.read_text()&lt;/code>, and &lt;code>subprocess.run()&lt;/code>.&lt;/li>
&lt;li>Read files that may start with a BOM with &lt;code>encoding="utf-8-sig"&lt;/code>.&lt;/li>
&lt;li>Read old ANSI exports with &lt;code>encoding="cp1252"&lt;/code>, on purpose.&lt;/li>
&lt;li>Until everyone is on 3.15, set &lt;code>PYTHONUTF8=1&lt;/code> on the machines that are not.&lt;/li>
&lt;/ol>
&lt;p>R code on Windows needs no change for 3.15, because R got here first. Files from colleagues still
on 3.14 are the exception: read those with &lt;code>read.csv(fileEncoding = "cp1252")&lt;/code>. How CSV, Parquet, and
other formats carry text and types between tools is covered in the
&lt;a href="https://advanced-data-analysis.netlify.app/102-data-formats.html">data formats chapter of Advanced Data Analysis&lt;/a>.
The audit script, &lt;a href="encoding_audit.py">encoding_audit.py&lt;/a>, sits next to this post.&lt;/p>
&lt;/div></description></item></channel></rss>