📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

How do UTF-8 and UTF-16 differ, and why can string.Length give the wrong answer for user-visible characters in .NET?

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.

Mid-Level Theory Intermediate computer-basics strings surrogate-pairs unicode utf-16 utf-8

🎯 Key points

  • UTF-8 uses 1 to 4 bytes per code point, is ASCII-compatible and has no byte-order issue
  • UTF-16 uses one 2-byte code unit for BMP characters (U+0000 to U+FFFF) and a 4-byte surrogate pair above U+FFFF
  • .NET strings and char are UTF-16; string.Length counts code units, not user-visible characters
  • Use Rune/EnumerateRunes for code points and StringInfo (GetNextTextElementLength, LengthInTextElements) for grapheme clusters
  • Truncating by char index can split a surrogate pair or grapheme, producing invalid text that becomes U+FFFD or is rejected downstream
  • Storage limits (UTF-8 bytes, NVARCHAR code units) and display limits (graphemes) need different counting, applied the same way in UI, API and database
  • UTF-8 is the web default; modern .NET processes it directly (u8 literals, spans, Utf8JsonReader, Utf8.IsValid) to avoid transcoding and allocations

⏱ 30-second answer

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.

πŸ—£ Full answer (senior)

How each encoding works

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.

Surrogate pairs

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.

Why string.Length misleads

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:

  • Validation: "name must be at most 20 characters" rejects valid names or allows too-long ones.
  • Truncation: s[..20] can cut a surrogate pair in half, leaving invalid UTF-16 that turns into οΏ½ when encoded.
  • Reversal and character loops: reversing chars breaks emoji and accents.

Doing it right in .NET

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.

Production example

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.

πŸ” What the interviewer is checking

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.

πŸ“š Learn in depth

1. UTF-8 bit layout

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).

2. UTF-16 surrogate maths

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

3. Byte order marks

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.

4. Three lengths, three limits

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.

5. Why .NET is UTF-16 inside and UTF-8 outside

  • .NET (like Windows NT, Java and JavaScript) chose UTF-16 when Unicode was believed to fit in 16 bits. Changing it would break char and every string API.
  • Instead, modern .NET does UTF-8 work at the edges without allocating strings: Utf8JsonReader and Utf8JsonWriter, Kestrel's request parsing, u8 literals, IUtf8SpanFormattable (.NET 8+). This avoids transcoding costs on hot paths.

6. Performance notes

  • UTF-16 to UTF-8 transcoding is vectorised in .NET and very fast, but allocation still costs; prefer Encoding.UTF8.GetBytes(string, Span<byte>) with a pooled buffer in hot code.
  • For mostly-ASCII data, UTF-8 halves memory and bandwidth compared with UTF-16. That's why JSON and HTTP use it.

7. Try it yourself

  1. Print the UTF-8 and UTF-16 bytes (Encoding.Unicode) for A, Γ©, 汉 and πŸ˜€ and explain each length.
  2. Take "Hello πŸ‘‹πŸ½ world", cut it with Substring(0, 7), encode it to UTF-8, and look for EF BF BD (the replacement character).
  3. Write TruncateGraphemes(string text, int max) using StringInfo.GetNextTextElementLength and test it with the family emoji.
  4. Create a SQL Server table with NVARCHAR(5) and insert five emoji. Observe the truncation error, and explain it in code units.

πŸ’» Code

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+)
    }
}

⚠ Common mistakes

  • Mistake: Using 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.
  • Mistake: Truncating with 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.
  • Mistake: Assuming UTF-16 is fixed width. Why it is wrong: Characters above U+FFFF take two code units, so indexing by char is not indexing by character. Instead: Iterate with EnumerateRunes() when you need code points.
  • Mistake: Swallowing encoding errors. Why it is wrong: By default .NET decoders replace invalid bytes with U+FFFD and encoders replace lone surrogates, so bad data is stored and nobody notices until customers complain. Instead: Validate at the boundary (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.
  • Mistake: Validating length differently in the browser, the API and the database. Why it is wrong: HTML 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.
  • Mistake: Confusing 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.
  • Mistake: Always writing a UTF-8 BOM, or never writing one. Why it is wrong: The optional BOM breaks some Unix tools and JSON parsers, while Excel needs it to detect UTF-8 CSV. Instead: Omit it by default and add it deliberately for consumers that need it.
  • Mistake: Converting to 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.

πŸ› Senior insight: trade-offs

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.

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate explains that truncating by char can split a surrogate pair. Why: Checks whether the candidate can apply byte limits and grapheme-safe truncation in a code review.
    How to answer

    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.

  2. When: Candidate mentions the UTF-8 byte order mark. Why: Moves to diagnosing encoding mismatches in files.
    How to answer

    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.

πŸ›  Real-world problems: how you handle them

Push notifications silently dropped after emoji truncation
Situation
A retail app's notification service sent order updates to about 400,000 users through a push provider with a 178-byte preview limit. Previews were built with `message.Substring(0, 100)`.
Signals
A support spike from users not receiving notifications; provider responses with HTTP 400 "invalid payload" for about 2% of sends; failures correlated with marketing messages containing emoji.
Investigation
Pulled failed payloads from structured logs, hex-dumped the UTF-8 and found `EF BF BD` at the cut point, confirming a split surrogate pair. Also measured that some valid previews exceeded the provider's byte limit because CJK and emoji text is 3–4 bytes per character.
Resolution
Replaced substring truncation with a helper that cuts on grapheme boundaries and enforces the provider's UTF-8 byte budget, adding an ellipsis when truncated. Re-sent failed notifications from the outbox.
Prevention
Unit tests with emoji, ZWJ sequences, combining accents and CJK text; a metric on provider 400 responses with an alert; the helper shared in a common library so other services stopped hand-rolling truncation.

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.

↩ Usually asked after

More in Computer Basics

Last updated 23 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me