String & Buffer Performance
Key Points
- Strings are immutable — every concat allocates. Use
StringBuilder,string.Create, orstring.Concat(span1, span2)for builds. Span<T>/ReadOnlySpan<T>for parsing without substring allocation.SearchValues<T>(.NET 8+) — vectorized search for any-of-many chars/bytes. Faster thanIndexOfAny.Utf8formatting (Utf8Formatter,Utf8JsonWriter) for network code — avoidsstring↔ bytes conversion.CompositeFormat— pre-parsed format string; reuses formatting plan.- Source-generated regex (
[GeneratedRegex]) — compiled at build; no setup alloc.
Concepts (deep dive)
String building
// ❌ Quadratic alloc
var s = "";
foreach (var w in words) s += w;
// ✅ StringBuilder (1 final string allocation)
var sb = new StringBuilder();
foreach (var w in words) sb.Append(w);
return sb.ToString();
// ✅ string.Join (when collection)
return string.Join(",", words);
// ✅ string.Create (single optimal allocation)
int totalLen = words.Sum(w => w.Length) + (words.Length - 1); // separators
return string.Create(totalLen, words, (span, ws) =>
{
var pos = 0;
for (int i = 0; i < ws.Length; i++)
{
if (i > 0) span[pos++] = ',';
ws[i].AsSpan().CopyTo(span[pos..]);
pos += ws[i].Length;
}
});
string.Create is the gold standard — one allocation, written in place.
StringBuilder pool
public class C(ObjectPool<StringBuilder> pool)
{
public string Build(IEnumerable<string> parts)
{
var sb = pool.Get();
try { foreach (var p in parts) sb.Append(p); return sb.ToString(); }
finally { pool.Return(sb); }
}
}
Default pool clears on return. Pool size capped (~1024 chars by default).
Span-based parsing
ReadOnlySpan<char> input = "key=value;timeout=30;retries=3";
while (!input.IsEmpty)
{
var sepIdx = input.IndexOf(';');
var part = sepIdx < 0 ? input : input[..sepIdx];
input = sepIdx < 0 ? ReadOnlySpan<char>.Empty : input[(sepIdx + 1)..];
var eqIdx = part.IndexOf('=');
var key = part[..eqIdx];
var val = part[(eqIdx + 1)..];
Process(key, val);
}
No Substring allocations. The whole parse runs on the original string's memory.
SearchValues (.NET 8+)
private static readonly SearchValues<char> _vowels = SearchValues.Create("aeiouAEIOU");
ReadOnlySpan<char> input = "Hello World";
int firstVowel = input.IndexOfAny(_vowels); // SIMD-vectorized
5–20x faster than IndexOfAny(char[]) for large inputs. Especially critical for byte parsing (HTTP headers, CSV).
Utf8Formatter / Utf8Parser
Span<byte> dest = stackalloc byte[16];
Utf8Formatter.TryFormat(123.45, dest, out int written);
// dest now holds "123.45" as UTF-8
Utf8Parser.TryParse(source, out int value, out _);
Skips string allocation entirely — useful for network protocols.
Utf8JsonWriter
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);
writer.WriteStartObject();
writer.WriteString("name", "Alice");
writer.WriteNumber("age", 30);
writer.WriteEndObject();
writer.Flush();
Direct UTF-8 emission. Faster than JsonSerializer.Serialize when you control the shape.
CompositeFormat
private static readonly CompositeFormat _fmt = CompositeFormat.Parse("Hello, {0}! You have {1} messages.");
var s = string.Format(null, _fmt, name, count);
Format string parsed once; reused. Marginal but real win in hot logging/error paths.
Source-gen regex
public partial class C
{
[GeneratedRegex(@"^\d{3}-\d{4}$")]
private static partial Regex PhoneRegex();
}
Compile-time generation. No setup; no reflection; ~10x faster than new Regex(...).
Interpolation
In .NET 6+, interpolated string handlers are optimized — string.Create-like, single allocation. But still allocates the result. Don't use in hot paths if you can avoid the string entirely (e.g., write to span).
Char operations
char.IsDigit('5'); // fast; no string
char.IsAsciiLetterOrDigit('a'); // .NET 7+; even faster
char.ToUpperInvariant('a');
Use Invariant variants. ToUpper() uses thread culture — slower; locale dependent.
String.Empty vs ""
Same string instance (interned). Use either — preference, no perf difference.
String.Intern / String.IsInterned
Manual interning. Generally avoid; the GC won't collect interned strings, leading to leaks. Compiler interns string literals automatically.
StringPool (Microsoft.Toolkit.HighPerformance)
Returns a deduplicated string for identical spans. Useful for parsing many copies of the same string (CSV column values).
Buffer copy
// ✅ Span/Memory CopyTo
src.AsSpan().CopyTo(dest);
// ✅ Buffer.MemoryCopy (low-level)
unsafe { Buffer.MemoryCopy(srcPtr, destPtr, bytes, bytes); }
// ✅ Array.Copy (legacy; works)
Array.Copy(src, dest, count);
Span's CopyTo is JIT-optimized; comparable to Buffer.MemoryCopy.
IndexOf vs SearchValues vs Vectorized custom
For single-char search, IndexOf is already vectorized in .NET 6+. For multi-char, SearchValues. For domain-specific (e.g., HTML escape characters), custom intrinsics with Vector256<byte>.
Encoding
// ✅ UTF-8 default in modern .NET
Encoding.UTF8.GetBytes("hello");
// ❌ Avoid Encoding.GetEncoding(0) (default codepage; unstable)
For high-performance UTF-8: use Span<byte> + Encoding.UTF8.GetBytes(span, dest) overload that avoids alloc.
string.Equals ordinal vs current culture
"abc".Equals("ABC", StringComparison.OrdinalIgnoreCase); // ✅ fast; deterministic
"abc".Equals("ABC", StringComparison.CurrentCultureIgnoreCase); // ❌ slower; locale-dependent
Always use Ordinal or OrdinalIgnoreCase unless you specifically need culture comparison.
String.Format vs $""
Compiler converts $"..." to string.Create calls (or interpolated handler). Roughly equivalent perf in modern .NET. string.Format for runtime templates; $"" for compile-time.
Code: correct vs wrong
❌ Wrong: substring allocates
foreach (var part in input.Split(','))
{
Process(part.Substring(0, part.IndexOf('='))); // 2 allocs per part
}
✅ Correct: span-based
var span = input.AsSpan();
while (!span.IsEmpty)
{
var idx = span.IndexOf(',');
var part = idx < 0 ? span : span[..idx];
var eq = part.IndexOf('=');
Process(part[..eq]); // ReadOnlySpan<char>
span = idx < 0 ? ReadOnlySpan<char>.Empty : span[(idx+1)..];
}
❌ Wrong: regex per call
✅ Correct: source-gen
[GeneratedRegex(@"^\d+$")] private static partial Regex DigitsRegex();
public bool Validate(string s) => DigitsRegex().IsMatch(s);
❌ Wrong: byte<->string round-trip
✅ Correct: Utf8Formatter
Design patterns for this topic
Pattern 1 — "Span for parsing"
- Intent: zero-alloc string surgery.
Pattern 2 — "string.Create for builds"
- Intent: single optimal allocation.
Pattern 3 — "SearchValues for multi-char search"
- Intent: SIMD speedup.
Pattern 4 — "Source-gen regex"
- Intent: compile-time; no setup.
Pattern 5 — "Utf8 formatting for network"
- Intent: avoid string conversion.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| StringBuilder | Familiar | Final ToString allocates |
| string.Create | One alloc | Verbose |
| Span | Zero alloc | API limited; no using |
| Regex generated | Fast | Build dep |
| Utf8Json | Faster than string | Less convenient |
When to use / when to avoid
- Use Span
for hot parsing. - Use string.Create for known-size builds.
- Use SearchValues for multi-pattern search.
- Avoid Substring in hot loops.
- Avoid culture-sensitive comparison unless needed.
Interview Q&A
Q1. Why is string concat in a loop quadratic? Each concat allocates a new string copying the previous. Total copying is O(n²).
Q2. StringBuilder vs string.Create? StringBuilder for unknown final size. string.Create when you know the size — single optimal alloc.
Q3. Span
Q4. SearchValues
Q5. Why source-gen regex? Compile-time codegen; no setup; faster than new Regex(...) repeatedly.
Q6. Ordinal vs CurrentCulture comparison? Ordinal: byte-by-byte; fast; deterministic. CurrentCulture: locale-aware; slow. Default to Ordinal.
Q7. Utf8Formatter vs ToString + Encoding.UTF8? Skips string allocation; writes UTF-8 directly to span.
Q8. CompositeFormat? Pre-parsed format string. Reuses parsing plan. Marginal win in hot logging.
Q9. Why is $"..." competitive in .NET 6+? Compiler emits interpolated handler — optimized. Comparable to string.Create.
Q10. ArrayPool for strings? Strings are immutable; pooling not direct. Pool the byte[] / char[] buffers used to build them.
Gotchas / common mistakes
- ⚠️ Substring in hot loops — alloc per call.
- ⚠️ Culture-sensitive comparison without need — slow.
- ⚠️
new Regex(...)per call — 10x slowdown. - ⚠️ Manual
String.Intern— leaks. - ⚠️ String concat for build — quadratic.
- ⚠️
Encoding.Default— unstable across machines.