Back to Blog
C#

C# String Usage: Methods, Immutability, and Performance

c# string usage: Explore practical C# string usage: common methods, immutability, concatenation, interpolation, and performance tradeoffs for better code.

C# stringsStringBuilderstring interpolationstring immutabilitystring performance
Diagram illustrating C# string immutability and concatenation performance

When you write var s = "Hello" + " " + "World"; in C#, the compiler optimizes constant concatenation into a single string at compile time. But once variables enter the expression, each + operation creates a new string instance at runtime. This behavior stems from string immutability, and it shapes how you should approach C# string usage in everyday code.

Why String Immutability Shapes Your Code

C# strings are immutable reference types. Any method that appears to modify a string—ToUpper, Replace, Substring—actually returns a new string, leaving the original untouched. This design has safety benefits: strings can be shared across threads without locking, and hash codes can be cached. But it also means that careless string manipulation can lead to excessive allocations.

Consider this loop:

string result = ""; for (int i = 0; i < 1000; i++) { result += i.ToString(); }

Each iteration creates a new string that holds the entire accumulated content, copying all previous characters. The total allocation cost grows quadratically with the number of iterations. For small loops this is harmless, but for larger data sets it becomes a measurable bottleneck.

Common String Methods and When to Use Them

The .NET base class library provides a rich set of string methods. Knowing which one fits a scenario reduces both code complexity and the chance of subtle bugs.

  • Substring(startIndex, length) extracts a portion of the string. It allocates a new string, so use it sparingly when you only need to inspect a part.
  • IndexOf and Contains locate substrings without allocating. Contains is syntactic sugar for IndexOf with a StringComparison parameter.
  • Replace returns a new string with all occurrences replaced. It allocates a new string even if no replacement occurs, so avoid calling it repeatedly on the same source.
  • Split returns an array of substrings. It is convenient for parsing, but be aware that each element is a new string. For high-throughput parsing, consider Span<char> or ReadOnlySpan<char> to avoid allocations.
  • Join is the most efficient way to combine a collection of strings with a separator. It precomputes the total length and writes directly into a single buffer.

Here is an example that combines several methods:

string input = "apple,banana,cherry"; string[] parts = input.Split(','); string normalized = string.Join(" | ", parts).ToUpperInvariant();

This works, but note that Split allocates three strings, Join allocates one, and ToUpperInvariant allocates another. If you only need to display the result, that is acceptable. If this code runs in a tight loop, you might want a more allocation-friendly approach.

Concatenation: +, string.Concat, and string.Join

The + operator is the most readable way to combine strings, and the compiler translates it into a call to string.Concat. For two or three operands, string.Concat is efficient because it calculates the final length and allocates once. However, chaining many + operations in a loop causes repeated allocations.

string.Join is not just for arrays with separators. It also works with IEnumerable<string> and is often the best choice when you need to combine a list with a delimiter. It avoids the intermediate strings that a +-based approach would create.

var names = new List<string> { "Alice", "Bob", "Charlie" }; string csv = string.Join(",", names);

If you need to build a string from a large collection without a delimiter, StringBuilder is usually the right tool.

StringBuilder for Repeated Modifications

StringBuilder maintains a mutable buffer and appends without creating a new string for every operation. It is ideal for loops that build a string incrementally, such as constructing a large SQL query or a CSV file.

var builder = new StringBuilder(); for (int i = 0; i < 1000; i++) { builder.Append(i); builder.Append(','); } string result = builder.ToString();

The ToString() call at the end creates a single string from the buffer. StringBuilder is not always faster than string.Concat for a small, fixed number of concatenations; the overhead of creating the builder can outweigh the savings. Use it when the number of appends is unknown or large.

String Interpolation and Formatting

String interpolation, introduced in C# 6, is the most readable way to embed expressions into a string:

string name = "Alice"; int age = 30; string message = $"{name} is {age} years old.";

The compiler transforms this into a string.Format call, or a FormattableString if you explicitly target that type. Interpolation supports format specifiers and alignment, just like composite formatting:

string price = $"{value:C2}"; // currency with two decimals

When you need to reuse a format string multiple times with different arguments, string.Format or a cached FormattableString can avoid repeated parsing of the format template. However, for most inline cases, interpolation is both clear and efficient enough.

String Comparison and Culture

String comparison in C# is not as straightforward as it might seem. The default == operator performs ordinal comparison, which is fast and case-sensitive. The Equals method, when called without a StringComparison, also uses ordinal rules for string instances. But methods like string.Compare default to culture-sensitive comparison, which can produce different results depending on the current culture.

string a = "Straße"; string b = "STRASSE"; bool ordinal = a.Equals(b, StringComparison.OrdinalIgnoreCase); // false bool culture = a.Equals(b, StringComparison.CurrentCultureIgnoreCase); // true in many cultures

For most application logic, especially when comparing identifiers, file paths, or configuration keys, use StringComparison.Ordinal or OrdinalIgnoreCase. Culture-sensitive comparison is appropriate for user-facing sorting and display, where linguistic rules matter. Always pass an explicit StringComparison to avoid ambiguity and to signal intent to future maintainers.

Performance Considerations and Allocation Costs

The biggest performance trap in C# string usage is unintentional allocation. Every string method that returns a new string allocates memory, and repeated allocations put pressure on the garbage collector. When you write performance-sensitive code, consider the following:

  • Use string.Concat or string.Join instead of a chain of + when you know all parts in advance.
  • Use StringBuilder for dynamic loops.
  • Use ReadOnlySpan<char> and MemoryExtensions methods to avoid allocations when parsing or searching.
  • Use string.Create when you need to construct a string from a known set of characters with custom logic, as it allows you to fill a buffer directly.
string result = string.Create(5, 0, (span, _) => { span[0] = 'H'; span[1] = 'e'; span[2] = 'l'; span[3] = 'l'; span[4] = 'o'; });

string.Create is an advanced API that avoids an extra copy when you already have the final content. It is not needed in most code, but it is useful in high-performance libraries.

Choosing the Right String Approach

The correct choice depends on the context. For a one-off concatenation of a few variables, + is clear and fine. For building a string from a collection, use string.Join. For a loop that appends many times, use StringBuilder. For parsing and searching without allocations, use spans. And always be explicit about comparison rules with StringComparison. By matching the approach to the workload, you keep code readable and avoid surprising performance problems.

c# string usage: Practical Usage and Code Examples | RYUSLOG DEV