Strings, Encoding & Interpolation Internals
Key Points
stringin .NET is UTF-16 internally — sequence ofchar(16-bit code units). One Unicode codepoint may take 1 or 2 chars (surrogate pair). Don't index into "characters."Encoding.UTF8for I/O — bytes on disk and on the wire are virtually always UTF-8 in 2026.[InterpolatedStringHandler](C# 10) makes$"..."allocation-free in hot paths (logging,string.Create, custom formatters).string.Create<TState>/string.Concat/StringBuilder— three perf tiers; pick by predicted length and number of fragments.- String interning is a CLR table for compile-time literals.
string.Internexists but is rarely a win in modern .NET.
Concepts (deep dive)
UTF-16 and surrogate pairs
string s = "𝕏"; // mathematical X — codepoint U+1D54F, outside BMP
Console.WriteLine(s.Length); // 2 — two UTF-16 code units (high + low surrogate)
Console.WriteLine(s[0]); // unprintable surrogate
foreach (var rune in s.EnumerateRunes()) Console.WriteLine($"{rune.Value:X}"); // 1D54F
Length is in code units, not codepoints. To iterate codepoints, use EnumerateRunes(). To iterate grapheme clusters (user-perceived characters incl. emoji + skin-tone modifiers), use StringInfo.GetTextElementEnumerator.
string is immutable
Every concatenation allocates. s += "x" in a loop is O(n²) memory.
// Bad
string s = "";
for (int i = 0; i < 1_000; i++) s += "x";
// Good
var sb = new StringBuilder(1_000);
for (int i = 0; i < 1_000; i++) sb.Append('x');
string s = sb.ToString();
// Best for known short list:
string s = string.Concat(parts);
// Or for templates:
string s = $"order={id} total={total:F2}";
Encoding: UTF-8 vs UTF-16 vs ASCII
byte[] utf8 = Encoding.UTF8.GetBytes(s);
byte[] utf16 = Encoding.Unicode.GetBytes(s); // BOM-less LE on .NET
byte[] utf16Bom = Encoding.UTF8.GetPreamble(); // [0xEF,0xBB,0xBF]
string back = Encoding.UTF8.GetString(utf8);
- UTF-8 for files, HTTP, JSON, logs.
- UTF-16 LE internal CLR layout, Windows APIs (the
Wvariants). - ASCII never — use UTF-8 (ASCII is a subset, no downside).
Encoding.Defaultis platform-dependent; rarely what you want in 2026 (it's UTF-8 on .NET 5+).
BOM (byte-order mark)
UTF-8 BOM (EF BB BF) is rare and undesirable on Linux/macOS. .NET writes UTF-8 without BOM by default since .NET 5. Watch for tools that prepend it (Excel CSV exporter is notorious).
Interpolated string handlers (C# 10+)
$"..." is rewritten to a DefaultInterpolatedStringHandler, which uses a stack-allocated Span<char> buffer when the result fits, falling back to ArrayPool<char> when not. Far better than the old string.Format.
[InterpolatedStringHandler]
public ref struct LogHandler
{
public LogHandler(int literalLen, int formattedCount, ILogger logger, LogLevel level, out bool enabled)
{ enabled = logger.IsEnabled(level); /* ... */ }
public void AppendLiteral(string s) { /* ... */ }
public void AppendFormatted<T>(T value) { /* ... */ }
public string ToStringAndClear() { /* ... */ }
}
public void Log(LogLevel level, [InterpolatedStringHandlerArgument(/* logger, level */)] LogHandler msg)
{
if (level >= MinLevel) _logger.Log(level, msg.ToStringAndClear());
}
// Caller:
Log(LogLevel.Trace, $"order={id} total={total}"); // when Trace is off, args never formatted
ASP.NET Core's LoggerMessage source generator and Microsoft.Extensions.Logging use this pattern to skip work when a log level is disabled.
string.Create<TState>
Allocate exactly the final length and write into a Span<char> — single allocation, no intermediate buffer:
string Format(int n)
=> string.Create(11, n, (span, value) =>
{
value.TryFormat(span, out int written);
" items".AsSpan().CopyTo(span.Slice(written));
});
Comparison APIs (recap from equality topic)
"a".Equals("A", StringComparison.OrdinalIgnoreCase) // true
"file.TXT".EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
"über".CompareTo("uber") // culture-dependent — avoid
"über".AsSpan().CompareTo("uber", StringComparison.Ordinal) // explicit
Utf8 (UTF-8 strings as ReadOnlySpan<byte>)
ReadOnlySpan<byte> json = """{"hello":"world"}"""u8; // u8 literal, C# 11+
Utf8.TryWrite(buffer, $"id={id}", out int written); // .NET 8+ Utf8 formatter
For wire protocols, working in UTF-8 bytes directly avoids the UTF-16 round-trip.
Interning
string a = "hello";
string b = "hello";
ReferenceEquals(a, b); // True — both point to the same interned literal
string c = string.Intern(new string('h', 1) + "ello"); // forces interning
Compile-time literals are auto-interned. Manual Intern rarely pays off in modern code (cost: lifetime is process-wide, can never be GC'd).
Globalization mode
Drops culture data from the runtime — smaller deployment, deterministic behavior. Good for backend services that never localize. Bad for anything customer-facing that needs proper sorting/casing.
Code: correct vs wrong
❌ Wrong: indexing emoji
string flag = "🇺🇸";
Console.WriteLine(flag.Length); // 4 — two surrogate pairs
char first = flag[0]; // unprintable surrogate; not "the U letter"
✅ Correct: iterate runes or grapheme clusters
foreach (var r in flag.EnumerateRunes()) Console.WriteLine(r.Value);
var enumerator = StringInfo.GetTextElementEnumerator(flag);
while (enumerator.MoveNext()) Console.WriteLine(enumerator.Current); // single 🇺🇸
❌ Wrong: building strings in a loop
✅ Correct: StringBuilder or string.Join
string result = string.Join(",", items);
// Or
var sb = new StringBuilder();
foreach (var item in items) sb.Append(item).Append(',');
❌ Wrong: hardcoding encoding
✅ Correct: explicit UTF-8 (or default UTF-8 on .NET 5+)
Design patterns for this topic
Pattern 1 — Pre-sized StringBuilder
Avoids reallocation as the buffer grows. Estimate from input length.
Pattern 2 — Custom interpolated handler for hot logging
Skip formatting when the logger isn't listening — the handler queries IsEnabled first and short-circuits.
Pattern 3 — UTF-8 literal + Utf8.TryWrite
ReadOnlySpan<byte> prefix = "Bearer "u8;
Span<byte> buffer = stackalloc byte[256];
prefix.CopyTo(buffer);
Utf8.TryWrite(buffer.Slice(prefix.Length), $"{token}", out int written);
For HTTP header construction, JSON serialization shortcuts.
Pattern 4 — CompositeFormat for repeated templates
private static readonly CompositeFormat Template = CompositeFormat.Parse("Hello, {0}! You have {1} messages.");
string result = string.Format(null, Template, name, count);
Parses the template once, reuses across calls. Replaces hot string.Format paths.
Pros & cons / trade-offs
| Approach | When | Cost |
|---|---|---|
+ concat | 2-3 fragments, throwaway | Allocates result |
string.Concat | Small known list | One alloc |
string.Join | Collection with separator | One alloc |
$"" interpolation | Modern default | Stack/pool buffer, very efficient |
StringBuilder | Loop with many appends | Buffer growth, final ToString |
string.Create | Known exact length | Single alloc, hand-fill |
Utf8.TryWrite | UTF-8 wire output | Zero alloc into a span |
When to use / when to avoid
- Default:
$""interpolation. The compiler picks the right path. - Hot loops:
StringBuilderwith capacity hint, orstring.Create. - Wire formats: stay in UTF-8 spans; avoid
Encoding.GetStringround-trips. - Avoid culture-sensitive string comparison for identifiers (use
Ordinal). - Avoid
string.Internunless profiling proves it.
Interview Q&A
Q1. Why is string.Length not the codepoint count? A. string is UTF-16; Length counts 16-bit code units. Codepoints outside the BMP take two units (surrogate pair). Use EnumerateRunes() for codepoints.
Q2. When does $"..." allocate? A. The interpolated string handler stack-allocates a small buffer; rents from ArrayPool<char> if larger; allocates the final string once. Far better than the old string.Format path.
Q3. What's the difference between EnumerateRunes and StringInfo.GetTextElementEnumerator? A. Runes = Unicode codepoints. Text elements = grapheme clusters (user-perceived characters, including combining marks and emoji sequences). For "characters as users see them," use text elements.
Q4. Why is Encoding.UTF8.GetBytes("hello") faster than Encoding.Default.GetBytes? A. They're typically the same on modern .NET (default = UTF-8 since .NET 5). On old code or different platforms Default could be Windows-1252 or similar. Always be explicit.
Q5. What does the u8 suffix do? A. Creates a ReadOnlySpan<byte> of UTF-8 bytes baked into the assembly metadata — zero-allocation literal, ideal for headers and protocol fragments.
Q6. Why prefer StringComparison.Ordinal for identifiers? A. Ordinal is byte-wise, fastest, deterministic across locales; culture-sensitive comparison (the default for Compare) varies (Turkish "I", German "ß") and is much slower.
Q7. What's string.Create good for? A. Building a string of known final length in a single allocation by writing directly into the destination Span<char>. Avoids the intermediate buffer that StringBuilder uses.
Q8. How does [InterpolatedStringHandler] enable conditional formatting? A. The handler's constructor receives an out bool indicating whether to format at all. If false, all AppendLiteral/AppendFormatted calls become no-ops, so the arguments aren't even evaluated when (e.g.) the log level is off.
Gotchas / common mistakes
- Indexing into a string assuming
char == codepoint == grapheme. - Hardcoding
Encoding.GetEncoding(1252)and getting mojibake everywhere else. - Building strings with
+=in loops. - Using
string.Formatinstead of interpolation in modern code. - Comparing strings with default culture-sensitive semantics for identifiers.
- BOM in CSV files breaking downstream parsers.
Encoding.UTF8constructor variants — passingtruefor BOM emission silently corrupts.- Iterating
stringand ignoring grapheme clusters when measuring "user-perceived length."