02. Strings - C#

Quote

“In our daily lives as programmers, we process text strings a lot. So I tried to work hard on text processing, namely the string class and regular expressions.”

Yukihiro Matsumoto, creator of Ruby

This note covers C#‘s string type in full: creation and immutability, indexing and range syntax, the complete set of string methods, interpolation and composite formatting, StringBuilder for efficient construction, Span<char> for zero-allocation parsing, and regular expressions including .NET 7’s source-generated [GeneratedRegex].

String Creation & Basics

This section covers the fundamental building blocks of string handling in C#: literal syntax, type conversions, construction patterns, and the immutability guarantee that shapes how strings behave at runtime.

Literals and declaration

C# provides several literal syntaxes for creating strings, from standard double-quoted literals to verbatim and raw string literals that simplify paths and multiline content.

Declare a string and a char

string (alias for System.String) represents an immutable sequence of UTF-16 characters. char represents a single Unicode character occupying 2 bytes. Double quotes delimit strings; single quotes delimit char values.

This example shows how to declare a string and a char.

using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
 
string s1 = "hello";
char c1 = 'A';
Console.WriteLine($"\"{s1}\"");
Console.WriteLine($"'{c1}' (type: {c1.GetType().Name})");
"hello"
'A' (type: Char)

Disable escape processing with verbatim strings

Prefix a string literal with @ to create a verbatim string where backslashes are treated as literal characters. This eliminates double-backslash escaping for file paths, regex patterns, and any text containing backslashes. Verbatim and escaped strings produce identical runtime values.

This example shows how to disable escape processing with verbatim strings.

string s2 = @"C:\Users\new\test";
string s3 = "C:\\Users\\new\\test";
Console.WriteLine(s2);
Console.WriteLine(s3);
Console.WriteLine($"Same? {s2 == s3}");
C:\Users\new\test
C:\Users\new\test
Same? True

Create multiline strings with verbatim and raw literals

Verbatim strings (@"") preserve line breaks exactly as written. Raw string literals (C# 11+) use triple quotes (""") and automatically trim leading whitespace based on the indentation of the closing delimiter, making them ideal for embedded SQL, JSON, or XML.

This example prints the same multiline content from a verbatim string and a raw string literal.

string s4 = @"This is
a multiline
string";
Console.WriteLine(s4);
 
string s5 = """
    This is a
    raw string literal
    """;
Console.WriteLine(s5);
This is
a multiline
string
This is a
raw string literal

Conversion and construction

Methods for converting other types to strings, assembling strings from parts, and checking for empty or null values.

Convert other types to string with ToString and Convert

Every type in C# inherits ToString() from System.Object, making it the universal conversion path. Convert.ToString() adds null safety — it returns string.Empty instead of throwing on null. For nullable references, chain ?.ToString() with ?? to supply a fallback.

This example shows how to convert other types to string with ToString and Convert.

#nullable enable
Console.WriteLine($"'{42.ToString()}'");
Console.WriteLine($"'{3.14}'");
Console.WriteLine($"'{true}'");
Console.WriteLine($"'{Convert.ToString(42)}'");
 
object? obj = null;
Console.WriteLine($"'{obj?.ToString() ?? "(null)"}'");
'42'
'3.14'
'True'
'42'
'(null)'

Repeat and concatenate strings

C# has no * operator for string repetition. Use new string(char, count) for single-character repeats, or string.Concat with Enumerable.Repeat for multi-character patterns. The + operator concatenates strings and is optimized by the compiler for small, fixed concatenations.

This example shows how to repeat and concatenate strings.

Console.WriteLine($"'{new string('*', 5)}'");
Console.WriteLine($"'{string.Concat(Enumerable.Repeat("ha", 3))}'");
Console.WriteLine($"'{"hello" + " " + "world"}'");
'*****'
'hahaha'
'hello world'

Check for empty, null, and whitespace strings

C# strings have three distinct “nothing” states: null (no object), "" (empty string with zero length), and whitespace-only (contains only spaces, tabs, or newlines). string.IsNullOrEmpty catches the first two; string.IsNullOrWhiteSpace catches all three. Prefer string.Empty over "" for clarity when declaring empty strings.

IsNullOrEmpty vs IsNullOrWhiteSpace

Use IsNullOrWhiteSpace when validating user input or external data — whitespace-only strings are almost never meaningful. Reserve IsNullOrEmpty for internal logic where whitespace may be intentional (e.g., formatting strings).

This example shows how to check for empty, null, and whitespace strings.

string empty = "";
Console.WriteLine(empty == "");
Console.WriteLine($"\"{string.Empty}\"");
Console.WriteLine(empty.Length);
Console.WriteLine(string.IsNullOrEmpty(" "));
Console.WriteLine(string.IsNullOrEmpty(""));
Console.WriteLine(string.IsNullOrEmpty(null));
Console.WriteLine(string.IsNullOrWhiteSpace("  "));
Console.WriteLine(string.IsNullOrWhiteSpace(""));
True
""
0
False
True
True
True
True

Immutability

The single most important property of System.String — every operation that appears to modify a string actually allocates a new object and returns it. Understanding this shapes how you write performant string code.

Understand string immutability and its implications

Once a string is created, its character sequence cannot change. Indexing into a string with assignment (s[0] = 'H') is a compile error. Any transformation — ToUpper(), Replace(), Substring(), or concatenation — allocates a new string on the heap. The original is unchanged and becomes eligible for garbage collection if no other reference points to it.

Immutable strings punish repeated incremental concatenation

Every apparent mutation creates a brand-new string, so loop-based += grows both copying cost and allocation pressure with each iteration.

Concatenation in loops turns string building into O(n^2) work

Each += in a loop creates a new string object and copies all previous characters. For n iterations this becomes O(n^2) in both time and allocations.

Use the construction strategy that matches the workload

Use StringBuilder for loop-based construction, string.Join for collections, and + or $"" only for small fixed concatenations.

This example shows that string methods return new values instead of modifying the original string instance.

string s = "hello";
s = 'H' + s.Substring(1);
Console.WriteLine(s);
Hello

The diagram below shows what happens in memory. The variable s is reassigned to point to a new string object; the original "hello" is not modified — it becomes unreachable and is collected by the GC.


flowchart LR
    subgraph Before["Before: s = #quot;hello#quot;"]
        BPAD[" "]
        s1["s"] -->|points to| obj1["#quot;hello#quot;<br/>(heap)"]
        BPAD ~~~ s1
    end
    subgraph After["After: s = 'H' + s.Substring(1)"]
        APAD[" "]
        s2["s"] -->|points to| obj2["#quot;Hello#quot;<br/>(new object)"]
        obj3["#quot;hello#quot;<br/>(unreachable → GC)"]
        APAD ~~~ s2
        APAD ~~~ obj3
    end
    Before --> After
    style BPAD fill:transparent,stroke:transparent,color:transparent
    style APAD fill:transparent,stroke:transparent,color:transparent

Indexing & Slicing

Accessing individual characters and extracting substrings. C# uses 0-based indexing, hat (^) indexing from the end, and Range syntax (..) for slicing — all without allocating intermediate collections.

Accessing characters and substrings

Direct character access by position, substring extraction, and Range syntax for slicing.

Access characters and substrings by index and range

C# strings support 0-based indexing with [], hat indexing from the end with ^, and Range syntax with .. for slicing. All return values without modifying the original string.

Indexing and slicing

  • s[i] — returns a char at position i
  • s[^i] — indexes from the end (^1 = last char), eliminating s[s.Length - i]
  • s[a..b] — substring via Range syntax (C# 8+), right-exclusive (s[0..5] = indices 0-4)
  • No step/stride support — use LINQ for every-nth-char
  • For pattern extraction, use Regex or Split instead of index math

This example shows how to access characters and substrings by index and range.

string s = "Hello, World!";
 
Console.WriteLine($"'{s[0]}'");
Console.WriteLine($"'{s[1]}'");
Console.WriteLine($"'{s[^1]}'");
Console.WriteLine($"'{s[^2]}'");
Console.WriteLine($"'{s[0..5]}'");
Console.WriteLine($"'{s[..5]}'");
Console.WriteLine($"'{s[7..]}'");
Console.WriteLine($"'{s[^6..]}'");
Console.WriteLine($"'{s[7..12]}'");
'H'
'e'
'!'
'd'
'Hello'
'Hello'
'World!'
'World!'
'World'

Extract substrings and stride through characters

Substring(startIndex) and Substring(startIndex, length) are the classic APIs; Range syntax (s[7..]) is the modern equivalent. C# has no built-in step/stride parameter — use LINQ Where with an index predicate for every-nth-character patterns, or Reverse() to reverse a string.

This example shows how to extract substrings and stride through characters.

Console.WriteLine($"'{s.Substring(7)}'");
Console.WriteLine($"'{s.Substring(7, 5)}'");
 
Console.WriteLine($"'{new string(s.Where((c, i) => i % 2 == 0).ToArray())}'");
Console.WriteLine($"'{new string(s.Reverse().ToArray())}'");
 
char[] arr = s.ToCharArray();
Array.Reverse(arr);
Console.WriteLine($"'{new string(arr)}'");
'World!'
'World'
'Hlo ol!'
'!dlroW ,olleH'
'!dlroW ,olleH'

Handle out-of-range access

Accessing a character beyond the string length throws IndexOutOfRangeException. Using a Range that exceeds bounds throws ArgumentOutOfRangeException. Neither returns null or a default — C# fails fast on invalid access.

String indexing is fail-fast, not forgiving

Dynamic positions have to be validated explicitly because C# throws as soon as an index or range exceeds the string bounds.

Out-of-range access does not fail silently

Unlike languages that may return an empty string or None, C# throws immediately on invalid access. Always validate indices against s.Length when positions come from data.

Guard access before indexing

Check bounds before indexing, or use a guarded access pattern such as:

string safe = index < s.Length ? s[index].ToString() : "(out of range)";
s[100]    → IndexOutOfRangeException
s[0..100] → ArgumentOutOfRangeException (range must be within bounds)

Iterating characters

Three patterns for walking through a string character by character: foreach for simple iteration, LINQ Select for index-value pairs, and the classic for loop for index-based access.

Iterate with foreach

foreach yields each char in sequence. Combined with Range syntax, you can iterate over a substring without allocating a copy in older runtimes (C# 8+ Range on string returns a new string, but the iteration itself is straightforward).

This example shows how to iterate with foreach.

bool firstChar = true;
foreach (char ch in s[..5])
{
    if (!firstChar) Console.Write(" ");
    Console.Write(ch);
    firstChar = false;
}
Console.WriteLine();
H e l l o

Enumerate characters with LINQ Select

LINQ’s Select overload provides both the character and its index as a tuple, similar to Python’s enumerate(). Destructure the tuple in the foreach header for clean access.

This example pairs each character with its index by using LINQ Select inside a foreach loop.

foreach (var (ch, i) in s[..5].Select((c, i) => (c, i)))
    Console.WriteLine($"  [{i}] = '{ch}'");
  [0] = 'H'
  [1] = 'e'
  [2] = 'l'
  [3] = 'l'
  [4] = 'o'

Iterate with a for loop

The classic index-based for loop gives direct control over start, end, and step. Prefer this when you need to skip characters, iterate in reverse, or access adjacent indices within the loop body.

This example shows how to iterate with a for loop.

for (int i = 0; i < 5; i++)
    Console.WriteLine($"  [{i}] = '{s[i]}'");
  [0] = 'H'
  [1] = 'e'
  [2] = 'l'
  [3] = 'l'
  [4] = 'o'

String Methods

The built-in methods on System.String for transforming case, trimming whitespace, inspecting content, searching, splitting, joining, and encoding. All transformation methods return new strings — the original is never modified.

Case conversion

Methods for changing letter case. C# provides ToUpper(), ToLower(), and ToTitleCase() (via TextInfo). There are no built-in swapcase or casefold equivalents.

Convert case with ToUpper, ToLower, and ToTitleCase

ToUpper() and ToLower() convert all characters in a string. ToTitleCase() is available via CultureInfo.CurrentCulture.TextInfo and capitalizes the first letter of each word. All three return new strings — the original is unchanged.

Case methods

  • ToUpper() / ToLower() — convert all characters
  • ToTitleCase() — via CultureInfo.CurrentCulture.TextInfo, capitalizes each word
  • No built-in swapcase or casefold
  • Culture-aware: ToUpper(CultureInfo) handles locale-specific rules (e.g., Turkish iİ)

Case-insensitive comparison should not be implemented by normalizing both sides manually

Converting strings to uppercase or lowercase first creates unnecessary allocations and can still produce locale-sensitive bugs.

ToUpper() or ToLower() is the wrong comparison primitive

Do not use ToUpper() for case-insensitive comparison. It allocates a new string and can behave incorrectly for locale edge cases such as Turkish I.

Use StringComparison.OrdinalIgnoreCase directly

Pass StringComparison.OrdinalIgnoreCase to string.Equals, IndexOf, StartsWith, or Contains so no intermediate uppercase string is created:

// Anti-pattern
if (s.ToUpper() == "HELLO") { }
 
// Correct
if (string.Equals(s, "hello", StringComparison.OrdinalIgnoreCase)) { }
if (s.Contains("hello", StringComparison.OrdinalIgnoreCase)) { }

This example shows how to convert case with ToUpper, ToLower, and ToTitleCase.

#nullable enable
 
string s = "  Hello, World!  ";
 
Console.WriteLine($"'{"hello world".ToUpper()}'");
Console.WriteLine($"'{"HELLO WORLD".ToLower()}'");
Console.WriteLine($"'{CultureInfo.CurrentCulture.TextInfo.ToTitleCase("hello world")}'");
'HELLO WORLD'
'hello world'
'Hello World'

Whitespace and padding

Methods for stripping leading/trailing whitespace (or custom characters) and padding strings to a fixed width.

Trim, TrimStart, TrimEnd, and Pad

Trim() removes whitespace from both ends; TrimStart()/TrimEnd() remove from one side only. Pass a char argument to trim specific characters instead of whitespace. PadLeft/PadRight extend a string to a target width, filling with spaces or a specified character — useful for tabular output and zero-padding.

This example demonstrates trim, TrimStart, TrimEnd, and Pad.

Console.WriteLine($"'{s.Trim()}'");
Console.WriteLine($"'{s.TrimStart()}'");
Console.WriteLine($"'{s.TrimEnd()}'");
Console.WriteLine($"'{"Hello!!".Trim('!')}'");
Console.WriteLine($"'{"hello".PadLeft(20)}'");
Console.WriteLine($"'{"hello".PadRight(20)}'");
Console.WriteLine($"'{"hello".PadLeft(20, '*')}'");
Console.WriteLine($"'{"42".PadLeft(8, '0')}'");
'Hello, World!'
'Hello, World!  '
'  Hello, World!'
'Hello'
'               hello'
'hello               '
'***************hello'
'00000042'

Character and content checks

Inspecting individual characters and testing whole strings for content categories.

Check character categories with char.IsXxx

The char struct provides static methods to classify individual characters: IsLetter, IsDigit, IsWhiteSpace, IsUpper, IsLower, and others. These are Unicode-aware — IsLetter returns true for letters in any script, not just ASCII.

This example applies the main char.IsXxx classification helpers to individual characters.

Console.WriteLine(char.IsLetter('A'));
Console.WriteLine(char.IsDigit('5'));
Console.WriteLine(char.IsWhiteSpace(' '));
Console.WriteLine(char.IsUpper('A'));
Console.WriteLine(char.IsLower('a'));
True
True
True
True
True

Test whole strings with LINQ All

C# has no built-in str.isalpha() or str.isdigit() like Python. Instead, combine LINQ’s All method with char.IsXxx predicates to test whether every character in a string satisfies a condition.

This example tests whole strings by combining LINQ All with char.IsXxx predicates.

Console.WriteLine("Hello".All(char.IsLetter));
Console.WriteLine("12345".All(char.IsDigit));
Console.WriteLine("Hello123".All(char.IsLetterOrDigit));
Console.WriteLine("HELLO".All(char.IsUpper));
Console.WriteLine("hello".All(char.IsLower));
Console.WriteLine("Hello".All(c => c < 128));
True
True
True
True
True
True

Search and location

Methods for finding substrings by position or existence.

Search with IndexOf, Contains, StartsWith, EndsWith

IndexOf returns the 0-based position of the first occurrence (or -1 if not found). Pass a startIndex to search from a specific position. LastIndexOf searches from the end. Contains, StartsWith, and EndsWith return bool for existence checks. All support a StringComparison overload for case-insensitive matching. C# has no built-in occurrence counter — split on the target and subtract one, or use Regex.Matches(...).Count.

This example shows how to search with IndexOf, Contains, StartsWith, EndsWith.

s = "Hello, World! Hello, C#!";
Console.WriteLine(s.IndexOf("Hello"));
Console.WriteLine(s.IndexOf("Hello", 1));
Console.WriteLine(s.LastIndexOf("Hello"));
Console.WriteLine(s.IndexOf("Java"));
Console.WriteLine(s.Contains("World"));
Console.WriteLine(s.StartsWith("Hello"));
Console.WriteLine(s.EndsWith("!"));
 
int count = s.Split("Hello").Length - 1;
Console.WriteLine(count);
0
14
14
-1
True
True
True
2

Comparison and ordering

Methods for comparing strings for equality and sort order — distinct from searching by position.

Compare strings for ordering with string.Compare and CompareTo

string.Compare returns a negative, zero, or positive int indicating sort order. CompareTo is the instance-method equivalent but defaults to CurrentCulture comparison — making it culture-sensitive and inappropriate for programmatic keys. string.CompareOrdinal performs a fast code-point-by-code-point comparison without culture rules — use it for identifiers, keys, and paths where linguistic sorting is irrelevant.

Ordinal vs culture-aware comparison

Use StringComparison.Ordinal (or OrdinalIgnoreCase) for internal identifiers, dictionary keys, file paths, and protocol strings. Use StringComparison.CurrentCulture only when displaying sorted results to users where locale-specific ordering matters (e.g., German ä sorting near a).

This example shows how to compare strings for ordering with string.Compare and CompareTo.

Console.WriteLine(string.Compare("apple", "banana"));
Console.WriteLine(string.Compare("banana", "apple"));
Console.WriteLine(string.Compare("apple", "apple"));
Console.WriteLine("apple".CompareTo("banana"));
Console.WriteLine(string.Compare("hello", "HELLO", StringComparison.OrdinalIgnoreCase));
Console.WriteLine(string.CompareOrdinal("hello", "HELLO"));
-1
1
0
-1
0
32

Replace, split, and join

Methods for substituting substrings, tokenizing strings into arrays, and reassembling arrays into strings.

Replace substrings and split strings into arrays

Replace substitutes all occurrences — there is no max-count parameter (use Regex.Replace with a count for first-only replacement). Split tokenizes a string by a delimiter and returns a string[]. Pass an int count to limit the number of resulting parts. Splitting on null with RemoveEmptyEntries splits on any whitespace and discards empty segments, equivalent to Python’s str.split().

This example shows how to replace substrings and split strings into arrays.

Console.WriteLine($"'{s.Replace("Hello", "Hi")}'");
 
string csv = "apple,banana,cherry";
Console.WriteLine($"[{string.Join(", ", csv.Split(','))}]");
Console.WriteLine($"[{string.Join(", ", csv.Split(',', 2))}]");
string words = "  hello  world  ";
Console.WriteLine($"[{string.Join(", ", words.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))}]");
Console.WriteLine($"[{string.Join(", ", words.Split(' '))}]");
'Hi, World! Hi, C#!'
[apple, banana, cherry]
[apple, banana,cherry]
[hello, world]
[, , hello, , world, , ]

Control split behavior with StringSplitOptions and rejoin with Join

StringSplitOptions.RemoveEmptyEntries discards empty segments from consecutive delimiters. TrimEntries (.NET 5+) trims whitespace from each segment after splitting. string.Join reassembles an array with a separator; string.Concat joins without any separator.

This example shows how to control split behavior with StringSplitOptions and rejoin with Join.

Console.WriteLine($"[{string.Join(", ", "a,,b,,c".Split(',', StringSplitOptions.RemoveEmptyEntries))}]");
Console.WriteLine($"[{string.Join(", ", " a , b , c ".Split(',', StringSplitOptions.TrimEntries))}]");
 
string[] parts = { "hello", "world", "csharp" };
Console.WriteLine($"'{string.Join(' ', parts)}'");
Console.WriteLine($"'{string.Join(", ", parts)}'");
Console.WriteLine($"'{string.Join("->", parts)}'");
Console.WriteLine($"'{string.Concat(parts)}'");
[a, b, c]
[a, b, c]
'hello world csharp'
'hello, world, csharp'
'hello->world->csharp'
'helloworldcsharp'

Encoding

Converting between strings (UTF-16 in memory) and byte arrays for I/O, hashing, or network transmission.

Convert between strings and byte arrays with Encoding

Encoding.UTF8.GetBytes serializes a string to a UTF-8 byte array; GetString reverses the process. Use Encoding.ASCII for 7-bit ASCII or Encoding.Unicode for UTF-16LE. For pure ASCII strings, UTF-8 and ASCII produce identical bytes.

BOM awareness

Encoding.UTF8 does not emit a BOM (byte order mark). Use new UTF8Encoding(true) if a BOM is required for file interoperability.

This example shows how to convert between strings and byte arrays with Encoding.

byte[] utf8 = System.Text.Encoding.UTF8.GetBytes("hello");
byte[] ascii = System.Text.Encoding.ASCII.GetBytes("hello");
Console.WriteLine($"[{string.Join(", ", utf8)}]");
Console.WriteLine($"[{string.Join(", ", ascii)}]");
Console.WriteLine($"'{System.Text.Encoding.UTF8.GetString(utf8)}'");
[104, 101, 108, 108, 111]
[104, 101, 108, 108, 111]
'hello'

String Formatting

Embedding values into strings and controlling how numbers, dates, and currencies are displayed. C# offers three mechanisms: string interpolation ($""), String.Format, and ToString with format specifiers.

Interpolation and composite formatting

The primary ways to embed expressions into string literals.

Embed values with string interpolation

String interpolation ($"") is the preferred approach. Expressions inside {...} are evaluated at runtime and converted to strings. You can place any valid C# expression inside the braces — arithmetic, method calls, ternary operators. For alignment and format specifiers, use the syntax {expr,alignment:format}.

This example shows how to embed values with string interpolation.

string name = "Alice";
int age = 30;
double n = 1234567.89123;
double pct = 0.856;
 
Console.WriteLine($"Name: {name}, Age: {age}");
Console.WriteLine(age + 1);
Console.WriteLine(name.ToUpper());
Name: Alice, Age: 30
31
ALICE

Format strings with String.Format (composite formatting)

String.Format is the older API that uses numbered placeholders ({0}, {1}). It is still useful when the format string is stored externally (resource files, config) or constructed dynamically — interpolation requires a compile-time literal. The placeholder syntax supports the same alignment and format specifiers as interpolation: {index,alignment:format}.

This example formats values with String.Format and composite-format placeholders.

Console.WriteLine(String.Format("Name: {0}, Age: {1}", name, age));
Console.WriteLine(String.Format("{0:C2}", 1234.5));
Console.WriteLine(String.Format("{0,-20}|{1,10}", "Left", "Right"));
Name: Alice, Age: 30
$1,234.50
Left                |     Right

Numeric format specifiers

Standard format strings passed to ToString("X") or inside interpolation braces {value:X} control how numbers are displayed.

Format numbers with standard specifiers

SpecifierMeaningExample
FFixed-point1234567.89
NNumber with group separator1,234,567.89
EScientific notation1.23E+06
GGeneral (compact)1235
PPercentage (multiplies by 100)85.6%
CCurrency (locale-aware)$1,234,567.89
DDecimal (integers only)255
X/xHexadecimal upper/lowerFF / ff

Append a digit to control precision: F2 = 2 decimal places, D8 = zero-padded to 8 digits. Use Convert.ToString(value, base) for binary (base 2) or octal (base 8).

This example applies standard numeric format specifiers for fixed-point, grouping, scientific, percent, currency, and radix output.

Console.WriteLine(n.ToString("F2"));
Console.WriteLine(n.ToString("F0"));
Console.WriteLine(n.ToString("N2"));
Console.WriteLine(n.ToString("E2"));
Console.WriteLine(n.ToString("G4"));
Console.WriteLine(pct.ToString("P1"));
Console.WriteLine(n.ToString("C2"));
 
int x = 255;
Console.WriteLine(x.ToString("D"));
Console.WriteLine(x.ToString("X"));
Console.WriteLine(x.ToString("x"));
Console.WriteLine(x.ToString("D8"));
Console.WriteLine(Convert.ToString(x, 2));
Console.WriteLine(Convert.ToString(x, 8));
1234567.89
1234568
1'234'567.89
1.23E+006
1.235E+06
85.6%
$1,234,567.89
255
FF
ff
00000255
11111111
377

Alignment and culture-specific formatting

Controlling field width for tabular output and formatting numbers according to locale conventions.

Align strings and format currencies by culture

PadLeft/PadRight align strings within a fixed-width field. For culture-specific formatting, pass a CultureInfo instance to ToString — the runtime uses the culture’s number group separator, decimal separator, and currency symbol. This is essential for financial applications that must display amounts in the user’s locale.

This example aligns strings with padding and formats the same currency amount under several cultures.

string s = "hi";
Console.WriteLine($"'{s.PadLeft(10, '*')}'");
Console.WriteLine($"'{s.PadRight(10, '*')}'");
 
double amt = 1234567.89;
Console.WriteLine(amt.ToString("C2", new CultureInfo("en-US")));
Console.WriteLine(amt.ToString("C2", new CultureInfo("fr-FR")));
Console.WriteLine(amt.ToString("C0", new CultureInfo("ja-JP")));
Console.WriteLine(amt.ToString("C2", new CultureInfo("zh-CN")));
Console.WriteLine(amt.ToString("C2", new CultureInfo("pt-BR")));
Console.WriteLine(amt.ToString("C2", new CultureInfo("en-GB")));
'********hi'
'hi********'
$1,234,567.89
1 234 567,89 €
¥1,234,568
¥1,234,567.89
R$ 1.234.567,89
£1,234,567.89

Efficient String Building (StringBuilder)

Strategies for building strings without the O(n²) penalty of repeated concatenation. StringBuilder for loops, string.Join for collections, and + for small fixed concatenations.


flowchart TD
    A["How many strings<br/>are you combining?"] --> B{"2–5 fixed parts?"}
    B -->|Yes| C["Use + or $#quot;#quot;<br/>Compiler optimizes this"]
    B -->|No| D{"From a collection<br/>or array?"}
    D -->|Yes| E["Use string.Join<br/>Single allocation"]
    D -->|No| F{"Built in a loop<br/>or dynamically?"}
    F -->|Yes| G["Use StringBuilder<br/>O(n) vs O(n²)"]
    F -->|No| H["Use string.Concat<br/>No separator needed"]

StringBuilder

StringBuilder maintains a mutable internal char[] buffer, appending in place without allocating new string objects on every operation.

Compare StringBuilder vs string + performance

StringBuilder

  • Modifies an internal char buffer in place — Append/AppendLine/Insert/Replace
  • O(n) for n appends vs O(n²) for string + in a loop
  • Pre-allocate capacity for known sizes: new StringBuilder(1024)
  • Use for building strings in loops, large template assembly, CSV generation
  • For simple concatenation (2–5 strings), + or $"" is cleaner; for collections, string.Join

The benchmark below compares repeated += concatenation against StringBuilder.Append and reports a deterministic faster/slower summary instead of machine-specific timings.

This example compares repeated string += concatenation with StringBuilder.Append and reports a deterministic faster/slower result.

var sw = Stopwatch.StartNew();
string result = "";
for (int i = 0; i < 50000; i++)
    result += i.ToString();
sw.Stop();
var t1 = sw.Elapsed.TotalSeconds;
 
sw.Restart();
var sb = new StringBuilder();
for (int i = 0; i < 50000; i++)
    sb.Append(i);
result = sb.ToString();
sw.Stop();
var t2 = sw.Elapsed.TotalSeconds;
Console.WriteLine($"+ in loop len={result.Length}");
Console.WriteLine($"StringBuilder len={result.Length}");
Console.WriteLine($"StringBuilder faster? {t2 < t1}");
+ in loop len=238890
StringBuilder len=238890
StringBuilder faster? True

Build strings with Append, Insert, Replace, Remove

Append adds to the end, AppendLine adds a string plus a newline, Insert places text at a specific index, Replace swaps substrings in place, and Remove deletes a range. Pre-allocate capacity with the constructor to avoid internal buffer resizing when the final size is known.

This example modifies a StringBuilder with Append, AppendLine, Insert, Replace, and capacity inspection.

sb = new StringBuilder("Hello");
sb.Append(", ");
sb.Append("World!");
sb.AppendLine();
sb.AppendLine($"{42}");
sb.Insert(0, ">>> ");
sb.Replace("World", "C#");
Console.WriteLine(sb);
Console.WriteLine(sb.Length);
Console.WriteLine(sb.Capacity);
 
var sb2 = new StringBuilder(1000);
Console.WriteLine(sb2.Capacity);
>>> Hello, C#!
42
 
20
33
1000

Join and Concat

For assembling strings from collections or a small number of parts without StringBuilder.

Assemble strings with string.Join and string.Concat

string.Join inserts a separator between each element of a collection — the single most efficient way to build delimited output (CSV rows, log lines). string.Concat joins without a separator. For 2–5 known parts, the + operator is fine — the compiler optimizes small fixed concatenations into a single string.Concat call.

This example assembles strings with string.Join, string.Concat, and a small fixed + concatenation.

var items = Enumerable.Range(0, 5).Select(i => $"item_{i}");
Console.WriteLine($"'{string.Join(", ", items)}'");
Console.WriteLine($"'{string.Concat(Enumerable.Range(0, 5))}'");
 
string first = "Hello";
string last = "World";
string full = first + " " + last;
Console.WriteLine($"'{full}'");
'item_0, item_1, item_2, item_3, item_4'
'01234'
'Hello World'

High-performance string processing with Span

For hot paths where allocation pressure matters, ReadOnlySpan<char> and string.Create avoid intermediate string objects entirely.

Slice strings without allocation using AsSpan

string.AsSpan() returns a ReadOnlySpan<char> — a stack-only view into the string’s character buffer with zero allocation. Many BCL methods (int.Parse, Regex, MemoryExtensions) accept spans directly, allowing parsing and inspection without creating substring copies.

This example slices a delimited row with AsSpan() inside a local parser to avoid substring allocations.

string data = "2026-04-04|AAPL|182.50";
 
static (string Date, string Ticker, double Price) ParseRow(string row)
{
    ReadOnlySpan<char> span = row.AsSpan();
    ReadOnlySpan<char> date = span[..10];
    ReadOnlySpan<char> ticker = span[11..15];
    ReadOnlySpan<char> price = span[16..];
    return (date.ToString(), ticker.ToString(), double.Parse(price));
}
 
var parsed = ParseRow(data);
Console.WriteLine(parsed.Date);
Console.WriteLine(parsed.Ticker);
Console.WriteLine(parsed.Price);
2026-04-04
AAPL
182.5

Build strings with string.Create

string.Create allocates exactly one string and lets you write directly into its buffer via a SpanAction<char, TState>. This avoids the temporary allocations that StringBuilder or concatenation would produce.

This example builds one string directly into its destination buffer with string.Create.

string result = string.Create(11, (first: "Hello", sep: ' ', last: "World"), (span, state) =>
{
    state.first.AsSpan().CopyTo(span);
    span[5] = state.sep;
    state.last.AsSpan().CopyTo(span[6..]);
});
Console.WriteLine(result);
Hello World

Regular Expressions

The System.Text.RegularExpressions namespace provides pattern matching, extraction, replacement, and splitting. Always use verbatim strings (@"") for patterns to avoid double-escaping backslashes.

Matching and capturing

Finding patterns in text and extracting matched groups.

Find matches with Regex.Match, Matches, and IsMatch

Regex

  • Regex.Match — returns the first match (check .Success)
  • Regex.Matches — returns all matches as MatchCollection
  • Use @"" verbatim strings to avoid double-escaping backslashes
  • Groups[0] is the full match; Groups[1..n] are capture groups
  • For simple Contains/StartsWith checks, string methods are faster

Regex code usually fails through silent assumptions or repeated setup cost

The two most common issues are reading match data that never succeeded and recompiling the same pattern over and over in hot paths.

Missing success checks and repeated regex construction hide bugs and waste CPU

  • Not checking .Success before reading .Value — empty match is not null
  • Recompiling the same pattern in a loop — cache with new Regex()

Validate matches and reuse compiled patterns

Always check .Success before accessing match data, and cache compiled Regex instances outside loops:

// Anti-pattern: no Success check
string val = Regex.Match(text, @"\d+").Value;  // returns "" if no match — silent bug
 
// Correct: guard with Success
var m = Regex.Match(text, @"\d+");
if (m.Success) Console.WriteLine(m.Value);
 
// Anti-pattern: recompile every iteration
foreach (var line in lines)
    Regex.IsMatch(line, @"\d{3}-\d{4}");
 
// Correct: compile once, reuse
var pat = new Regex(@"\d{3}-\d{4}", RegexOptions.Compiled);
foreach (var line in lines)
    pat.IsMatch(line);

Regex.Match returns the first match; Regex.Matches returns all matches as a MatchCollection. Regex.IsMatch is a quick boolean check that avoids allocating match objects when you only need yes/no.

This example uses Regex.Match, Regex.Matches, and Regex.IsMatch to find one match, many matches, and a boolean result.

string text = "Contact us at support@email.com or sales@company.org. Call 123-456-7890 or 987-654-3210.";
 
var match = Regex.Match(text, @"\d{3}-\d{3}-\d{4}");
if (match.Success)
    Console.WriteLine($"{match.Value} at [{match.Index}:{match.Index + match.Length}]");
 
var phones = Regex.Matches(text, @"\d{3}-\d{3}-\d{4}");
var emails = Regex.Matches(text, @"[\w.+-]+@[\w-]+\.[\w.]+");
Console.WriteLine(string.Join(", ", phones.Select(m => m.Value)));
Console.WriteLine(string.Join(", ", emails.Select(m => m.Value)));
 
foreach (Match m in phones)
    Console.WriteLine($"  {m.Value} at [{m.Index}:{m.Index + m.Length}]");
 
Console.WriteLine(Regex.IsMatch("12345", @"^\d+$"));
Console.WriteLine(Regex.IsMatch("123a5", @"^\d+$"));
123-456-7890 at [59:71]
123-456-7890, 987-654-3210
support@email.com, sales@company.org.
  123-456-7890 at [59:71]
  987-654-3210 at [75:87]
True
False

Extract sub-matches with capture groups

Parentheses (...) in a pattern create numbered capture groups accessible via Groups[1], Groups[2], etc. (Groups[0] is always the full match). Named groups (?<name>...) use angle brackets (not P<> as in Python) and are accessed via Groups["name"].

This example extracts numbered and named capture groups from regex matches.

match = Regex.Match(text, @"(\d{3})-(\d{3})-(\d{4})");
if (match.Success)
{
    Console.WriteLine(match.Groups[0].Value);
    Console.WriteLine(match.Groups[1].Value);
    Console.WriteLine(match.Groups[2].Value);
    Console.WriteLine(match.Groups[3].Value);
}
 
match = Regex.Match(text, @"(?<user>[\w.+-]+)@(?<domain>[\w-]+\.[\w.]+)");
if (match.Success)
{
    Console.WriteLine(match.Groups["user"].Value);
    Console.WriteLine(match.Groups["domain"].Value);
}
123-456-7890
123
456
7890
support
email.com

Replace, split, and compilation

Transforming text with pattern-based replacement, splitting on patterns, and pre-compiling for performance.

Replace, split, and compile regex patterns

Regex.Replace substitutes matches — pass a string for static replacement, a MatchEvaluator lambda for dynamic transformation, or $1/$2 backreferences for group rearrangement. Regex.Split tokenizes on a pattern instead of a fixed delimiter. For patterns used repeatedly, instantiate new Regex(..., RegexOptions.Compiled) once — this precompiles the pattern to IL, avoiding re-parsing on each call.

This example replaces, splits, and reuses a compiled regex pattern.

Console.WriteLine(Regex.Replace(text, @"\d{3}-\d{3}-\d{4}", "***-***-****"));
 
Console.WriteLine(Regex.Replace("price: 50, qty: 3", @"\d+", m => (int.Parse(m.Value) * 2).ToString()));
 
Console.WriteLine(Regex.Replace("user@host", @"(\w+)@(\w+)", "$2/$1"));
 
Console.WriteLine(string.Join(", ", Regex.Split("Hello World. How are you? Fine!", @"[.!?]\s*").Where(part => part.Length > 0)));
Console.WriteLine(string.Join(", ", Regex.Split("a , b , c", @"\s*,\s*")));
 
var phonePat = new Regex(@"\d{3}-\d{3}-\d{4}", RegexOptions.Compiled);
Console.WriteLine(string.Join(", ", phonePat.Matches(text).Select(m => m.Value)));
Console.WriteLine(phonePat.Replace(text, "REDACTED"));
Contact us at support@email.com or sales@company.org. Call ***-***-**** or ***-***-****.
price: 100, qty: 6
host/user
Hello World, How are you, Fine
a, b, c
123-456-7890, 987-654-3210
Contact us at support@email.com or sales@company.org. Call REDACTED or REDACTED.

Syntax reference

Quick-reference tables for regex syntax elements.

Regex syntax — characters, quantifiers, anchors, groups

A condensed reference for .NET regex syntax. All patterns apply to System.Text.RegularExpressions.

CHARACTERS
.         Any character (except newline)
\d        Digit [0-9]              \D  Non-digit
\w        Word char [a-zA-Z0-9_]   \W  Non-word
\s        Whitespace [ \t\n\r]     \S  Non-whitespace
\b        Word boundary             \B  Non-word boundary
 
QUANTIFIERS
*         0 or more (greedy)        *?  0 or more (lazy)
+         1 or more (greedy)        +?  1 or more (lazy)
?         0 or 1 (optional)         ??  0 or 1 (lazy)
{n}       Exactly n                 {n,m}  Between n and m
{n,}      n or more                 {n,m}? Between n and m (lazy)
 
ANCHORS
^         Start of string/line      $   End of string/line
\A        Start of string only      \Z  End of string only
 
GROUPS
(...)     Capture group             (?:...)  Non-capture group
(?<name>...)  Named group (C# uses <> not P<>)
\1, \2    Backreference by number   \k<name> By name
 
LOOKAROUND
(?=...)   Lookahead (positive)      (?!...)  Lookahead (negative)
(?<=...)  Lookbehind (positive)     (?<!...) Lookbehind (negative)
 
CHARACTER CLASSES
[abc]     Any of a, b, c            [^abc]  NOT a, b, c
[a-z]     Range a through z         [a-zA-Z0-9]  Alphanumeric
|         OR (alternation)

Options and flags

RegexOptions flags modify matching behavior. Combine multiple flags with bitwise OR (|).

Control matching with RegexOptions flags

IgnoreCase enables case-insensitive matching. Multiline makes ^ and $ match line boundaries instead of string boundaries. Singleline makes . match newline characters (these two are independent despite the confusing names). IgnorePatternWhitespace allows formatting patterns with whitespace and inline # comments for readability.

This example applies RegexOptions flags such as IgnoreCase, Multiline, and Singleline.

string text = "Hello\nworld\nHELLO";
 
var ic = Regex.Matches(text, @"hello", RegexOptions.IgnoreCase);
Console.WriteLine(string.Join(", ", ic.Select(m => m.Value)));
 
var ml = Regex.Matches(text, @"^\w+", RegexOptions.Multiline);
Console.WriteLine(string.Join(", ", ml.Select(m => m.Value)));
 
Console.WriteLine(Regex.IsMatch(text, @"Hello.world", RegexOptions.Singleline));
Hello, HELLO
Hello, world, HELLO
True

Write readable patterns with IgnorePatternWhitespace

IgnorePatternWhitespace ignores unescaped whitespace in the pattern and enables # comments. This makes complex patterns self-documenting. Combine flags with bitwise OR to apply multiple options.

This example formats a verbose regex with IgnorePatternWhitespace and then prints the captured phone number.

var pattern = new Regex(@"
    (\d{3})     # area code
    [-.]        # separator
    (\d{3})     # first 3 digits
    [-.]        # separator
    (\d{4})     # last 4 digits
", RegexOptions.IgnorePatternWhitespace);
var m2 = pattern.Match("Call 123-456-7890");
if (m2.Success) Console.WriteLine($"{m2.Groups[1]}-{m2.Groups[2]}-{m2.Groups[3]}");
123-456-7890

This example combines IgnoreCase and Multiline flags to match multiple line starts.

var combined = Regex.Matches(text, @"^hello", RegexOptions.IgnoreCase | RegexOptions.Multiline);
Console.WriteLine(string.Join(", ", combined.Select(m => m.Value)));
Hello, HELLO

RegexOptions summary table

OptionEffect
IgnoreCaseCase-insensitive matching
Multiline^ and $ match line boundaries
Singleline. matches newline characters
IgnorePatternWhitespaceWhitespace in pattern is ignored, enables comments
CompiledPrecompiles regex to IL for repeated use
RightToLeftSearch proceeds right to left
ExplicitCaptureOnly named groups capture, (...) becomes non-capturing
NonBacktracking.NET 7+, guaranteed linear time (no catastrophic backtracking)

Common patterns

Ready-to-use validation patterns for frequently matched formats.

Common regex patterns for validation

PatternRegex
Email^[\w.+-]+@[\w-]+\.[\w.]+$
URLhttps?://[\w./\-?=&#]+
IPv4\b\d{1,3}(\.\d{1,3}){3}\b
Date (YYYY-MM-DD)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])
Time (HH:MM)(?:[01]\d|2[0-3]):[0-5]\d
Hex color^#[0-9a-fA-F]{6}$
Phone (US)\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}
Zip code (US)\d{5}(-\d{4})?
Strong password^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$

This example demonstrates common regex patterns for validation.

var patterns = new (string name, string pat)[] {
    ("email",           @"^[\w.+-]+@[\w-]+\.[\w.]+$"),
    ("URL",             @"https?://[\w./\-?=&#]+"),
    ("IPv4",            @"\b\d{1,3}(\.\d{1,3}){3}\b"),
    ("date YYYY-MM-DD", @"\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])"),
    ("time HH:MM",      @"(?:[01]\d|2[0-3]):[0-5]\d"),
    ("hex color",       @"^#[0-9a-fA-F]{6}$"),
    ("phone US",        @"\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}"),
    ("zip code US",     @"\d{5}(-\d{4})?"),
    ("strong password", @"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$"),
};
foreach (var (pname, pat) in patterns)
    Console.WriteLine($"{pname,-20}: {pat}");
email               : ^[\w.+-]+@[\w-]+\.[\w.]+$
URL                 : https?://[\w./\-?=&#]+
IPv4                : \b\d{1,3}(\.\d{1,3}){3}\b
date YYYY-MM-DD     : \d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])
time HH:MM          : (?:[01]\d|2[0-3]):[0-5]\d
hex color           : ^#[0-9a-fA-F]{6}$
phone US            : \(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}
zip code US         : \d{5}(-\d{4})?
strong password     : ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$

Source-generated regex (.NET 7+)

The [GeneratedRegex] attribute replaces RegexOptions.Compiled with build-time source generation — faster startup, AOT-compatible, and trim-safe.

Use GeneratedRegex for compile-time regex generation

Decorate a partial method returning Regex with [GeneratedRegex]. The source generator emits optimized matching code at compile time, eliminating the runtime IL-emit cost of RegexOptions.Compiled. The generated code is also compatible with Native AOT and trimming, which Compiled is not.

When to use GeneratedRegex vs Compiled

Use [GeneratedRegex] for all patterns known at compile time — it is strictly better than Compiled in every dimension (startup, throughput, AOT). Reserve new Regex(..., RegexOptions.Compiled) only when the pattern is constructed dynamically at runtime. Analyzer SYSLIB1045 automatically flags existing Regex usages that can be converted, with a one-click fixer in Visual Studio.

Declare and call a source-generated regex

The containing class must be partial so the source generator can emit the matching implementation. The [GeneratedRegex] attribute takes the pattern and optional RegexOptions. Call the generated method to get a cached Regex instance, then use it like any other Regex. The example below includes a manual fallback implementation so the snippet remains executable in script hosts that do not run source generators.

This example declares a [GeneratedRegex] partial method and calls it through a script-safe fallback implementation.

partial class MyRegexHelper
{
    [GeneratedRegex(@"\d{3}-\d{3}-\d{4}", RegexOptions.IgnoreCase)]
    public static partial Regex PhonePattern();
 
    // Script-host fallback: a normal project build would get this body from the source generator.
    public static partial Regex PhonePattern() => new(@"\d{3}-\d{3}-\d{4}", RegexOptions.IgnoreCase);
}
 
string phoneText = "Call 123-456-7890";
var match = MyRegexHelper.PhonePattern().Match(phoneText);
Console.WriteLine(match.Value);
123-456-7890

C# String Workloads

Applicability

  • Production string processing — C#‘s static typing, Span<char>, and StringBuilder provide predictable performance for high-throughput text handling in services and pipelines.
  • Zero-allocation parsingReadOnlySpan<char> with int.Parse(span) and string.AsSpan() enables parsing without substring allocations — critical for hot paths processing millions of records.
  • Complex regex with compile-time safety[GeneratedRegex] (C# 11/.NET 7+) produces optimized, AOT-compatible matching code with compile-time pattern validation.
  • Culture-aware string operationsStringComparison enum and CultureInfo provide fine-grained control over case folding, sorting, and collation rules.
  • Windows and .NET ecosystem — native integration with ASP.NET, EF Core, and Azure SDKs where string APIs are designed for the platform.

Limitations

  • Ad-hoc text exploration — C#‘s ceremony makes quick regex experimentation slower than Python’s REPL. Prototype patterns in Python, then port to C#.
  • Dynamic scripting — Python’s split()/join() with list comprehensions is more concise for one-off text transformations.
  • No built-in translate() equivalent — C# lacks Python’s str.maketrans/str.translate for single-pass multi-character substitution. Use Regex.Replace or manual StringBuilder loops.
  • UTF-16 surrogate pair complexity — C# strings are UTF-16 internally. Characters outside the Basic Multilingual Plane (emoji, rare scripts) are stored as surrogate pairs, making string[i] return half a character. Use StringInfo.GetTextElementEnumerator() for correct grapheme iteration.

Common Traps and Safe Patterns

Build Large Strings with the Right Tool

Large string assembly needs an explicit buffering strategy

Repeated concatenation looks harmless in small examples but scales badly under real workloads.

String concatenation in loops is O(n^2)

Each += allocates a new string and copies all previous content. For large iteration counts, StringBuilder is dramatically faster.

Use a builder or join strategy

Use new StringBuilder(estimatedCapacity) for loops. Use string.Join() for collections. Reserve + for a few fixed parts that the compiler can fold into string.Concat.

Treat Interpolated Commands as Injection Risks

String interpolation becomes dangerous the moment text crosses into a command language

SQL, shells, and similar interpreters do not distinguish trusted values from concatenated text unless you give them structured parameters.

Interpolated SQL and shell commands are injection-prone

$"SELECT * FROM users WHERE id = {userId}" is vulnerable to SQL injection, and the same pattern is unsafe for shell command construction.

Pass structured parameters instead of interpolated text

SQL: command.Parameters.AddWithValue("@id", userId). Shell: pass arguments as arrays or structured argument objects to Process.Start().

Specify Comparison Semantics Explicitly

String equality has to state whether case and culture matter

Relying on == bakes in ordinal case-sensitive semantics whether or not that matches the domain.

== hides the comparison policy

== is always ordinal case-sensitive. Comparing user input or file paths without specifying OrdinalIgnoreCase causes false negatives.

Call comparison APIs with an explicit StringComparison

Use string.Equals(a, b, StringComparison.OrdinalIgnoreCase) for case-insensitive matching. For culture-aware sorting or display comparisons, use the relevant CurrentCulture option instead.

Reuse Regex Compilation on Repeated Paths

Repeated regex use should not pay the parse cost on every call

If the same pattern runs in a loop or hot path, startup cost becomes part of the steady-state cost.

Default regex construction reparses the pattern repeatedly

new Regex(pattern) interprets the pattern on every call. For repeated use, this is slower than generated or compiled variants.

Use generated or compiled regex for repeated patterns

Use [GeneratedRegex] on .NET 7+ for compile-time-known patterns. Use new Regex(pattern, RegexOptions.Compiled) for dynamic patterns that are still reused.

C# Strings Recommendations

  • Use StringBuilder for any string construction involving loops or more than ~5 concatenations.
  • Use Span<char> / ReadOnlySpan<char> for hot-path parsing — avoids Substring() allocations.
  • Use [GeneratedRegex] for all compile-time-known patterns — better performance, AOT-compatible, and the analyzer SYSLIB1045 flags opportunities.
  • Always specify StringComparison in Equals(), IndexOf(), StartsWith(), and Compare() — never rely on the default.
  • Prefer string.IsNullOrWhiteSpace() over string.IsNullOrEmpty() — whitespace-only strings are almost always invalid input.
  • Use raw string literals ("""...""", C# 11+) for JSON templates, SQL, regex patterns, and any string with heavy escaping.
  • Use string.Create() (.NET 5+) for advanced zero-allocation string construction with a SpanAction<char> callback.
  • Avoid string.Format() in new code — prefer $"" interpolation. Use string.Format() only when the template is a runtime variable.

C# Strings Troubleshooting

NullReferenceException on string methods

string instance methods such as .Length, .Trim(), and .Replace() throw on null. Guard with string.IsNullOrEmpty() or a null-conditional fallback before you call the method.

This example checks a nullable string before trimming it.

string? input = null;
Console.WriteLine(string.IsNullOrEmpty(input));
Console.WriteLine(input?.Trim() ?? "(missing)");
True
(missing)

s[i] throws IndexOutOfRangeException

string indexing is 0-based and stops at s.Length - 1. Check the bounds first or slice with s.AsSpan() when you only need a safe range.

This example guards the index and then uses AsSpan() for a safe slice.

string s = "hello";
int index = 7;
Console.WriteLine(index < s.Length ? s[index] : '?');
Console.WriteLine(s.AsSpan(1, 3).ToString());
?
ell

== misses case-insensitive matches

== is ordinal and case-sensitive. Use string.Equals(a, b, StringComparison.OrdinalIgnoreCase) when the comparison should ignore case.

This example compares the same two values with both operators.

string a = "Hello";
string b = "hello";
Console.WriteLine(a == b);
Console.WriteLine(string.Equals(a, b, StringComparison.OrdinalIgnoreCase));
False
True

Groups["name"] is empty

A named group only carries text when it participated in the match. Check group.Success before reading .Value.

This example shows a named group that did not match any digits.

using System.Text.RegularExpressions;
 
var match = Regex.Match("ID=", @"ID=(?<id>\d+)");
var group = match.Groups["id"];
Console.WriteLine(group.Success);
Console.WriteLine($"'{group.Value}'");
False
''

ArgumentException means the regex is invalid

Regex syntax errors surface as ArgumentException when the pattern is parsed. Validate the pattern earlier, or rely on [GeneratedRegex] for compile-time patterns.

This example catches the parse error from an invalid pattern.

using System.Text.RegularExpressions;
 
try
{
    _ = new Regex(@"(\d+");
    Console.WriteLine("parsed");
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.GetType().Name);
}
ArgumentException

StringBuilder separators should not trail

Appending the separator after every item creates an extra suffix. Use string.Join() or add the separator only after the first item.

This example uses string.Join() to avoid a trailing separator.

string[] items = new[] { "a", "b", "c" };
Console.WriteLine(string.Join(", ", items));
a, b, c

s[i] splits surrogate pairs

A char is one UTF-16 code unit, not always one visible character. Use StringInfo.GetTextElementEnumerator() when you need grapheme-safe iteration.

This example enumerates text elements instead of raw UTF-16 code units.

using System.Globalization;
 
string text = "A🙂B";
var enumerator = StringInfo.GetTextElementEnumerator(text);
while (enumerator.MoveNext())
{
    Console.WriteLine(enumerator.GetTextElement());
}
A
🙂
B

Encoding.UTF8 should be explicit

Default encoding can differ from the file’s actual bytes. Specify Encoding.UTF8 when you read or write text that must round-trip cleanly.

This example encodes and decodes text with Encoding.UTF8.

using System.Text;
 
byte[] bytes = Encoding.UTF8.GetBytes("café");
Console.WriteLine(Encoding.UTF8.GetString(bytes));
café

Regex.Replace() slows down in tight loops

A repeatedly parsed pattern wastes work. Cache the regex or use [GeneratedRegex] for patterns that are known at compile time.

This example reuses a compiled regex to replace phone numbers.

using System.Text.RegularExpressions;
 
var phonePat = new Regex(@"\d{3}-\d{3}-\d{4}", RegexOptions.Compiled);
Console.WriteLine(phonePat.Replace("Call 123-456-7890", "REDACTED"));
Call REDACTED

string.Format() needs matching placeholder indices

FormatException usually means the composite-format indices do not line up with the argument list. Recheck the placeholders or switch to interpolation when the template is static.

This example uses matching indices and arguments.

Console.WriteLine(string.Format("{0} + {1} = {2}", 2, 3, 5));
2 + 3 = 5

C# Strings Cross-References

  • Data Architecture: SerializationSerialization Formats for when string encoding choices matter in pipelines