Strings and Text
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
string is a reference type whose instances are immutable sequences of UTF-16 code units. Every operation
that appears to modify a string returns a new one. That single fact drives everything on this page: why
StringBuilder exists, why interning is safe, and why ReadOnlySpan<char> is the way to slice text without
allocating.
Immutability and Interning
string s = "hello";
string upper = s.ToUpperInvariant();
Console.WriteLine(s); // hello -- unchanged
Console.WriteLine(upper); // HELLO -- a new string
// Identical literals are *interned*: the runtime stores one instance per distinct literal.
string a = "shared";
string b = "shared";
Console.WriteLine(ReferenceEquals(a, b)); // True
// Strings built at run time are not interned unless you ask.
string c = new string(['s', 'h', 'a', 'r', 'e', 'd']);
Console.WriteLine(ReferenceEquals(a, c)); // False
Console.WriteLine(a == c); // True -- == compares content
Console.WriteLine(ReferenceEquals(a, string.Intern(c))); // True
== on string is overloaded to compare content using ordinal comparison — unlike most reference types,
where it compares identity. ReferenceEquals is how you ask the identity question explicitly.
Concatenation and StringBuilder
Concatenation in a loop is O(n²) because each + allocates a new string:
// Bad in a loop: 10 000 intermediate strings.
static string Slow(IEnumerable<string> parts)
{
string result = "";
foreach (string part in parts)
{
result += part;
}
return result;
}
// Good: one growable buffer.
static string Fast(IEnumerable<string> parts)
{
var sb = new StringBuilder();
foreach (string part in parts)
{
sb.Append(part);
}
return sb.ToString();
}
// Better still when the shape is known:
static string Joined(IEnumerable<string> parts) => string.Join(", ", parts);
static string Concatenated(params string[] parts) => string.Concat(parts);
A handful of + operations on one expression is fine — the compiler folds them into a single
string.Concat call. It is the loop that hurts.
StringBuilder supports interpolation directly (sb.Append($"…") uses a handler that appends without building
an intermediate string) and AppendLine, Insert, Replace, Remove and Clear.
String Interpolation
string name = "Ada";
int year = 1843;
double ratio = 0.8567;
Console.WriteLine($"{name} published in {year}.");
// Format specifiers after `:`
Console.WriteLine($"{ratio:P1}"); // 85,7 % (culture-dependent)
Console.WriteLine($"{ratio:F3}"); // 0,857
Console.WriteLine($"{year:D6}"); // 001843
Console.WriteLine($"{DateTime.UnixEpoch:yyyy-MM-dd}"); // 1970-01-01
// Alignment after `,` -- negative means left-align
Console.WriteLine($"|{name,10}|{name,-10}|"); // | Ada|Ada |
// Both together: alignment first, then format
Console.WriteLine($"|{ratio,12:P2}|");
// Escape a brace by doubling it
Console.WriteLine($"{{{name}}}"); // {Ada}
An interpolated string is string by default, but it converts to FormattableString when you need the format
and arguments separately — for culture control or for logging that defers formatting:
FormattableString fs = $"{1234.5:C}";
Console.WriteLine(fs.Format); // {0:C}
Console.WriteLine(fs.ArgumentCount); // 1
Console.WriteLine(fs.ToString(CultureInfo.InvariantCulture)); // ¤1,234.50
Console.WriteLine(FormattableString.Invariant($"{1234.5:F2}")); // 1234.50
Interpolated String Handlers
Since C# 10, the compiler lowers an interpolated string to calls on a handler type rather than to
string.Format. That is why sb.Append($"x = {x}") allocates nothing beyond the builder’s own buffer, and why
a logging API can skip formatting entirely when the level is disabled:
[InterpolatedStringHandler]
public ref struct DebugLogHandler
{
private readonly StringBuilder? _builder;
// The extra `out bool` lets the handler cancel formatting before any argument is evaluated.
public DebugLogHandler(int literalLength, int formattedCount, bool enabled, out bool shouldAppend)
{
_builder = enabled ? new StringBuilder(literalLength + (formattedCount * 8)) : null;
shouldAppend = enabled;
}
public void AppendLiteral(string s) => _builder!.Append(s);
public void AppendFormatted<T>(T value) => _builder!.Append(value?.ToString());
public override string ToString() => _builder?.ToString() ?? string.Empty;
}
public static class DebugLog
{
public static void Write(bool enabled,
[InterpolatedStringHandlerArgument(nameof(enabled))] ref DebugLogHandler message)
{
if (enabled)
{
Console.WriteLine(message.ToString());
}
}
}
Called as DebugLog.Write(enabled, $"expensive {Compute()}"), Compute() is never invoked when enabled is
false.
Verbatim, Raw and UTF-8 Literals
// Verbatim: no escape sequences; "" is a literal quote; newlines are kept.
string path = @"C:\Users\ada\Documents";
string quoted = @"She said ""hello"".";
// Raw string literals (C# 11): at least three quotes; no escaping at all.
string json = """
{ "name": "Ada", "path": "C:\Users\ada" }
""";
// The closing delimiter's indentation is stripped from every line.
string sql = """
SELECT id, name
FROM users
WHERE active = 1
""";
// Interpolation in a raw literal: the number of $ says how many braces start a hole.
string name = "Ada";
string jsonWithHole = $$"""
{ "name": "{{name}}", "literal": { "braces": "stay" } }
""";
// Use more quotes when the content itself contains three.
string tricky = """"He wrote """raw""" in the doc."""";
// UTF-8 literal: a ReadOnlySpan<byte>, not a string -- no encoding work at run time.
ReadOnlySpan<byte> utf8 = "application/json"u8;
Console.WriteLine($"{path} {quoted} {json} {sql} {jsonWithHole} {tricky} {utf8.Length}");
Raw literals are the right default for embedded JSON, SQL, XML and regular expressions: no escaping means the text in your source is exactly the text at run time.
nameof
public static void Guard(string? input)
{
ArgumentNullException.ThrowIfNull(input); // message names the parameter for you
if (input.Length == 0)
{
throw new ArgumentException("Must not be empty.", nameof(input));
}
}
public sealed class Person
{
public string Name { get; set; } = "";
public void Raise() => Console.WriteLine(nameof(Name)); // "Name"
}
// C# 14: nameof accepts an unbound generic type.
public static string ListName() => nameof(List<>); // "List"
nameof is evaluated at compile time and refactoring-safe: renaming the member updates the string.
Comparison and Culture
This is where text bugs live. string comparison has three axes: ordinal versus linguistic, case-sensitive
versus insensitive, and which culture.
string a = "straße";
string b = "STRASSE";
// Ordinal: compares UTF-16 code units. Fast, stable, locale-independent.
Console.WriteLine(string.Equals(a, b, StringComparison.Ordinal)); // False
Console.WriteLine(string.Equals(a, b, StringComparison.OrdinalIgnoreCase)); // False
// Linguistic: applies the culture's collation rules.
Console.WriteLine(string.Equals(a, b, StringComparison.InvariantCultureIgnoreCase)); // True on ICU
// Sorting differs too.
string[] names = ["apple", "Apple", "Banana", "banana"];
Array.Sort(names, StringComparer.Ordinal);
Console.WriteLine(string.Join(",", names)); // uppercase sorts before lowercase
Array.Sort(names, StringComparer.CurrentCulture);
Console.WriteLine(string.Join(",", names)); // case-insensitive-ish grouping
The rule of thumb from Microsoft’s guidance:
-
Ordinal for identifiers, file paths, protocol tokens, dictionary keys, anything machine-readable. It is the default for
==and forDictionary<string, …>. -
OrdinalIgnoreCase for case-insensitive machine-readable comparison (HTTP headers, file names on Windows).
-
CurrentCulture only for text shown to and sorted for a human user.
-
InvariantCulture for culture-independent-but-linguistic comparison — rarely the right answer; prefer ordinal.
ToUpper()/ToLower() are culture-sensitive; ToUpperInvariant()/ToLowerInvariant() are not. Never
case-fold to compare — pass a StringComparison instead.
Searching, Splitting and Trimming
string line = " id=42 ; name=Ada ; active=true ";
Console.WriteLine(line.Trim());
Console.WriteLine(line.TrimStart().TrimEnd(' ', '\t'));
Console.WriteLine(line.Contains("name", StringComparison.Ordinal)); // True
Console.WriteLine(line.IndexOf("name", StringComparison.Ordinal)); // 11
Console.WriteLine(line.StartsWith(" id", StringComparison.Ordinal)); // True
string[] fields = line.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine(fields.Length); // 3
Console.WriteLine(string.Join(" | ", fields));
Console.WriteLine("a-b-c".Replace('-', '+'));
Console.WriteLine("abcdef".Substring(2, 3)); // cde
Console.WriteLine("abcdef"[2..5]); // cde -- a range, see Operators and Expressions
Console.WriteLine("abcdef"[^2..]); // ef
Console.WriteLine(string.IsNullOrWhiteSpace(" ")); // True
Spans over Text
ReadOnlySpan<char> is a view over an existing string with no allocation. Slicing it is free, which makes it
the tool for parsers:
static (int Id, string Name) ParseRecord(ReadOnlySpan<char> input)
{
int separator = input.IndexOf(',');
ReadOnlySpan<char> idPart = input[..separator].Trim();
ReadOnlySpan<char> namePart = input[(separator + 1)..].Trim();
return (int.Parse(idPart, CultureInfo.InvariantCulture), namePart.ToString());
}
public static class SpanTextDemo
{
public static void Run()
{
var (id, name) = ParseRecord(" 42 , Ada Lovelace ");
Console.WriteLine($"{id} {name}"); // 42 Ada Lovelace
}
}
Every Trim, IndexOf and slice above allocates nothing; only the final ToString() does. Since C# 14 an
array or string converts implicitly to a span in more positions — see
Unsafe Code, Spans and Performance.
char, Code Points and Rune
A char is one UTF-16 code unit. Characters outside the Basic Multilingual Plane (emoji, many CJK extensions,
historic scripts) need two — a surrogate pair. System.Text.Rune represents a whole Unicode scalar value:
string text = "a😀b";
Console.WriteLine(text.Length); // 4 -- not 3! the emoji is a surrogate pair
foreach (Rune rune in text.EnumerateRunes())
{
Console.WriteLine($"{rune} U+{rune.Value:X4} ({rune.Utf16SequenceLength} UTF-16 units)");
}
Console.WriteLine(char.IsDigit('7')); // True
Console.WriteLine(Rune.IsLetter(new Rune('é'))); // True
Console.WriteLine(new StringInfo(text).LengthInTextElements); // 3 -- grapheme clusters
Use Length for buffer sizing, EnumerateRunes() when you mean "characters", and
StringInfo/TextElementEnumerator when you mean what a user would call a character (a grapheme cluster — 👨👩👧 is one of those and several runes).
Composite Formatting and IFormattable
public readonly record struct Money(decimal Amount, string Currency) : IFormattable
{
public override string ToString() => ToString(null, CultureInfo.CurrentCulture);
public string ToString(string? format, IFormatProvider? provider) => format switch
{
null or "G" => $"{Amount.ToString("N2", provider)} {Currency}",
"S" => $"{Currency} {Amount.ToString("N2", provider)}",
"R" => Amount.ToString(provider),
_ => throw new FormatException($"Unknown format '{format}'."),
};
}
public static class FormattingDemo
{
public static void Run()
{
var price = new Money(1234.5m, "EUR");
Console.WriteLine(price.ToString("S", CultureInfo.InvariantCulture)); // EUR 1,234.50
Console.WriteLine($"{price:G}");
Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "{0:S}", price));
}
}
Implementing ISpanFormattable in addition lets callers format into a buffer with no allocation, and
IUtf8SpanFormattable does the same directly in UTF-8.
See Also
-
Basic Types and Variables —
char,stringand where they sit in the type system. -
Unsafe Code, Spans and Performance —
Span<T>in full. -
Operators and Expressions — the index and range operators used above.
-
Collections and Iterators —
stringas anIEnumerable<char>.