Practical encoding work in .NET 10: Encoding APIs and defaults, BOMs, invalid-byte handling, Windows-1252 via CodePagesEncodingProvider, UTF-8 literals and the Utf8 class, Rune and StringInfo for user-visible characters, and normalisation for comparisons.
In memory a .NET string is UTF-16; an Encoding converts it to and from bytes at the boundaries (files, HTTP, databases). I default to UTF-8 without a BOM (new UTF8Encoding(false)), and I use throwOnInvalidBytes: true when bad input should fail rather than silently become �. Legacy formats like Windows-1252 need CodePagesEncodingProvider registered. For speed there are "OK"u8 literals and the Utf8 class for span transcoding. For text correctness, Rune handles code points beyond the BMP, StringInfo counts grapheme clusters (what users see), and I normalise to NFC before comparing or storing user input.
Inside .NET, a string is UTF-16. An encoding is how you convert that text to and from bytes for files, network, databases and APIs. The senior topics are: always being explicit about the encoding, the BOM, legacy code pages, efficient UTF-8 in modern .NET (u8 literals, Utf8 APIs), and the difference between char, code points (Rune) and user-perceived characters (grapheme clusters), plus normalisation.
Encoding classbyte[] bytes = Encoding.UTF8.GetBytes("Café"); // 5 bytes: 43 61 66 C3 A9
string text = Encoding.UTF8.GetString(bytes);
int count = Encoding.UTF8.GetByteCount("Café");
| Encoding | Bytes per character | Notes |
|---|---|---|
Encoding.UTF8 |
1–4 | Web, JSON, Linux default. Encoding.UTF8 emits a BOM when used with GetPreamble/writers |
new UTF8Encoding(false) |
1–4 | UTF-8 without BOM — what File.WriteAllText and StreamWriter use by default |
Encoding.Unicode |
2 or 4 | UTF-16 little-endian (Windows APIs, .NET strings) |
Encoding.UTF32 |
4 | Rare |
Encoding.ASCII |
1 | 7-bit; non-ASCII characters become ? silently |
Encoding.Latin1 |
1 | ISO-8859-1 (.NET 5+) |
Encoding.GetEncoding(1252) |
1 | Legacy Windows code pages — requires registering a provider on .NET Core |
Many APIs accept an Encoding: StreamReader/StreamWriter, File.ReadAllText, HttpContent, Convert. Always pass one explicitly for data crossing a boundary.
The byte order mark is EF BB BF at the start of a UTF-8 file. It is not needed for UTF-8 (there is no byte order) but signals "this is UTF-8".
Café.StreamReader detects BOMs by default (detectEncodingFromByteOrderMarks: true) and strips them.So for an Australian business user's CSV export that opens in Excel: write UTF-8 with BOM (new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); for APIs and files consumed by programs: without.
.NET Core includes only UTF-*, ASCII and Latin1 by default. For Windows-1252, Shift-JIS, GB2312 and similar (common when integrating with old ERP exports or banking files):
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var win1252 = Encoding.GetEncoding(1252);
Always get the encoding from the file specification of the partner — encodings cannot be reliably auto-detected from bytes.
u8 literals (C# 11): ReadOnlySpan<byte> header = "Content-Type"u8; — bytes baked into the binary, no encoding at run time. Used heavily with Utf8JsonWriter, Kestrel and protocol code.System.Text.Unicode.Utf8: Utf8.FromUtf16/ToUtf16 transcode between spans with status results instead of exceptions.IUtf8SpanFormattable (.NET 8): int, DateTime, Guid etc. can format directly to UTF-8 bytes (value.TryFormat(utf8Span, out written)), and Utf8.TryWrite(span, $"...") supports interpolation into UTF-8.System.Text.Json works natively in UTF-8 — deserialising from a Stream or ReadOnlySpan<byte> avoids ever creating a UTF-16 string.char, Rune and grapheme clusterschar is one UTF-16 code unit. Characters outside the Basic Multilingual Plane (emoji like 😀, some CJK) need two chars (a surrogate pair). "😀".Length == 2.Rune (System.Text.Rune, .NET Core 3.0) is one Unicode scalar value (code point). s.EnumerateRunes() iterates them correctly."é" (e + combining accent) is 2 code points; 👨👩👧 (family) is 5 code points joined by zero-width joiners; flags are 2 regional-indicator code points. Use StringInfo.GetTextElementEnumerator / StringInfo.LengthInTextElements (updated to Unicode extended grapheme clusters in .NET 5).So "length" depends on what you mean: storage (Length), code points (runes), or visible characters (text elements). Truncating with Substring(0, 50) can split a surrogate pair or an accent and produce invalid or garbled text.
The same visible text can have multiple encodings: é as U+00E9 (precomposed, NFC) or e + U+0301 (decomposed, NFD). macOS file systems historically used NFD; most of the web uses NFC.
string nfc = input.Normalize(NormalizationForm.FormC);
bool same = a.Normalize() == b.Normalize();
Normalise user-entered identifiers (usernames, tags, search keys) to NFC (or NFKC, which also folds compatibility characters like full-width letters and ligatures) before storing and comparing.
A payroll export opened in Excel showed names like Zoë and Ngô. The file was UTF-8 without BOM, so Excel read it as Windows-1252. Adding a BOM fixed Excel but broke an automated import at the bank, which rejected the first bytes. The solution was two outputs: UTF-8 with BOM for the human download, and the bank's specified encoding (UTF-8 without BOM, validated against their spec) for the integration file — plus a test that checks the first three bytes of each output. A second issue in the same system: names truncated to 30 characters with Substring split surrogate pairs in emoji-containing nicknames, producing invalid UTF-8 that SQL Server rejected; truncating by text elements fixed it.
The interviewer checks whether the candidate can prevent and fix encoding bugs with the right .NET APIs.
Green flags
Rune, StringInfo and normalisation.u8 literals or span transcoding for performance.Red flags
string.Length equals the number of visible characters.| Code point range | Bytes | Bit pattern |
|---|---|---|
| U+0000–U+007F | 1 | 0xxxxxxx (ASCII) |
| U+0080–U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800–U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000–U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Properties that matter:
10, so you can find character boundaries from any position.�) by default; use new UTF8Encoding(false, throwOnInvalidBytes: true) when you must reject corrupt input..NET strings are sequences of 16-bit code units. Code points above U+FFFF are split into a high surrogate (U+D800–U+DBFF) and a low surrogate (U+DC00–U+DFFF). A lone surrogate is invalid Unicode but can exist in a .NET string (strings are not validated). Encoding such a string to UTF-8 replaces the lone surrogate with U+FFFD, silently changing data. char.IsSurrogate, char.IsSurrogatePair and Rune.TryCreate help validate.
Encoding.GetString(bytes) assumes the bytes contain whole characters. When reading a stream in chunks, a multi-byte character can be split across chunk boundaries. Decoder (from encoding.GetDecoder()) keeps state between calls, which is what StreamReader uses internally. Writing your own chunked reader with GetString per buffer corrupts characters at boundaries — a classic bug in socket code.
System.IO.Pipelines + Utf8JsonReader or SequenceReader<byte> handle UTF-8 across segment boundaries without decoding to UTF-16.
Modern .NET transcodes UTF-8 ↔ UTF-16 with SIMD (AVX2/AVX-512, Arm64 AdvSimd), often at several GB/s for ASCII-heavy text. Still, avoiding transcoding entirely is faster:
System.Text.Json reads and writes UTF-8 directly.u8 literals avoid runtime encoding of constants.Ascii class (.NET 8) offers fast ASCII-only validation and case-insensitive comparison on byte spans.string s = "Ngô 😀 é";
Console.WriteLine(s.Length); // UTF-16 code units
Console.WriteLine(s.EnumerateRunes().Count()); // code points
Console.WriteLine(new StringInfo(s).LengthInTextElements); // graphemes
Safe truncation by user-perceived characters:
static string TruncateGraphemes(string s, int max)
{
var e = StringInfo.GetTextElementEnumerator(s);
int count = 0, end = 0;
while (count < max && e.MoveNext()) { end = e.ElementIndex + ((string)e.Current).Length; count++; }
return s[..end];
}
(.NET 5+ also offers StringInfo.GetNextTextElementLength(ReadOnlySpan<char>) for allocation-free iteration over grapheme clusters.)
For database limits measured in bytes (e.g. a varchar UTF-8 column with a byte limit), truncate by encoded byte count while respecting boundaries.
| Form | Decomposition | Composition | Use |
|---|---|---|---|
| NFC | Canonical | Yes | Default for storage and comparison |
| NFD | Canonical | No | Some file systems, linguistic processing |
| NFKC | Compatibility | Yes | Identifiers, search keys (folds full-width letters and ligatures) |
| NFKD | Compatibility | No | Search indexing |
string.IsNormalized() is cheap for ASCII and avoids allocation when already normalised. Note: normalisation of non-ASCII text depends on ICU data, so in globalisation-invariant mode it is limited — test it in your production container image.
SQL Server nvarchar stores UTF-16; varchar with a _UTF8 collation (SQL Server 2019+) stores UTF-8. Length limits are in byte-pairs for nvarchar(n) and bytes for UTF-8 varchar(n), not characters — emoji take 2 units in nvarchar and 4 bytes in UTF-8. EF Core's HasMaxLength maps to these units, so validation by string.Length can still overflow a UTF-8 varchar column.
"Café, Ngô" to a CSV with and without BOM and open both in Excel.Length, rune count and text-element count for strings with emoji, flags and combining accents.é using GetString per chunk vs a Decoder."é" == "é" before and after Normalize().using System.Globalization;
using System.Text;
namespace Strings;
public static class Encodings
{
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
private static readonly UTF8Encoding Utf8Strict = new(false, throwOnInvalidBytes: true);
public static void Main()
{
// 1. BOM or not
Console.WriteLine(Encoding.UTF8.GetPreamble().Length); // 3 (EF BB BF)
Console.WriteLine(Utf8NoBom.GetPreamble().Length); // 0
// 2. Strict decoding
byte[] latin1Bytes = [0x4A, 0x6F, 0x73, 0xE9]; // "José" in Windows-1252
Console.WriteLine(Encoding.UTF8.GetString(latin1Bytes)); // Jos� (silent replacement)
try { Utf8Strict.GetString(latin1Bytes); }
catch (DecoderFallbackException) { Console.WriteLine("Invalid UTF-8 detected"); }
// 3. Legacy code page
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var win1252 = Encoding.GetEncoding(1252);
Console.WriteLine(win1252.GetString(latin1Bytes)); // José
// 4. UTF-8 literal: bytes at compile time
ReadOnlySpan<byte> contentType = "application/json"u8;
Console.WriteLine(contentType.Length); // 16
// 5. Characters users see
string thumbs = "\U0001F44D\U0001F3FD"; // thumbs up, medium skin tone
Console.WriteLine(thumbs.Length); // 4 UTF-16 code units
Console.WriteLine(thumbs.EnumerateRunes().Count()); // 2 code points
Console.WriteLine(new StringInfo(thumbs).LengthInTextElements); // 1 grapheme
// 6. Normalise before comparing
string composed = "café", decomposed = "café";
Console.WriteLine(composed == decomposed); // False
Console.WriteLine(composed.Normalize() == decomposed.Normalize()); // True (NFC)
}
// CSV for Excel users: deliberately write a BOM
public static void WriteExcelCsv(string path, IEnumerable<string> lines)
=> File.WriteAllLines(path, lines, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
}
What happens: new StreamReader(stream) or File.ReadAllText(path) used for partner files with unknown or legacy encodings.
Why it is wrong: defaults assume UTF-8; Windows-1252 files produce � replacement characters and corrupted names.
Do instead: pass the encoding documented by the partner; register CodePagesEncodingProvider for code pages.
What happens: JSON or API payloads written with Encoding.UTF8 (BOM), or Excel CSVs written without a BOM.
Why it is wrong: parsers reject the BOM; Excel misreads BOM-less UTF-8 as ANSI.
Do instead: use new UTF8Encoding(false) for machine formats and new UTF8Encoding(true) for Excel-targeted CSV.
Encoding.ASCII for user textWhat happens: names or addresses encoded with ASCII.
Why it is wrong: every non-ASCII character becomes ? silently — permanent data loss.
Do instead: use UTF-8; if a partner requires ASCII, transliterate deliberately and validate.
SubstringWhat happens: name.Substring(0, 50) to fit a column or SMS limit.
Why it is wrong: can split surrogate pairs or combining sequences, creating invalid UTF-16 or visually broken text.
Do instead: truncate by text elements (or runes) and, for byte limits, by encoded size.
Length is the number of charactersWhat happens: validation rules like "max 20 characters" use string.Length.
Why it is wrong: emoji and some scripts count as 2+ code units; combining marks count separately.
Do instead: define the unit (graphemes for UI rules, bytes for storage) and measure accordingly.
What happens: socket or blob reader calls Encoding.UTF8.GetString(buffer, 0, read) per chunk.
Why it is wrong: multi-byte characters split across chunks become �.
Do instead: use StreamReader, a stateful Decoder, or System.IO.Pipelines with UTF-8-aware readers.
What happens: "José" typed on different devices is stored twice.
Why it is wrong: NFC and NFD forms differ under ordinal comparison.
Do instead: normalise to NFC (or NFKC for identifiers) at input.
What happens: corrupted input is silently decoded with replacement characters and saved.
Why it is wrong: data corruption spreads without anyone noticing.
Do instead: use throwOnInvalidBytes: true for strict inputs, or detect � and reject or quarantine.
What happens: building a string then calling Encoding.UTF8.GetBytes to write to a stream in hot paths.
Why it is wrong: extra allocations and transcoding.
Do instead: write UTF-8 directly (Utf8JsonWriter, u8 literals, IUtf8SpanFormattable, StreamWriter over the stream).
What happens: heuristics guess encoding from bytes. Why it is wrong: many byte sequences are valid in several encodings; guesses fail on short or unusual data. Do instead: agree on encodings in specifications and validate on import.
Seniors keep text correct by owning the boundaries: explicit encodings, strict decoding where data matters, and Unicode-aware processing.
| Boundary | Decision |
|---|---|
| HTTP APIs | UTF-8, no BOM, charset in Content-Type |
| Files for systems | documented encoding, strict decoding |
| Files for Excel users | UTF-8 with BOM |
| Legacy partners (banks, government) | code pages via provider, exception fallback |
| Database | nvarchar or UTF-8 collations (_UTF8) in SQL Server 2019+ |
| User identifiers | NFC normalisation + ordinal comparison |
José, Müller and 王小明.u8 literals, Utf8JsonWriter, pipelines) to avoid UTF-16 round trips.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.
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.
How to tell it (STAR)
**S:** After our payroll migration [at company], the bank rejected payment files for employees with accented names. **T:** I had to get payments through before the cut-off and fix it properly. **A:** I compared byte dumps, found UTF-8 multi-byte names breaking fixed record lengths, registered the code-pages provider to write Windows-1252 with a throwing fallback, and added input validation and golden-file tests. **R:** The files were accepted the same day and there have been no rejections since.
Last updated 30 Oct 2026.