Strings
C# provides string (an immutable reference type representing a sequence of UTF-16 code units), StringBuilder (a mutable builder), and a rich set of string operations in System.String. The string type is reference-typed but exhibits value-like behaviour: == compares characters, GetHashCode is content-based, and the type is immutable. The language admits several string-literal forms — regular, verbatim, interpolated, raw — each suited to different use cases. Formatting has evolved from string.Format through composite formatting to interpolation and the modern IFormatProvider-based culture handling.
string and immutability
string instances are immutable: every operation that appears to modify a string returns a new string instead:
string s = "hello";
string t = s.ToUpper(); // returns a new string; s is unchanged
Console.WriteLine(s); // hello
Console.WriteLine(t); // HELLO
The immutability has consequences:
- Strings can be safely shared across threads without synchronisation.
- Equality comparison compares the contents; two distinct
stringinstances with the same characters are equal. - The CLR may intern string literals — store one shared copy of identical literals — so reference equality often holds for literals even though it is not guaranteed.
- Building a string by concatenation in a loop allocates one new string per iteration; the cost grows quadratically. The defence is
StringBuilder.
string a = "hello";
string b = "hello";
bool eq = a == b; // true: content comparison
bool ref_eq = ReferenceEquals(a, b); // typically true (interned), but not guaranteed
String literals
Four literal forms:
Regular
"hello"
"line1\nline2"
"quote: \""
Backslash escapes are interpreted. The standard escapes are essentially C’s: \n, \t, \r, \\, \", \', \0, \xHH, \uXXXX, \UXXXXXXXX.
Verbatim
@"line1
line2"
@"C:\Users\alice" // backslashes are literal
@"quote: """"" // doubled "" represents a single "
The @ prefix turns off backslash escapes; the only escape is the doubled quote. Verbatim literals are conventional for file paths, regular expressions, and any literal with substantial backslash content.
Interpolated
string name = "alice";
int age = 30;
string s = $"{name} is {age} years old";
The $ prefix admits expressions inside {…}. The expressions are evaluated, formatted, and concatenated into the resulting string. Format specifiers may follow a colon:
double pi = 3.14159;
string s = $"pi to two places: {pi:F2}"; // "pi to two places: 3.14"
string h = $"hex: {255:X4}"; // "hex: 00FF"
Interpolation may be combined with verbatim: $@"…" or @$"…" — both forms are accepted; C# 8 introduced @$"…" and earlier versions required $@"…".
Raw string literals
C# 11 introduced raw string literals, denoted by three or more double quotes:
string json = """
{
"name": "alice",
"age": 30
}
""";
string regex = """\d{4}-\d{2}-\d{2}""";
Raw literals interpret no escapes, accept any number of internal quotes (as long as the delimiter has more), and (when multi-line) align the content to the closing delimiter’s indentation. They are the conventional choice for JSON, regular expressions, SQL, and any literal where backslashes or quotes would otherwise need escaping.
Interpolated raw literals combine the two forms:
string s = $$"""
{
"name": "{{name}}",
"age": {{age}}
}
""";
The number of $ signs determines how many { braces are needed to begin an interpolation. Two $ means {{…}} is interpolation and { is literal; three $ means {{{…}}}, and so on. The mechanism is the conventional way to build strings that contain literal braces alongside interpolated expressions.
StringBuilder
Building a string by concatenation in a loop allocates one string per iteration; for large outputs the cost is substantial. StringBuilder (in System.Text) is a mutable buffer:
using System.Text;
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.Append("line ").Append(i).Append('\n');
}
string result = sb.ToString();
The principal operations:
Append(value)— append a value’s string representation.AppendLine(value)— append plus a newline.AppendFormat(fmt, args...)— composite formatting, likestring.Format.Insert(pos, value)— insert at position.Remove(pos, count)— remove a range.Replace(old, new)— replace all occurrences.Clear()— reset to empty.ToString()— produce the final immutable string.
For building strings of known final size, string.Create (with a span-based callback) is faster still:
string s = string.Create(length, state, (span, st) => {
/* fill span using st */
});
The construction is rare in user code; the framework uses it internally for the most performance-sensitive paths.
The string operations
The System.String class exposes a substantial set of instance and static methods:
| Operation | Effect |
|---|---|
s.Length | Number of UTF-16 code units. |
s[i] | Element access (one code unit). |
s.IndexOf(value) / s.LastIndexOf(value) | Position of substring or character. |
s.Contains(value) | Substring containment. |
s.StartsWith(value) / s.EndsWith(value) | Prefix/suffix tests. |
s.Substring(start, length) | Returns a sub-string. |
s.Replace(old, new) | Replaces all occurrences. |
s.Trim() / s.TrimStart() / s.TrimEnd() | Strip whitespace. |
s.ToUpper() / s.ToLower() | Case conversion. |
s.ToUpperInvariant() / s.ToLowerInvariant() | Culture-independent case conversion. |
s.Split(separator) | Tokenise. |
string.Join(sep, parts) | Concatenate with a separator. |
string.Concat(values) | Concatenate without a separator. |
string.IsNullOrEmpty(s) / string.IsNullOrWhiteSpace(s) | Common null/empty tests. |
s.PadLeft(n, ch) / s.PadRight(n, ch) | Pad to a width. |
s.CompareTo(other) / string.Compare(a, b) | Comparison. |
s.Equals(other, StringComparison) | Comparison with explicit culture/case rules. |
The conventional discipline:
- Use
StringComparison.Ordinalfor symbolic comparisons (file paths, identifiers, programmatic strings). - Use
StringComparison.OrdinalIgnoreCasefor case-insensitive symbolic comparisons. - Use
StringComparison.CurrentCultureandCurrentCultureIgnoreCasefor user-facing comparisons. - Avoid
s.ToLower() == t.ToLower()for case-insensitive comparison; the culture-specificToLowermay produce different results in different locales. Usestring.Equals(s, t, StringComparison.OrdinalIgnoreCase)instead.
Encoding and the UTF-16 underlying representation
C# strings store UTF-16 code units. A char is a 16-bit value; characters outside the Basic Multilingual Plane (code points above U+FFFF) are represented by a surrogate pair — two char values that together encode one code point.
string s = "🎉";
int len = s.Length; // 2: the emoji is a surrogate pair
foreach (char c in s) {
/* iterates each code unit, not each user-perceived character */
}
// Iterate code points:
TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(s);
while (enumerator.MoveNext()) {
string element = (string)enumerator.Current;
}
The discipline:
s.Lengthis the count of code units, not characters.s[i]is a code unit; for grapheme-aware iteration useStringInfoorRune(System.Text.Rune, C# 9).- Conversion to and from byte arrays uses the
Encodingclass:Encoding.UTF8.GetBytes(s),Encoding.UTF8.GetString(bytes).
using System.Text;
byte[] utf8 = Encoding.UTF8.GetBytes("hello, 世界");
string back = Encoding.UTF8.GetString(utf8);
The conventional encoding choice for new code is UTF-8; legacy code may use the system’s default encoding (Encoding.Default) or a specific code page.
Formatting
C# admits four formatting interfaces:
string.Format
The classic composite formatting:
string s = string.Format("{0} is {1} years old", name, age);
string p = string.Format("pi: {0:F2}", 3.14159);
The format string contains positional placeholders {n[:format]}. The format specifier follows the colon; the same vocabulary as Console.WriteLine’s formatting.
Interpolation
The conventional contemporary form:
string s = $"{name} is {age} years old";
string p = $"pi: {3.14159:F2}";
Interpolation is compile-time-checked (the compiler knows the argument types) and frequently faster than string.Format because the compiler can avoid the parameter-array allocation.
Console.Write and Console.WriteLine
Direct output of formatted strings:
Console.WriteLine($"hello, {name}");
Console.WriteLine("pi: {0:F2}", 3.14);
Both interpolation and composite-formatting forms are supported.
Format specifiers
The format specifier (after the colon) is type-specific. Common cases:
| Specifier | Type | Effect |
|---|---|---|
D | integer | decimal |
D5 | integer | decimal, padded to 5 digits |
X | integer | hexadecimal (uppercase) |
x | integer | hexadecimal (lowercase) |
N | numeric | grouped decimal (1,000.00) |
C | numeric | currency (locale-aware) |
P | numeric | percent |
F2 | floating | fixed, 2 decimal places |
E | floating | scientific |
G | floating | general (the default) |
yyyy-MM-dd | DateTime | ISO date |
HH:mm:ss | DateTime | 24-hour time |
R | DateTime | round-trip format (RFC 1123) |
DateTime now = DateTime.Now;
string ts = $"{now:yyyy-MM-dd HH:mm:ss}";
Span<char> and the modern parsing/formatting interfaces
Span<char> (C# 7.2 and later) is a stack-only view over contiguous character data:
ReadOnlySpan<char> input = "hello, world".AsSpan();
ReadOnlySpan<char> first = input.Slice(0, 5); // "hello"; no allocation
The standard library exposes Span<char>-based parsing and formatting that avoids string allocations:
int n;
bool ok = int.TryParse("42".AsSpan(), out n);
Span<char> buffer = stackalloc char[16];
bool wrote = n.TryFormat(buffer, out int written);
ReadOnlySpan<char> result = buffer.Slice(0, written);
The Span<char> interface is the high-performance counterpart to the conventional string operations; production code uses it in hot paths where allocation pressure matters.
Comparison and culture
String comparison in C# admits three axes: ordinal vs culture-aware; case-sensitive vs case-insensitive; default culture vs invariant culture.
The conventional choices:
| Need | StringComparison |
|---|---|
| Compare programmatic strings (file paths, identifiers) | Ordinal |
| Compare programmatic strings, case-insensitively | OrdinalIgnoreCase |
| Compare user-facing text in the user’s locale | CurrentCulture |
| Compare user-facing text in the user’s locale, case-insensitively | CurrentCultureIgnoreCase |
| Compare user-facing text in a culture-independent manner | InvariantCulture |
| Same, case-insensitively | InvariantCultureIgnoreCase |
bool eq = string.Equals(path1, path2, StringComparison.OrdinalIgnoreCase);
int cmp = string.Compare(name1, name2, StringComparison.CurrentCulture);
The default s.Equals(t) and s == t use ordinal comparison. The default s.CompareTo(t) uses current culture, which is occasionally surprising — the same comparison may produce different results for different users. Production code typically specifies the comparison explicitly.