u/Practical_Air6315

Get-Content -Encoding UTF8 fixed four of my log files and broke two others. I wrote the same string 13 ways to find out which is which.

I had two log files sitting in the same folder. One was written by my own script. One was written by a node process my script had launched. Get-Content read mine perfectly and returned garbage for node's. Adding -Encoding UTF8 fixed node's and broke mine.

So I wrote the same string with every writer I could think of, and read each file back both ways. The string is 12 characters of Japanese — it is the phrase a lot of tools print for "file not found", which is exactly the kind of line you cannot afford to lose.

Host: Windows 11, ja-JP, ACP=932, OEMCP=932, Windows PowerShell 5.1.26100.9168. [Console]::OutputEncoding = 932 (shift_jis), $OutputEncoding = 20127 (us-ascii).

writer                          first bytes    bare       -Enc UTF8
------------------------------- -------------- ---------- ----------
Out-File (default)              FF FE D5 30    OK         OK
Out-File -Encoding utf8         EF BB BF E3    OK         OK
Out-File -Encoding ascii        3F 3F 3F 3F    MOJIBAKE   MOJIBAKE
Set-Content (default)           83 74 83 40    OK         MOJIBAKE
Set-Content -Encoding UTF8      EF BB BF E3    OK         OK
Add-Content (default)           83 74 83 40    OK         MOJIBAKE
Tee-Object -FilePath            FF FE D5 30    OK         OK
> redirection                   FF FE D5 30    OK         OK
IO.File WriteAllText (UTF8)     E3 83 95 E3    MOJIBAKE   OK
IO.File WriteAllBytes (UTF8)    E3 83 95 E3    MOJIBAKE   OK
python via cmd.exe >            E3 83 95 E3    MOJIBAKE   OK
node via cmd.exe >              E3 83 95 E3    MOJIBAKE   OK
node captured by PS, Out-File   EF BB BF E7    MOJIBAKE   MOJIBAKE

Three groups.

1. BOM present, text intact — 5 rows. Both reads work. Get-Content sniffs FF FE or EF BB BF and uses it. The read parameter is irrelevant. Note that Out-File, Tee-Object and > all default to UTF-16LE here, which is why they are in this group by accident rather than by anyone's intent.

2. No BOM — 6 rows. Exactly one read is correct, and which one flips depending on the writer.

Set-Content and Add-Content without -Encoding write the machine ANSI code page — 83 74 is CP932, not UTF-8 — so the bare read is right and -Encoding UTF8 is wrong. Everything that put real UTF-8 on disk without a BOM is the exact reverse. With no BOM, Get-Content falls back to ANSI, and that fallback is correct precisely when the writer also used ANSI.

This is the part I did not expect: "just add -Encoding UTF8" is not a safe default. Across these 13 files it corrects 4 and corrupts 2. There is no single read parameter that is right for all of them. If you have a folder holding both your own logs and a build tool's logs, no one setting reads both.

3. Damage that happened before the file existed — 2 rows. No read parameter can fix these.

Out-File -Encoding ascii wrote 3F 3F 3F 3F, which is literally ????. The characters were destroyed at write time.

The last row is the one worth your time. I let PowerShell capture node's stdout into a variable and re-write it with Out-File -Encoding utf8:

node via cmd.exe >     36 bytes  12 chars  E3 83 95 E3 82 A1 E3 82 A4 E3 83 AB
                       U+30D5 U+30A1 U+30A4 U+30EB U+304C U+898B U+3064 U+304B

node captured by PS    65 bytes  20 chars  EF BB BF E7 B9 9D E8 BC 94 E3 81 83
                       U+7E5D U+8F14 U+3043 U+7E67 U+FF64 U+7E5D U+FF6B U+7E3A

What PowerShell actually wrote into that file, all 20 characters of it:

繝輔ぃ繧、繝ォ縺瑚ヲ九▽縺九j縺セ縺帙s

That second file carries a valid UTF-8 BOM and is well-formed UTF-8. It is also wrong. [Console]::OutputEncoding is 932 on this host, so PowerShell decoded node's UTF-8 bytes as CP932, got 20 different characters out of 12, and then faithfully encoded those as UTF-8 with a BOM. The file went from 36 bytes to 65. Nothing threw, nothing warned.

It is also the only row where the two reads agree with each other and are both wrong. Everywhere else, when one read returns garbage the other returns clean text, so there is a way to notice. Here there is no second opinion.

A BOM tells you how the file is encoded. It tells you nothing about whether the text in it is correct.

Minimal repro (numbers below are from the 932 host; on a Latin-1 ANSI code page the first pair behaves differently, because CP1252 cannot represent these characters at all):

$s = [char]0x30D5 + [char]0x30A1
$d = $env:TEMP

Set-Content -Path "$d\ansi.log" -Value $s
[IO.File]::WriteAllBytes("$d\utf8.log", [Text.Encoding]::UTF8.GetBytes($s))

(Get-Content "$d\ansi.log" -Raw).TrimEnd()                 -eq $s   # True
(Get-Content "$d\ansi.log" -Raw -Encoding UTF8).TrimEnd()  -eq $s   # False
(Get-Content "$d\utf8.log" -Raw).TrimEnd()                 -eq $s   # False
(Get-Content "$d\utf8.log" -Raw -Encoding UTF8).TrimEnd()  -eq $s   # True

Same cmdlet, same parameter, opposite answers, two files in one directory.

What I changed in my own scripts

  • Reading a log a native child process wrote (redirected by cmd.exe, so nothing decoded it on the way in): always pass -Encoding UTF8. That file holds the program's own bytes and will not have a BOM.
  • Reading a file PowerShell itself wrote: leave Get-Content bare. The BOM is there and handles it. Adding -Encoding UTF8 here is what broke rows 4 and 6.
  • Do not capture a native process's stdout into a variable when the output can be non-ASCII. Redirect it to a file and read the file. That decode is governed by [Console]::OutputEncoding, which was 932 here; I have not tested whether setting it to UTF-8 up front avoids the problem, so I am not claiming that it does.
  • Out-File -Encoding ascii on non-ASCII text is silent data loss, not a display issue.

Measured on one locale. If you are on a non-Latin ANSI code page I would be curious whether rows 4 and 6 come out the same for you — that is the pair that makes the usual advice backfire.

reddit.com
u/Practical_Air6315 — 1 day ago

Follow-up: I measured what a UTF-8 "replace" decode does to CP932 output. 0 of 9,206 characters survive, and 50 of them leave a backslash instead of U+FFFD.

A few days ago I posted here about BOM-less .ps1 files being read as ANSI on Windows PowerShell 5.1. A couple of you pushed back on the parser-test approach and pointed me at the raw-byte check instead, which was right. This is the other half of the same problem: not source files, but output - what happens to CP932 bytes coming back through a pipe.

It matters because agent tooling tends to do this:

subprocess.Popen(args, text=True, encoding="utf-8", errors="replace")

text=True decodes at the pipe level, so by the time anything sees a string the original bytes are gone. On a Japanese-locale box the child process emits CP932, not UTF-8.

Setup. A child writes a fixed 50-byte CP932 sequence to stderr. The parent reads the raw bytes and decodes them two ways. No language pack needed, so the input is identical everywhere. Windows PowerShell 5.1, ACP=932.

decode path chars U+FFFD stray backslash
UTF-8 with replacement 42 29 4
raw bytes then CP932 26 0 -

Three things fell out of it that I did not expect.

1. Not everything becomes U+FFFD. Some of it becomes a backslash.

CP932 trail bytes are 0x40-0x7E and 0x80-0xFC. 0x5C is in that range, and 0x5C is the backslash. A two-byte character whose second byte is 0x5C does not get replaced - it leaves a \ sitting in the string.

Sweeping the whole double-byte space, 50 characters have 0x5C as their trail byte. Four of them are in the 50-byte sample above: 8F5C 975C 8D5C 835C. Those are not obscure code points - they are characters that appear in ordinary words, so this fires constantly rather than occasionally.

That is why this failure so often gets filed as a path bug, a quoting bug, or a shell-escaping bug. The output does not look like an encoding failure. It looks like something ate a directory separator.

2. The whole double-byte space dies.

CP932 double-byte characters enumerated : 9,206
  survive a UTF-8 + replacement decode  : 0
  survive raw bytes + a CP932 decode    : 9,206

Measured per character in isolation. In a real stream a CP932 character followed by other bytes can occasionally form valid UTF-8, so this is not "every byte in every stream" - but as a per-character result it is 0.

3. The replacement output is not even stable across runtimes.

The identical 50 bytes:

.NET Framework 4.8  (Windows PowerShell 5.1)   29 U+FFFD
.NET 8              (PowerShell 7.4)           30 U+FFFD
CPython 3.11                                   30 U+FFFD

There is a known open issue about UTF-8 replacement differing between .NET Framework and .NET Core (dotnet/standard#1679). I am reporting the measurement, not claiming to know the mechanism.

The practical consequence is what changed my mind about errors="replace". It does not merely discard the original bytes - the wreckage it leaves is not consistent either. So you cannot reliably detect "this string was mangled" downstream by counting replacement characters.

Bonus: the tables disagree.

I assumed .NET on Windows would defer to the OS NLS tables and give a different count from .NET on Linux. It does not - .NET carries its own CP932 table and gives 9,206 on both. The split is Python vs .NET, not Windows vs Linux:

table double-byte chars trail byte 0x5C
CPython 3.11 cp932 9,604 52
.NET (Windows and Linux) 9,206 50

If you are fixing this on the Python side, Python's table is the more permissive of the two, which is convenient.

The fix is the boring one. Do not let text=True decode at the pipe. Collect raw bytes, then choose the decoder - UTF-8 strict first, fall back to the ANSI code page. errors="replace" should not be the only safety net, because it destroys bytes a fallback could have recovered.

Harness and raw output, MIT: https://github.com/yoggydev/cp932-pipe-probe

It runs in about two seconds and needs no install. The script source is ASCII-only on purpose - a script that measures mojibake should not be able to become a victim of it.

(Drafted with Claude. The measurements are mine, on my own ja-JP box.)

u/Practical_Air6315 — 2 days ago

BOM-less .ps1 in PS 5.1: I tested all 545 Japanese chars x 95 ASCII chars. The byte right after Japanese text disappears, but only if it is 0x40 or higher

This is a Japanese-Windows problem, but the mechanism applies to any DBCS code page.

Everyone knows PowerShell 5.1 reads a BOM-less .ps1 as ANSI (CP932 on a Japanese system), and that the fix is "save it with a UTF-8 BOM". What I did not know was what actually breaks. I always assumed the mojibake was the problem. It is not.

So I measured it: all 545 Japanese characters (hiragana, katakana, kanji, full-width symbols) x all 95 printable ASCII characters (0x20-0x7E). Write the pair as UTF-8, read it back as CP932, and check whether the trailing ASCII character survived.

Results:

  • 32 ASCII characters never disappeared
  • 63 ASCII characters did, 41.3-46.2% of the time
  • Every single one that disappeared was 0x40 or higher. Nothing below 0x40 was ever eaten.

The boundary is exactly 0x40, and the reason is the CP932 trail-byte range:

lead byte:  0x81-0x9F, 0xE0-0xFC
trail byte: 0x40-0x7E, 0x80-0xFC

A Japanese character in UTF-8 is 3 bytes. When its last byte gets misread as a lead byte, the next byte is swallowed as the trail byte - but only if that byte falls inside the trail-byte range, i.e. 0x40 or above.

Which is exactly why this is so hard to diagnose:

"   0x22   never eaten
'   0x27   never eaten
(   0x28   never eaten
;   0x3B   never eaten
\   0x5C   eaten 44.6%
{   0x7B   eaten 41.3%
}   0x7D   eaten 41.3%

Quotes always survive. Your strings still look correctly closed, so you never suspect the encoding. Instead you get "Missing closing '}'" pointing at a completely unrelated line, and you go fix braces that were never wrong.

It is worse for paths. PowerShell uses \ constantly. If a \ sitting right after a Japanese character disappears, the path silently becomes a different path. No error at all - it just looks somewhere else.

With a BOM, across the same 545 characters: 0 broken out of 545. Ran it twice, identical both times.

Practical takeaway: you do not need to memorise the table. Look at the byte value of the ASCII character sitting immediately after Japanese text. 0x40 or above means it can be swallowed.

[edit] The repo this originally pointed at is no longer public. The measurement harness now lives on its own, MIT: https://github.com/yoggydev/cp932-pipe-probe - it carries the same raw-byte BOM check in CI, which is how I ended up chasing this in the first place.

u/Practical_Air6315 — 6 days ago