Skip to content

~ / blog / csharp-strings-and-stringbuilder

C# Strings: Immutability, StringBuilder and the Loop That Kills Performance

C# 10 min read

The last post put string in an awkward spot: a reference type that behaves like a value type, with == comparing characters instead of identity. That is not an inconsistency, it is a deliberate design, and it explains the single most common performance mistake in C# codebases. This post is about what a string really is, why concatenating in a loop is quadratic, and how to compare strings without introducing a bug that only appears in Turkey.

A string is immutable, and that changes everything

var title = "1984";
title.ToUpper();
Console.WriteLine(title);       // 1984, unchanged

Every method on string that looks like it modifies the string does not. ToUpper, Trim, Replace, Substring, Insert, PadLeft: all of them return a new string and leave the original untouched. There is no method anywhere in System.String that mutates the instance.

title = title.ToUpper();        // you must assign the result

That is not an oversight, it is the whole design. Once a string exists, its characters never change for as long as it lives. The benefits are worth listing, because they explain the cost that follows:

  • Safe to share. You can hand a string to any method, including across threads, and know nobody can alter it behind your back. Compare that with handing out a List<T>.
  • Safe as a dictionary key. A key whose hash code could change would silently corrupt a Dictionary. Immutability makes the hash stable forever, so Dictionary<string, T> is safe by construction.
  • Cacheable hash codes, and safe interning, which is next.
  • Value semantics. Because a string can never change, comparing two by content is always meaningful, so == is overloaded to do exactly that.

Interning: why two identical literals are the same object

var a = "1984";
var b = "1984";
Console.WriteLine(ReferenceEquals(a, b));      // True, the same object

var c = new string(['1', '9', '8', '4']);
Console.WriteLine(ReferenceEquals(a, c));      // False, a separate object
Console.WriteLine(a == c);                     // True, same characters

The runtime keeps an intern pool: a table of unique string literals. Every identical literal in your entire assembly points to one shared instance. This is only safe because strings are immutable: sharing a mutable object between unrelated code would be a disaster.

Two practical consequences. First, ReferenceEquals on strings tells you nothing useful; always use == or Equals. Second, strings built at runtime (from concatenation, a file, a database, or user input) are not interned, so never assume reference identity even when the content looks like a literal you wrote.

The loop that kills performance

Here is the mistake, and it is everywhere:

var report = "";
foreach (var book in books)
{
    report += $"{book.Title} - {book.Price}\n";     // allocates a whole new string, every iteration
}

Because strings are immutable, report += x cannot append. It must allocate a brand-new string big enough for both, copy the old content in, copy the new content in, and abandon the old one for the garbage collector.

Do the arithmetic. On iteration 1 you copy ~20 characters. On iteration 500 you copy ~10,000 characters, because the accumulated string is that long by then. Total work is proportional to n², and total garbage is proportional to n² too. With 5 books it is invisible. With 10,000 rows it is hundreds of megabytes of allocation and a method that takes seconds.

StringBuilder fixes it:

var sb = new StringBuilder();
foreach (var book in books)
{
    sb.Append(book.Title).Append(" - ").Append(book.Price).Append('\n');
}
var report = sb.ToString();

StringBuilder keeps a mutable char[] buffer and writes into it, growing by doubling when full. Appending is amortised O(1), so the loop is linear, and exactly one string is produced at the end by ToString(). Same output; on 10,000 rows, orders of magnitude less work.

When not to use it. StringBuilder is itself an allocation, so for a fixed, small number of pieces the plain version is faster and clearer:

var label = book.Title + " by " + book.Author;                  // fine
var label = $"{book.Title} by {book.Author}";                   // fine, and preferred

The compiler turns a chain of + on a single expression into one String.Concat call, which allocates once with the total length known upfront. Interpolation compiles to DefaultInterpolatedStringHandler, which is essentially a pooled StringBuilder and avoids boxing the values.

The rule: the number of concatenations matters, not the size. Fixed count in one expression, use + or interpolation. Unknown count in a loop, use StringBuilder.

Also worth knowing for the common cases:

string.Join(", ", books.Select(b => b.Title));   // best tool for "list of things with separators"
string.Concat(parts);                            // best for "glue these together, no separator"

string.Join computes the total size first and fills a single buffer. Reaching for a StringBuilder to build a comma-separated list is usually reinventing it, slower and with more code.

Comparison, and the bug that only appears in Turkey

This is where strings stop being about performance and start being about correctness.

"HELLO".Equals("hello", StringComparison.OrdinalIgnoreCase);   // true
"HELLO".ToLower() == "hello";                                  // true, but allocates and is culture-sensitive

string.Equals and Compare accept a StringComparison, and the choice matters:

  • Ordinal: compare the raw UTF-16 code units. Fast, exact, deterministic everywhere on Earth.
  • OrdinalIgnoreCase: the same, ignoring ASCII-style case. Fast, and still deterministic.
  • CurrentCulture: compare the way a human in the current locale would sort words. Slower, locale-dependent.
  • InvariantCulture: culture rules, but a fixed pseudo-culture.

The default for == and Equals(string) is ordinal, which is the right default. The trap is ToLower()/ToUpper() and CompareTo, which use the current culture unless you say otherwise. In Turkish, the lowercase of I is ı (dotless), not i. So:

if (input.ToLower() == "identity") { ... }   // false on a Turkish machine

This is the famous “Turkish I problem”, and it has broken real authentication and file-handling code. It fails only on machines with a specific locale, which means it passes every test you run and breaks for one customer.

The rules that avoid all of it:

  • Comparing identifiers, keys, file paths, protocol tokens, enum names, anything machine-facing: use StringComparison.Ordinal or OrdinalIgnoreCase, explicitly.
  • Sorting or comparing text a human will read in a UI: use CurrentCulture.
  • Never use ToLower() for comparison. Equals(x, y, StringComparison.OrdinalIgnoreCase) is faster (no allocation) and correct.
  • Use string.IsNullOrEmpty / IsNullOrWhiteSpace instead of == "" or .Length == 0 guarded by null checks.

Building strings without allocating at all

Two modern additions worth knowing, because they come up in performance conversations.

Span<char> and ReadOnlySpan<char> give you a window over existing memory with no copy. Substring allocates a new string; AsSpan().Slice() does not:

ReadOnlySpan<char> span = title.AsSpan(0, 4);   // no allocation, just a view

Parsers, log formatters and hot paths in ASP.NET Core are written this way, which is a large part of why modern .NET is fast. int.Parse, DateTime.TryParse and friends all have span overloads.

string.Create builds a string of known length in a single allocation, writing directly into the final buffer. Niche, but it is the answer when someone asks how to concatenate with zero waste.

For everyday code, the practical takeaway is smaller: prefer Span-friendly APIs when they exist (AsSpan, Contains(char), StartsWith(char)), and do not slice a huge string with Substring inside a loop.

The same idea inside Google, Meta and the JVM

Immutable strings and a mutable builder is one of the most copied pairings in language design.

Java (Netflix, Android). Identical model, identical trap, and the same fix, Java even has two builders:

StringBuilder sb = new StringBuilder();   // not thread-safe, fast, the default
StringBuffer  sf = new StringBuffer();    // synchronised, legacy

Java also interns literals in a string pool, and == on Java strings compares references, which is why Java developers are taught to always use .equals(). C# quietly avoided that entire class of bug by overloading ==.

Python. Strings are immutable too, and the idiomatic answer to the loop is "".join(parts), which is string.Join with the arguments swapped. Python has no builder because joining a list covers the same ground.

JavaScript (Meta, every browser). Strings are immutable primitives, and engines like V8 cheat with “ropes”: a + b in a loop creates a lazy tree of pieces that is only flattened when the string is actually read. That is why the naive loop is not catastrophic in JS, and why the same code is catastrophic in C#. If you have moved from JavaScript, this is the specific habit to unlearn.

Go (Google). Strings are immutable byte slices, and strings.Builder exists for exactly the reason StringBuilder does. Go’s linter will tell you off for += in a loop.

Rust. Splits the two ideas into two types: &str is an immutable view (a span, essentially) and String is the growable buffer. C# has the same pair, it just calls them ReadOnlySpan<char> and StringBuilder, and does not force you to choose.

Summary

  • string is immutable. Every “modifying” method returns a new string; the original never changes.
  • Immutability is why strings are safe to share, safe as dictionary keys, internable, and why == compares content.
  • Identical literals are interned to one instance; runtime-built strings are not. Never use ReferenceEquals on strings.
  • += in a loop is O(n²) in both time and garbage, because each step copies the whole accumulated string.
  • StringBuilder keeps a mutable buffer and makes appending amortised O(1). Use it for an unknown or large number of appends; use + or interpolation for a fixed few.
  • string.Join is the right tool for separated lists and beats a hand-rolled builder.
  • Use StringComparison.Ordinal/OrdinalIgnoreCase for machine-facing comparisons, culture comparison only for human-facing text, and never ToLower() to compare.
  • ReadOnlySpan<char> gives allocation-free slicing for hot paths.

Interview questions and answers

1. Why are strings immutable in C#?

So they can be shared safely without defensive copying: across threads, as dictionary keys with a stable hash code, and through the intern pool where identical literals share one instance. Immutability is also what makes value-style equality with == meaningful for a reference type.

2. Is string a value type or a reference type?

A reference type. It behaves like a value type in two visible ways (== compares characters rather than identity, and it can never be observed changing), but instances live on the heap and a string variable holds a reference. It can also be null, which no true value type can be.

3. What is string interning?

The runtime maintains a pool of unique string literals, so every occurrence of the same literal across an assembly refers to a single instance. It saves memory and makes literal comparisons fast. Strings created at runtime are not interned unless you call string.Intern explicitly.

4. Why is concatenating strings in a loop slow?

Because each += allocates a new string and copies both operands into it. As the accumulated string grows, each iteration copies more, so total work and total garbage are proportional to the square of the number of iterations.

5. When should you use StringBuilder?

When the number of appends is unknown or large, typically any concatenation inside a loop. For a fixed handful of pieces in a single expression, plain + or interpolation is faster, because the compiler emits one String.Concat or a pooled interpolation handler, and StringBuilder would add an allocation for no benefit.

6. How does StringBuilder work internally?

It holds a mutable character buffer (in modern .NET, a linked list of chunks). Appending writes into the current chunk and adds a new one when it fills, so appending is amortised O(1) and does not copy previous content. ToString() produces the single final string.

7. What is the difference between ordinal and culture-sensitive comparison?

Ordinal compares raw UTF-16 code unit values: fast, exact and identical on every machine. Culture-sensitive comparison applies locale rules for ordering and casing, which is what a user expects when sorting a visible list but is machine-dependent and unsuitable for identifiers, keys or paths.

8. Why should you avoid ToLower() for case-insensitive comparison?

It allocates a new string, and it uses the current culture by default, so it produces different results in different locales, the Turkish dotless-i problem being the classic case. string.Equals(a, b, StringComparison.OrdinalIgnoreCase) allocates nothing and behaves identically everywhere.

9. What is the difference between string.Empty, "" and null?

string.Empty and "" are the same interned instance of a zero-length string; the choice is stylistic. null is no string at all, and calling a member on it throws NullReferenceException. Use string.IsNullOrEmpty or IsNullOrWhiteSpace to cover both cases in one check.

10. How do Span and ReadOnlySpan help with strings?

They represent a window over existing character memory with no allocation, so slicing, parsing and searching can be done without creating substrings. Combined with span-based Parse/TryParse overloads and string.Create, they let high-throughput code process text with close to zero allocations, which is a major source of modern .NET’s performance.

# tags csharpstringsperformancedotnetinterview-questions
$ share linkedin x / twitter

Tayyab Shahid, Senior Software Engineer in London

Tayyab Shahid

Senior Software Engineer & .NET Consultant · London, UK

about github linkedin

§ related posts

Building something like this?

I take on freelance .NET projects, consulting, and senior roles.

get in touch

← cd ~/blog

CV