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 for Dictionary<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