A practical debugging walkthrough for mojibake and replacement characters: reading the bytes in hex, identifying UTF-8 versus Windows-1252 and double encoding, the BOM and Excel, and fixing both export and import paths in .NET.
First I look at the bytes, not the screen. José is classic mojibake: the file contains UTF-8 (é is C3 A9) but the reader decoded it as Windows-1252. With Excel on Windows this usually happens when a user double-clicks a CSV that has no BOM: Excel then uses the system's legacy (ANSI) code page, which depends on the Windows locale (1252 for English, 1258 for Vietnamese, and UTF-8 only if the "Use Unicode UTF-8 for worldwide language support" option is turned on). Importing through Data → From Text/CSV lets the user pick UTF-8, but most users double-click. The reliable fix for the export is to write UTF-8 with a BOM (new UTF8Encoding(true)) or produce .xlsx. Jos� is the opposite: a Windows-1252 file (é is the single byte E9) read as UTF-8, which is invalid, so the decoder substitutes U+FFFD. The fix is to read with the right encoding or reject invalid UTF-8 with a clear error. Then I check whether bad data was already saved and repair it separately.
Screens lie; bytes don't. Take a failing file (or the HTTP response, or the database value) and hex-dump the bad region.
| You see | Bytes for "é" | Meaning |
|---|---|---|
é correct |
C3 A9 read as UTF-8 |
Fine |
é |
C3 A9 read as Windows-1252 |
UTF-8 data, wrong decoder (mojibake) |
� or ? |
E9 read as UTF-8 |
Windows-1252 data, wrong decoder (invalid UTF-8) |
é |
C3 83 C2 A9 |
Double encoding: mojibake was saved and re-encoded as UTF-8 |
? in the database |
3F |
Lost for good: written through Encoding.ASCII or a non-Unicode column |
Walk the path: source system → file or API → our reader → our model → database → our writer → consumer (Excel, browser, partner). Check the encoding at each hop. The bug is at the hop where the bytes stop matching what the next reader expects.
File.WriteAllText, StreamWriter).C3 A9 shows as é.new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), or, from an API, prepend Encoding.UTF8.Preamble to the stream. The HTTP charset=utf-8 header doesn't help, because Excel only sees the saved file..xlsx (no encoding ambiguity), or tell users to use Data → From Text/CSV and pick UTF-8.�, silently.Encoding.RegisterProvider(CodePagesEncodingProvider.Instance) then Encoding.GetEncoding(1252).Utf8.IsValid or decode with a throwing decoder (new UTF8Encoding(false, throwOnInvalidBytes: true)), and reject the file with a clear message.? or � substitutions are lossy and can't be reversed; recover from the original source files.A payroll export for a Sydney client showed Nguyá»…n instead of Nguyễn for hundreds of staff. The CSV was correct UTF-8; Excel was the reader. Adding a BOM fixed new exports. Separately, an older import path had saved some names double-encoded, which a targeted repair script fixed after comparing against the HR system.
Checks practical debugging skill on a common, user-visible problem.
Green flags: starts with the bytes, recognises mojibake versus replacement characters on sight, knows the Excel BOM behaviour, handles legacy code pages correctly, separates the fix from data repair, adds tests.
Red flags: "just use UTF-8 everywhere" without diagnosing; repairs data blindly; doesn't know defaults differ between tools.
decoder
UTF-8 Windows-1252
bytes +----------------+-----------------+
UTF-8 | correct é | mojibake é |
(C3 A9) | | |
+----------------+-----------------+
1252 | invalid � | correct é |
(E9) | | |
+----------------+-----------------+
Two symptoms, two opposite causes. Recognising the pattern on sight saves hours.
"é" --UTF-8--> C3 A9 --read as 1252--> "é" --saved as UTF-8--> C3 83 C2 A9
Each round trip makes the text longer and stranger. Seeing àpatterns means the data went through the wrong decoder at least twice.
| Where | Tool |
|---|---|
| File, Linux or macOS | xxd file.csv \| head, file -i file.csv |
| File, Windows | Format-Hex file.csv in PowerShell |
| File, editor | VS Code shows the encoding in the status bar and can "Reopen with Encoding" |
| .NET | Convert.ToHexString(Encoding.UTF8.GetBytes(value)) in a debugger watch |
| SQL Server | SELECT CONVERT(varbinary(100), Name) shows UTF-16LE for NVARCHAR |
| HTTP | Browser dev tools or Fiddler: check Content-Type charset and the raw body |
| API | Default |
|---|---|
File.ReadAllText(path) |
Detects a BOM, otherwise UTF-8 |
File.WriteAllText(path, text) |
UTF-8 without a BOM |
new StreamWriter(path) |
UTF-8 without a BOM |
Encoding.UTF8 used to write |
Emits a BOM with StreamWriter and File.WriteAllText |
Console.Out on Windows |
The console code page unless you set Console.OutputEncoding |
| ASP.NET Core text responses | UTF-8 |
VARCHAR with a non-UTF-8 collation stores single-byte code page data; Unicode characters outside that code page become ? on insert. Use NVARCHAR (or a _UTF8 collation on SQL Server 2019+).SqlClient send string as NVARCHAR by default; mapping a property to varchar (IsUnicode(false)) is a deliberate choice that loses characters outside the code page.?).José, Nguyễn, Māori, 王芳 and an emoji.José to a CSV with File.WriteAllText and open it in Excel by double-clicking. Then write it with new UTF8Encoding(true) and open it again.File.ReadAllText, and print the hex of the result. Then read it correctly with Encoding.GetEncoding(1252).namespace EncodingFixes;
using System.Text;
using System.Text.Unicode;
public static class CsvEncoding
{
// Excel-friendly export: UTF-8 with a byte order mark.
public static async Task WriteForExcelAsync(string path, IEnumerable<string[]> rows, CancellationToken ct)
{
await using var writer = new StreamWriter(path, append: false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
foreach (var row in rows)
{
await writer.WriteLineAsync(string.Join(',', row.Select(Escape)).AsMemory(), ct);
}
}
// Strict import: reject anything that isn't valid UTF-8 instead of silently inserting U+FFFD.
public static string ReadUtf8Strict(byte[] content)
{
if (!Utf8.IsValid(content))
{
throw new InvalidDataException("File is not valid UTF-8. Ask the sender to export as UTF-8.");
}
var strict = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
ReadOnlySpan<byte> span = content;
if (span.StartsWith(Encoding.UTF8.Preamble))
{
span = span[Encoding.UTF8.Preamble.Length..];
}
return strict.GetString(span);
}
// Legacy partner that still sends Windows-1252.
public static string ReadWindows1252(byte[] content)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(1252).GetString(content);
}
// Reverses one layer of UTF-8-read-as-1252 mojibake. Only run on rows proven to be damaged.
public static string RepairMojibake(string damaged)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
byte[] originalBytes = Encoding.GetEncoding(1252).GetBytes(damaged);
return Encoding.UTF8.GetString(originalBytes);
}
private static string Escape(string field) =>
field.IndexOfAny([',', '"', '\n', '\r']) >= 0 ? $"\"{field.Replace("\"", "\"\"")}\"" : field;
}
# Inspect the first bytes of a file
xxd export.csv | head -n 3
# 00000000: efbb bf4e 616d 652c ... <- EF BB BF is the UTF-8 BOM
Mistake: changing encodings in code until the screen looks right. Trial and error often fixes one viewer while breaking another, and can double-encode stored data. Instead: hex-dump the bytes, identify the pattern, and fix the specific boundary that's wrong.
Mistake: having no encoding standard across systems. When each service, file feed and team picks its own default, every new integration is a fresh chance for mojibake, and nobody knows which side is wrong. Instead: make UTF-8 the documented standard for files, APIs and queues, list any legacy exceptions in the interface specification, and validate at ingestion.
Mistake: setting charset=utf-8 on the HTTP response and expecting Excel to honour it.
The header is lost once the file is saved; Excel only sees the bytes.
Instead: add a UTF-8 BOM for CSVs meant for Excel, or produce .xlsx.
Mistake: decoding untrusted files with the default UTF-8 decoder and carrying on.
Invalid bytes silently become �, and damaged names are stored permanently.
Instead: validate with Utf8.IsValid or a throwing decoder, and reject or route the file for handling.
Mistake: running a mojibake repair over the whole table. On correct rows it turns valid text into garbage, which is worse than the original bug. Instead: identify damaged rows precisely (pattern match and source comparison), back up, repair those rows only, and verify.
Mistake: leaving the database layer's encoding implicit.
A VARCHAR column with a legacy collation, or an EF Core property mapped with IsUnicode(false), turns characters outside the code page into ? on insert, and the information is gone.
Instead: use NVARCHAR (or a _UTF8 collation) for human text, and review column types and IsUnicode mappings as part of the schema design.
Mistake: testing only with English data. Encoding bugs stay hidden until a real customer with an accented or non-Latin name appears. Instead: keep a standard set of test names with accents, macrons, CJK and emoji in every import and export test.
This is a diagnosis question disguised as a trivia question. Seniors follow evidence (bytes at each hop) instead of guessing, distinguish reversible damage (mojibake) from irreversible loss (?, �), and treat repairing stored data as a separate, risk-managed change. They also fix the process: interface contracts that state UTF-8, strict decoding at ingestion, and test data that reflects real customers. In Australia that matters for privacy too: a corrupted name is inaccurate personal information under APP 10.
Both encode Unicode code points. UTF-8 uses 1–4 bytes per code point and is ASCII-compatible, so it is compact for English (half the memory of UTF-16) and dominates files, HTTP and JSON. UTF-16 uses 2 bytes for the Basic Multilingual Plane and 4 bytes (a surrogate pair) above U+FFFF, such as most emoji; it is more compact than UTF-8 for CJK text (2 bytes instead of 3). .NET strings are UTF-16, and string.Length counts UTF-16 code units, not characters: "👍".Length is 2, "👍🏽".Length is 4, and "é" is 1 if precomposed (U+00E9) but 2 if written as e + combining accent. Count what you mean: EnumerateRunes() for code points, StringInfo for user-visible characters, Encoding.UTF8.GetByteCount for byte limits. On high-traffic paths, stay in UTF-8 bytes (u8 literals, spans, Utf8JsonReader) so you do not transcode and allocate on every request.
Several real bugs. Encoding.ASCII turns José into Jos?, so names are corrupted. The field limit is 32 bytes, but the code truncates at 32 chars; once you fix the encoding to UTF-8, a 32-char name can be up to 96 bytes and overflows the buffer. Substring can also split a surrogate pair. BitConverter writes the machine's little-endian order, but the partner specification says big-endian, so every ID and length is wrong. I'd rewrite it with BinaryPrimitives, UTF-8 truncated on grapheme boundaries to 32 bytes, input validation, and golden-byte tests from the partner's specification.
How to tell it (STAR)
**Situation:** At [company], a major payroll client saw garbled Vietnamese names in our CSV export [two days] before a pay run. **Task:** I had to fix it quickly without damaging stored data. **Action:** I hex-dumped the file and saw valid UTF-8 without a BOM, so the data was right and Excel was decoding it as Windows-1252. I added a BOM to the export. While checking the database bytes I also found [41] rows double-encoded by an older import, which I repaired with a targeted, backed-up script verified against the HR system. **Result:** The client ran payroll on time, and I added multilingual test data and strict UTF-8 validation so the same class of bug was caught in CI.
Last updated 23 Oct 2026.