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

📚 Interview question

How do you work with text encodings in C#? Explain the Encoding class, UTF-8 with and without a BOM, legacy code pages, u8 literals, Rune, grapheme clusters and Unicode normalisation.

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.

Mid-Level Theory Intermediate csharp encoding rune strings unicode utf-8

🎯 Key points

  • A .NET string is UTF-16 in memory; an Encoding converts between strings and bytes for files, network and databases
  • Encoding.UTF8 writes a BOM through its preamble; File.WriteAllText and StreamWriter default to UTF-8 without a BOM; use new UTF8Encoding(false) to be explicit
  • Invalid bytes become U+FFFD by default; new UTF8Encoding(false, throwOnInvalidBytes: true) fails fast instead
  • Legacy code pages such as Windows-1252 need CodePagesEncodingProvider registered on .NET (Core); Encoding.Default is UTF-8 there
  • "text"u8 literals produce UTF-8 bytes at compile time; System.Text.Unicode.Utf8 transcodes spans without allocating
  • Rune represents one Unicode scalar value; EnumerateRunes avoids splitting surrogate pairs
  • StringInfo and TextElementEnumerator count grapheme clusters (what users see as one character), e.g. emoji with modifiers
  • Normalise (NFC) before comparing or storing user text so composed and decomposed forms of é compare equal

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The Encoding class

byte[] 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.

2. UTF-8 with and without a BOM

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

  • Without BOM is the default for .NET Core file APIs, the web, JSON (RFC 8259 forbids generating it), Linux tools and most APIs. A BOM at the start of JSON or a shell script often breaks parsers.
  • With BOM is what Excel on Windows needs to open a CSV as UTF-8; without it, Excel assumes the legacy ANSI code page and shows 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.

3. Legacy code pages

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

4. Efficient UTF-8 in modern .NET

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

5. char, Rune and grapheme clusters

  • A char is one UTF-16 code unit. Characters outside the Basic Multilingual Plane (emoji like 😀, some CJK) need two chars (a surrogate pair). "😀".Length == 2.
  • A Rune (System.Text.Rune, .NET Core 3.0) is one Unicode scalar value (code point). s.EnumerateRunes() iterates them correctly.
  • A grapheme cluster is what a user sees as one character: "é" (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.

6. Unicode normalisation

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.

7. A production example

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.

🔍 What the interviewer is checking

The interviewer checks whether the candidate can prevent and fix encoding bugs with the right .NET APIs.

Green flags

  • Knows strings are UTF-16 and encodings apply at boundaries.
  • Handles BOMs deliberately (and knows the Excel CSV case).
  • Mentions the code-pages provider, strict decoding, Rune, StringInfo and normalisation.
  • Uses u8 literals or span transcoding for performance.

Red flags

  • Thinks string.Length equals the number of visible characters.
  • Never specifies encodings.
  • Unaware that invalid bytes are silently replaced.

📚 Learn in depth

1. How UTF-8 encodes code points

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:

  • ASCII is valid UTF-8 — why UTF-8 won the web.
  • Self-synchronising: continuation bytes always start with 10, so you can find character boundaries from any position.
  • No byte-order issues, hence no BOM needed.
  • Invalid sequences exist (overlong forms, lone continuation bytes, encoded surrogates). .NET's decoders replace invalid bytes with U+FFFD (�) by default; use new UTF8Encoding(false, throwOnInvalidBytes: true) when you must reject corrupt input.

2. UTF-16 and surrogates

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

3. Encoders, decoders and streaming

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.

4. Performance of transcoding

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:

  • Kestrel parses HTTP headers as UTF-8/ASCII bytes.
  • System.Text.Json reads and writes UTF-8 directly.
  • u8 literals avoid runtime encoding of constants.
  • The Ascii class (.NET 8) offers fast ASCII-only validation and case-insensitive comparison on byte spans.

5. Rune and text-element APIs

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.

6. Normalisation forms

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.

7. Databases

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.

Try it yourself

  1. Write "Café, Ngô" to a CSV with and without BOM and open both in Excel.
  2. Print Length, rune count and text-element count for strings with emoji, flags and combining accents.
  3. Decode a UTF-8 byte array split in the middle of é using GetString per chunk vs a Decoder.
  4. Compare "é" == "é" before and after Normalize().

💻 Code

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

⚠ Common mistakes

1. Not specifying an encoding at boundaries

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.

2. BOM in the wrong place (or missing where needed)

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.

3. Using Encoding.ASCII for user text

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

4. Truncating with Substring

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

5. Assuming Length is the number of characters

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

6. Decoding stream chunks independently

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.

7. Comparing user identifiers without normalisation

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.

8. Ignoring invalid byte handling

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.

9. Converting to string just to write bytes

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

10. Trying to auto-detect encodings

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.

🏛 Senior insight: trade-offs

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
  • Most encoding incidents are data-quality incidents found late (by customers); add contract tests with non-ASCII names such as José, Müller and 王小明.
  • Prefer UTF-8 end to end in hot paths (u8 literals, Utf8JsonWriter, pipelines) to avoid UTF-16 round trips.
  • The theory of code points, code units and UTF-8 versus UTF-16 is a separate topic; this question is about using the APIs correctly.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions surrogate pairs or Rune. Why: Goes down to the theory of UTF-8, UTF-16 and why string.Length can mislead.
    How to 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.

  2. When: The candidate mentions BOMs or U+FFFD. Why: A debugging scenario for mojibake and replacement characters.
    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

Bank payment file rejected because of encoding defaults
Situation
A payroll system generated a nightly fixed-width payment file for a bank that required Windows-1252 and no BOM; the system was migrated from .NET Framework to .NET 8.
Signals
First the job crashed with NotSupportedException for code page 1252; after a quick fix to UTF-8 the bank rejected files where employee names had accents, citing invalid characters and wrong record lengths.
Investigation
Compared byte dumps of old and new files, saw multi-byte UTF-8 sequences in name fields that changed record lengths, and confirmed .NET lacks code page 1252 without the provider.
Resolution
Registered CodePagesEncodingProvider, wrote files with Windows-1252 and an exception encoder fallback, and validated names at input against characters representable in 1252.
Prevention
Golden-file tests comparing bytes against a bank-approved sample, input validation rules, and an alert on rejected bank files.

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.

↩ Usually asked after

More in Strings

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me