Why text needs an encoding, what ASCII covers and where it stops, how Unicode assigns code points, and how code points, code units, bytes and user-perceived characters (grapheme clusters) differ.
Computers store numbers, so text needs two agreements: a character set that gives each character a number, and an encoding that turns those numbers into bytes and back. ASCII is both at once: 128 characters (0–127) stored in 7 bits, enough for English but not é, ā or 汉. Unicode is a character set only: it gives every character in every script a code point from U+0000 to U+10FFFF (U+0041 is A, U+00E9 is é). UTF-8, UTF-16 and UTF-32 are the encodings that store code points as code units of 8, 16 or 32 bits. What a user sees as one character, a grapheme cluster, can be several code points (an emoji with a skin-tone modifier, or e plus a combining accent), and the same text can be written in composed or decomposed form, so applications normalise (usually to NFC) before comparing or storing user input.
Memory and files hold bytes. To store text you need an agreed mapping between characters and numbers (a character set) and between those numbers and bytes (an encoding). The same bytes mean different text under different encodings, which is the root of most garbled-text bugs.
\n is 10, \r is 13, tab is 9); 32–126 are printable (A is 65, a is 97, 0 is 48).0xE9 is é in Windows-1252 but Θ in code page 437.U+XXXX, from U+0000 to U+10FFFF (about 1.1 million values) in 17 planes. Plane 0, the Basic Multilingual Plane (BMP), holds most common characters; emoji and many historic scripts are above U+FFFF.| Term | Meaning | Example: é |
Example: 👍🏽 |
|---|---|---|---|
| Grapheme cluster | What the user sees as one character | 1 | 1 |
| Code points | Unicode numbers | 1 (U+00E9) or 2 (e + U+0301) |
2 (U+1F44D + U+1F3FD) |
UTF-16 code units (char) |
16-bit units, what string.Length counts |
1 or 2 | 4 |
| UTF-8 bytes | Bytes on disk or the wire | 2 or 3 | 8 |
The same visible text can arrive in different forms: a Mac keyboard or file name may send e + combining accent, a Windows form sends precomposed é, and copy-paste from PDFs adds ligatures such as fi. Ordinal comparison treats these as different, so a user cannot log in, a unique index allows "duplicate" names, and a search misses rows. Normalise to NFC (text.Normalize(NormalizationForm.FormC)) at the input boundary, and use NFKC only where compatibility folding is wanted (for example usernames, where fi should equal fi).
| Store | What it holds | Watch out for |
|---|---|---|
SQL Server nvarchar |
UTF-16 | n counts code units; safe for all languages |
SQL Server varchar with a legacy collation |
a code page such as 1252 | non-Latin characters silently become ? |
SQL Server varchar with a _UTF8 collation |
UTF-8 | n counts bytes, so fewer CJK characters fit |
PostgreSQL, MySQL utf8mb4, SQLite |
UTF-8 | MySQL's old utf8 holds only 3-byte characters, so emoji fail |
If the client, connection and column disagree on encoding, characters are corrupted on the way in, and queries such as WHERE name = @name stop matching because the stored bytes differ from the parameter.
string is a sequence of UTF-16 code units; a char is one code unit, not one character.System.Text.Rune is one Unicode scalar value; StringInfo works with grapheme clusters.Encoding.UTF8, Encoding.Unicode (UTF-16LE), Encoding.ASCII and Encoding.Latin1 convert between strings and bytes.A customer import used Encoding.ASCII to read uploaded files "because they are CSVs". Every Māori macron (ā) and accented French name became ?, and the data was saved like that. The fix was reading as strict UTF-8, rejecting invalid files with a clear message, normalising names to NFC, and repairing the damaged records from the original uploads.
Checks whether the candidate understands text as data, which underpins file handling, APIs and databases.
Green flags: clear separation of character set and encoding, ASCII limits, code points versus code units versus graphemes, knows .NET strings are UTF-16, mentions normalisation and explicit encodings.
Red flags: "Unicode is 16-bit", "a char is a character", treats code pages as ASCII, no idea why text gets garbled.
what the user sees é 👍🏽
|
grapheme clusters [é] [👍🏽]
|
code points U+00E9 U+1F44D U+1F3FD
| encoding form
UTF-16 code units 00E9 D83D DC4D D83C DFFD
UTF-8 bytes C3 A9 F0 9F 91 8D F0 9F 8F BD
é can be precomposed (U+00E9) or decomposed (e U+0065 + combining acute U+0301). They look identical but compare unequal with ordinal comparison.
string.Normalize(NormalizationForm.FormC) converts. Normalise user input before comparing or using it as a key (user names, file names).| Code | Name | Where |
|---|---|---|
| 0 | NUL | C strings end with it; it truncates text in some native APIs |
| 9 | Tab | TSV files |
| 10 | LF | Line ending on Linux and macOS |
| 13 | CR | CRLF line endings on Windows and in HTTP |
| 27 | ESC | ANSI colour codes in terminals |
Line endings cause real bugs: a CRLF file read on Linux leaves \r at the end of each line, which breaks parsing and comparisons.
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance) once, then Encoding.GetEncoding(1252).0x80–0x9F (Windows-1252 has €, curly quotes and dashes there).string.Equals(a, b, StringComparison.Ordinal) compares code units: fast and predictable; use it for identifiers, keys and protocol values.ı problem with ToUpper).OrdinalIgnoreCase for case-insensitive identifiers, not ToLower() comparisons.а looks like Latin a; attackers register look-alike user names and domains. Normalise and restrict allowed scripts where identity matters.U+FFFD (�) by default; validate input at the boundary instead of silently accepting damage.../ past filters; modern .NET decoders reject them."é".Length for the precomposed and decomposed forms ("é"), then compare them with == and after Normalize()."👍🏽".Length, the number of runes (EnumerateRunes().Count()), and new StringInfo("👍🏽").LengthInTextElements."Café" with Encoding.ASCII, Encoding.Latin1 and Encoding.UTF8, print the bytes in hex, and decode each with the wrong encoding to see what breaks.namespace EncodingBasics;
using System.Globalization;
using System.Text;
public static class Program
{
public static void Main()
{
string composed = "é"; // é as one code point
string decomposed = "é"; // e + combining acute accent
Console.WriteLine($"{composed.Length} vs {decomposed.Length}"); // 1 vs 2
Console.WriteLine(composed == decomposed); // False
Console.WriteLine(composed == decomposed.Normalize(NormalizationForm.FormC)); // True
string thumbs = "\U0001F44D\U0001F3FD"; // thumbs up + medium skin tone
Console.WriteLine($"chars (UTF-16 code units): {thumbs.Length}"); // 4
Console.WriteLine($"runes (code points): {thumbs.EnumerateRunes().Count()}"); // 2
Console.WriteLine($"graphemes: {new StringInfo(thumbs).LengthInTextElements}"); // 1
foreach (var encoding in new[] { Encoding.ASCII, Encoding.Latin1, Encoding.UTF8 })
{
byte[] bytes = encoding.GetBytes("Café");
Console.WriteLine($"{encoding.WebName,-12} {Convert.ToHexString(bytes)}");
}
// us-ascii 4361663F (é lost, replaced by '?')
// iso-8859-1 436166E9
// utf-8 436166C3A9
}
}
Encoding.ASCII for user data. Why it is wrong: ASCII can only represent code points 0–127. .NET's Encoding.ASCII uses a replacement fallback by default, so é, ā or 汉 silently become ? with no exception, and the original text is lost for good. Instead: Use UTF-8. Where a legacy partner truly needs ASCII, create the encoding with EncoderFallback.ExceptionFallback so unsupported characters fail loudly, and decide explicitly how to transliterate.new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) at the boundary and reject or quarantine bad files.char is one character. Why it is wrong: Emoji, many CJK extension characters and combining sequences take several chars, so length checks and substrings cut characters in half. Instead: Use Rune for code points and StringInfo for user-perceived characters.é (U+00E9) and e + U+0301 look identical but are different code points, which breaks log-ins, unique constraints in databases and file lookups (macOS file names historically used decomposed forms). Instead: Normalise to NFC at the boundary (Normalize(NormalizationForm.FormC)) and compare with an explicit StringComparison.Encoding.Default code page, mainframe EBCDIC exports). Instead: Pass the encoding explicitly whenever you read or write bytes, and register CodePagesEncodingProvider when you must read a legacy code page.Encoding bugs are boundary bugs: they appear wherever text crosses a file, network, database or process boundary and someone assumed the encoding.
| Boundary | Senior default |
|---|---|
| Files and queues | UTF-8 without BOM, stated in the contract; strict decoding on input |
| HTTP | Content-Type: ...; charset=utf-8 on every text response |
| SQL Server | nvarchar (UTF-16) or a UTF-8 collation with varchar; never a legacy code page for user text |
| Identity (user names, emails, file names) | normalise to NFC, compare ordinally, consider restricting scripts to stop homoglyph spoofing |
| Legacy partners | named code page, documented, converted at one adapter |
Seniors validate on input instead of repairing later, because once ? or � is stored the original text cannot be recovered. They also know that user-visible length (grapheme clusters), storage length (bytes) and string.Length (UTF-16 code units) are three different numbers, and pick the right one for each limit.
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.
Last updated 23 Oct 2026.