How UTF-8 and UTF-16 encode code points, surrogate pairs, why .NET strings are UTF-16 internally, when UTF-8 is used, and how to count, slice and limit text correctly with Rune and StringInfo.
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.
| UTF-8 | UTF-16 | |
|---|---|---|
| Code unit | 8 bits | 16 bits |
| Bytes per code point | 1 (ASCII), 2, 3 or 4 | 2 (BMP) or 4 (surrogate pair) |
| ASCII-compatible | Yes | No (every ASCII char takes 2 bytes, half are zero) |
| Byte order issue | None | Yes: UTF-16LE vs UTF-16BE, usually marked by a BOM |
| Typical use | Files, web, JSON, Linux, databases with UTF-8 collations | .NET, Java and JavaScript strings, Windows APIs, SQL Server NVARCHAR |
Size examples: A is 1 byte in UTF-8 and 2 in UTF-16; Γ© 2 and 2; ζ± 3 and 2; π 4 and 4.
UTF-16 can directly hold only 65,536 values. Code points from U+10000 to U+10FFFF are split into a high surrogate (D800βDBFF) and a low surrogate (DC00βDFFF). A lone surrogate is invalid text.
string.Length returns the number of char values, which are UTF-16 code units:
"Γ©" can be 1 or 2 (precomposed or combining accent)."π" is 2 (surrogate pair)."π¨βπ©βπ§" (family emoji) is 8: five code points joined by zero-width joiners.That breaks:
s[..20] can cut a surrogate pair in half, leaving invalid UTF-16 that turns into οΏ½ when encoded.chars breaks emoji and accents.| Need | API |
|---|---|
| Count or iterate code points | string.EnumerateRunes(), Rune |
| Count or slice user-visible characters | StringInfo, StringInfo.GetNextTextElementLength |
| Bytes needed for storage or the wire | Encoding.UTF8.GetByteCount(s) |
| Fast UTF-8 without strings | "text"u8, ReadOnlySpan<byte>, System.Text.Unicode.Utf8 |
| Validate UTF-8 input | Utf8.IsValid(bytes) (.NET 8+) |
Since .NET 5, StringInfo follows Unicode's extended grapheme cluster rules, so emoji sequences count as one text element.
A notification service truncated message previews with text.Substring(0, 100). Messages ending with an emoji at position 100 were cut mid-surrogate; the push provider rejected the payload as invalid UTF-8 after encoding produced a replacement character, and some users stopped receiving notifications. The fix truncated at grapheme boundaries with StringInfo and checked the UTF-8 byte budget the provider actually enforced.
Checks a precise understanding of Unicode encodings and their practical consequences in .NET.
Green flags: explains variable width in both encodings, surrogate pairs, why string.Length is code units, uses Rune and StringInfo, distinguishes byte limits from character limits, mentions database column semantics.
Red flags: "UTF-16 is two bytes per character", truncates with Substring without concern, unaware that emoji are surrogate pairs.
code point range bytes bit pattern
U+0000 - U+007F 1 0xxxxxxx
U+0080 - U+07FF 2 110xxxxx 10xxxxxx
U+0800 - U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 - U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
Worked example, Γ© = U+00E9 = 000 1110 1001:
110 00011 10 101001
C 3 A 9 -> C3 A9
Properties that make UTF-8 robust: lead bytes and continuation bytes (10xxxxxx) never overlap, so a decoder can resynchronise after an error, and plain ASCII bytes never appear inside a multi-byte sequence (so searching for / or " in UTF-8 bytes is safe).
code point U+1F600 (π)
subtract 0x10000 -> 0x0F600 (20 bits)
high 10 bits 0x003D + 0xD800 -> 0xD83D
low 10 bits 0x0200 + 0xDC00 -> 0xDE00
UTF-16: D83D DE00
| Encoding | BOM bytes |
|---|---|
| UTF-8 | EF BB BF (optional, discouraged on the web, needed by Excel for CSV) |
| UTF-16LE | FF FE |
| UTF-16BE | FE FF |
Encoding.UTF8 emits a BOM when used with StreamWriter or File.WriteAllText(path, text, Encoding.UTF8). new UTF8Encoding(false) and the default for File.WriteAllText(path, text) don't. StreamReader detects a BOM by default.
| Limit | Count with | Example |
|---|---|---|
| What a user sees ("max 50 characters") | Grapheme clusters (StringInfo) |
Display names, tweet-like limits |
| What a store or protocol accepts ("max 255 bytes") | Encoding.UTF8.GetByteCount |
Kafka headers, HTTP headers, file names on Linux (255 bytes) |
What SQL Server NVARCHAR(n) accepts |
UTF-16 code units (string.Length) |
NVARCHAR(50) holds 50 code units, so 25 emoji |
SQL Server VARCHAR(n) with a UTF-8 collation (SQL Server 2019+) counts bytes, so VARCHAR(50) might hold only 12 CJK characters.
char and every string API.Utf8JsonReader and Utf8JsonWriter, Kestrel's request parsing, u8 literals, IUtf8SpanFormattable (.NET 8+). This avoids transcoding costs on hot paths.Encoding.UTF8.GetBytes(string, Span<byte>) with a pooled buffer in hot code.Encoding.Unicode) for A, Γ©, ζ± and π and explain each length."Hello ππ½ world", cut it with Substring(0, 7), encode it to UTF-8, and look for EF BF BD (the replacement character).TruncateGraphemes(string text, int max) using StringInfo.GetNextTextElementLength and test it with the family emoji.NVARCHAR(5) and insert five emoji. Observe the truncation error, and explain it in code units.namespace Utf8Utf16;
using System.Globalization;
using System.Text;
using System.Text.Unicode;
public static class TextLimits
{
/// Truncates to at most maxGraphemes user-visible characters, never splitting one.
public static string TruncateGraphemes(string text, int maxGraphemes)
{
ReadOnlySpan<char> span = text;
int index = 0, count = 0;
while (index < span.Length && count < maxGraphemes)
{
index += StringInfo.GetNextTextElementLength(span[index..]);
count++;
}
return text[..index];
}
/// Truncates so the UTF-8 encoding fits in maxBytes, without splitting a grapheme.
public static string TruncateUtf8Bytes(string text, int maxBytes)
{
ReadOnlySpan<char> span = text;
int index = 0, bytes = 0;
while (index < span.Length)
{
int length = StringInfo.GetNextTextElementLength(span[index..]);
int elementBytes = Encoding.UTF8.GetByteCount(span.Slice(index, length));
if (bytes + elementBytes > maxBytes)
{
break;
}
bytes += elementBytes;
index += length;
}
return text[..index];
}
}
public static class Program
{
public static void Main()
{
string family = "\U0001F468β\U0001F469β\U0001F467";
Console.WriteLine(family.Length); // 8 UTF-16 code units
Console.WriteLine(family.EnumerateRunes().Count()); // 5 code points
Console.WriteLine(new StringInfo(family).LengthInTextElements); // 1 grapheme
Console.WriteLine(Encoding.UTF8.GetByteCount(family)); // 18 bytes
string text = "Kia ora ππ½ team";
Console.WriteLine(TextLimits.TruncateGraphemes(text, 9)); // "Kia ora ππ½"
Console.WriteLine(TextLimits.TruncateUtf8Bytes(text, 10)); // "Kia ora "
ReadOnlySpan<byte> utf8 = "CafΓ©"u8; // UTF-8 literal (C# 11)
Console.WriteLine(Utf8.IsValid(utf8)); // True (.NET 8+)
}
}
string.Length as "number of characters". Why it is wrong: It counts UTF-16 code units, so emoji and combining sequences count as 2 or more and limits are enforced wrongly. Instead: Decide which length the rule means (graphemes, code points, bytes or code units) and count that.Substring or a range at an arbitrary index. Why it is wrong: It can split a surrogate pair or a grapheme, producing invalid text that becomes οΏ½ or is rejected downstream. Instead: Truncate on text-element boundaries with StringInfo.GetNextTextElementLength.char is not indexing by character. Instead: Iterate with EnumerateRunes() when you need code points.Utf8.IsValid, or a UTF8Encoding with throwOnInvalidBytes: true), reject with a clear error, and log the source, offset and a hex snippet of the bad bytes (not the full payload) so the problem can be traced.maxlength and JavaScript .length count UTF-16 code units, the API might count graphemes, and a UTF-8 VARCHAR column counts bytes, so a name accepted by the form is rejected or truncated later. Instead: Define one rule per field (for example "50 graphemes and at most 200 UTF-8 bytes"), size the column for the worst case, and enforce it in each layer with shared tests.NVARCHAR(n) with n characters. Why it is wrong: n is UTF-16 code units, so 50 emoji do not fit in NVARCHAR(50). Instead: Size columns in code units from realistic data, and validate with the same rule in the API.string just to process UTF-8 bytes. Why it is wrong: It allocates and transcodes on hot paths such as request parsing or log processing. Instead: Work with ReadOnlySpan<byte>, Utf8JsonReader and the Utf8 APIs where performance matters.The senior move is to name which length a requirement means. Product people say "characters" and mean what users see; databases and protocols enforce bytes or code units. Getting this wrong causes data loss, rejected payloads and support tickets from users with non-English names, which in Australia includes MΔori, Vietnamese, Chinese and Aboriginal and Torres Strait Islander language speakers.
Seniors also know .NET's split personality and its performance consequences:
| Layer | Encoding | Senior practice |
|---|---|---|
In-memory string |
UTF-16 | fine for business logic; count graphemes for user-facing limits |
| HTTP, JSON, files, Kafka | UTF-8 | parse directly from bytes (Utf8JsonReader, Kestrel, PipeReader) |
| Hot transcoding paths | both | Encoding.UTF8.GetBytes(string, Span<byte>) into pooled buffers; avoid per-request byte[] |
| Storage | UTF-16 (nvarchar) or UTF-8 collation |
size columns from real data in the unit the column counts |
Transcoding is vectorised and fast, but at tens of thousands of requests per second the allocations show up as GC pressure, which is why ASP.NET Core and System.Text.Json stay in UTF-8 end to end.
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.
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)
**Situation:** At [company], about [2%] of push notifications started failing with "invalid payload" errors, mostly on marketing messages. **Task:** I needed to find why and stop users missing order updates. **Action:** I hex-dumped failed payloads and found the replacement character `EF BF BD` exactly where we truncated, because `Substring(0, 100)` was cutting emoji surrogate pairs in half. I wrote a helper that truncates on grapheme boundaries and enforces the provider's byte limit, added tests with emoji and CJK text, and replayed the failed sends. **Result:** Failures dropped to [zero], and the shared helper replaced [N] other hand-written truncations across services.
Last updated 23 Oct 2026.