The System.String type and its immutability: memory layout, what concatenation, Replace and ToUpper actually do, why immutability gives thread safety, hashing and security benefits, and its allocation cost.
A string is a sealed reference type containing UTF-16 characters, and once it is created its contents never change. Methods like Replace, ToUpper, Trim or + return a new string and leave the original alone, so s.Replace("a", "b"); on its own does nothing useful. Immutability makes strings thread-safe, safe as dictionary keys (their hash never changes), allows interning, and stops someone changing a value after it has been validated. The cost is allocation: building text with += in a loop copies everything each time, which is why we use StringBuilder, string.Join or string.Create.
A string object's characters can never change after it is created. Every operation that looks like a modification — +, Replace, ToUpper, Trim, Substring — returns a new string and leaves the original untouched. The variable is just re-pointed. Immutability was a deliberate design choice for safety, simplicity and sharing, and the trade-off is allocation when you build strings incrementally.
string s = "Hello";
string t = s; // t and s reference the SAME object
s += " World"; // allocates a NEW string "Hello World"; s now points to it
Console.WriteLine(t); // "Hello" - original unchanged
Step by step:
"Hello" is a literal, so it is an interned string object created when the code is loaded.s += " World" compiles to s = string.Concat(s, " World");.Concat calculates the total length, allocates a new string object of length 11, copies both sets of characters into it, and returns it.s now references the new object. t still references "Hello".A string object on 64-bit .NET is: object header (8 B) + MethodTable pointer (8 B) + int length (4 B) + UTF-16 characters (2 B each) + a null terminator (2 B), rounded up to 8 bytes. "Hello" is about 32 bytes.
Common "no-op" bug: name.Trim(); on its own line does nothing — the trimmed result is discarded.
Dictionary or HashSet.Substring(0), Trim() with nothing to trim, or string.Concat(s, "") can return the same instance.int, DateTime) even though they are reference types — assignment never creates aliasing surprises.Building a string in a loop with += copies everything built so far on every iteration: O(n²) character copies and n garbage strings. For 10,000 rows of 100 characters that is ~5 × 10⁹ bytes copied and a lot of Large Object Heap churn once strings pass 85,000 bytes.
Tools:
StringBuilder for loops and unknown counts.string.Join / string.Concat for known collections.$"..."), which since C# 10 builds into a pooled buffer and allocates only the final string.string.Create for exact-length, allocation-tight construction.ReadOnlySpan<char> to slice and parse without creating substrings at all.A CSV export endpoint built its output with result += line + "\n"; for each row. It was fine for 100 rows in testing; in production a 50,000-row export took over 40 seconds, allocated gigabytes of LOH strings, and caused Gen2 collections that slowed every other request on the same instance. Switching to streaming rows directly to the response with a StreamWriter (or PipeWriter) removed the big string entirely; exports finished in about a second and memory stayed flat. Immutability wasn't the bug — using immutable strings as a mutable buffer was.
A fundamentals check that quickly shows whether the candidate understands allocation behaviour.
Green flags
+ of a few pieces is one Concat, but loops are O(n²).Red flags
s.Replace(...) without assignment in a code sample.StringBuilder is always faster, even for two pieces.System.String object layout[ header 8B ][ MethodTable* 8B ][ _stringLength int 4B ][ _firstChar char 2B ][ ...chars ][ '\0' ]
'\0' exists so strings can be passed to native APIs (fixed (char* p = s)) without copying.FastAllocateString) and does not zero-initialise twice.The String class exposes no members that mutate. Internally, the runtime writes into a freshly allocated string before publishing it (e.g. inside Concat, string.Create), which is safe because nobody else holds a reference yet.
Unsafe code can mutate a string:
unsafe { fixed (char* p = s) p[0] = 'J'; } // DON'T
This breaks everything that relies on immutability: if s was an interned literal, every use of that literal in the process changes; dictionary entries with that key become unreachable; cached hash codes go stale. MemoryMarshal.AsMemory/AsSpan tricks have the same danger. Treat it as undefined behaviour.
| Operation | Allocates? |
|---|---|
s.Substring(i, n) |
Yes (unless it returns the whole string) |
s.AsSpan(i, n) |
No — a view over the same memory |
s.Trim() |
Only if there is whitespace to remove |
s.ToUpperInvariant() |
Yes, unless already upper-case (the runtime checks) |
s.Replace(a, b) |
Only if a is found |
s + t |
Yes (unless one is empty) |
s.Split(',') |
One array + one string per part |
s.AsSpan().Split(',') (.NET 9 MemoryExtensions.Split) |
No — enumerates ranges |
string.Equals(s, t, Ordinal) |
No |
The runtime is smart about returning the same instance when nothing changes, which is only safe because strings are immutable.
+= in a loop is quadraticIteration i copies the existing i × L characters plus L new ones. Total copies = L × (1 + 2 + ... + n) = L × n(n+1)/2 → O(n²). It also creates n intermediate strings that immediately become garbage. Note the C# compiler optimises a + b + c + d in a single expression into one string.Concat(a, b, c, d) call — the problem is specifically repeated concatenation across statements or loop iterations.
All string literals in an assembly are interned: two occurrences of "OK" refer to the same object, so ReferenceEquals("OK", "OK") is true. Strings created at run time (concatenation, ToString(), reading files) are not interned unless you call string.Intern. This sharing is safe only because no one can mutate the shared instance.
std::string is mutable with value semantics (copied on assignment), which avoids aliasing problems at the cost of copies.StringBuilder, char[], Span<char>, and for UTF-8, byte[]/Span<byte> with Utf8Formatter and IUtf8SpanFormattable.Because strings are immutable and garbage-collected, a password or secret stored in a string cannot be wiped from memory; copies may linger until the GC overwrites them and can appear in crash dumps. SecureString is not recommended for new development on .NET Core (it is not encrypted on non-Windows platforms). The practical guidance: minimise the lifetime of secrets, avoid logging them, use char[]/byte[] buffers you clear (CryptographicOperations.ZeroMemory) for highly sensitive data, and keep secrets in Key Vault rather than in long-lived strings.
ReferenceEquals(s, t) before and after s += "x".+= vs StringBuilder vs string.Join for 10, 1,000 and 100,000 items with [MemoryDiagnoser]."abc".Trim() and "abc".ToUpperInvariant() on already-trimmed / already-upper input and check whether the same instance is returned.dotnet-counters loh-size while running a large += loop.using System.Text;
namespace Strings;
public static class Immutability
{
public static void Main()
{
string name = " ada ";
name.Trim(); // BUG: result discarded
Console.WriteLine($"[{name}]"); // [ ada ]
name = name.Trim().ToUpperInvariant(); // new string assigned
Console.WriteLine($"[{name}]"); // [ADA]
// Two references, one object: changing one variable does not affect the other
string a = "hello";
string b = a;
a += " world";
Console.WriteLine(b); // hello
// Equality compares contents
string c = new string(['h', 'e', 'l', 'l', 'o']);
Console.WriteLine(b == c); // True
Console.WriteLine(object.ReferenceEquals(b, c)); // False
// Build text efficiently
var rows = Enumerable.Range(1, 5).Select(i => $"row{i}");
Console.WriteLine(string.Join(",", rows));
// string.Create: write characters once, before the string is visible
string padded = string.Create(8, 42, static (span, value) =>
{
span.Fill('0');
value.TryFormat(span[^2..], out _);
});
Console.WriteLine(padded); // 00000042
}
// Stream large output instead of building a huge string
public static async Task WriteCsvAsync(Stream output, IAsyncEnumerable<string[]> rows, CancellationToken ct)
{
await using var writer = new StreamWriter(output, new UTF8Encoding(false));
await foreach (var row in rows.WithCancellation(ct))
{
await writer.WriteLineAsync(string.Join(',', row));
}
}
}
What happens: name.Trim();, email.ToLowerInvariant();, path.Replace("\\", "/"); on their own lines.
Why it is wrong: strings are immutable; these methods return new strings, so the original variable is unchanged.
Do instead: assign the result (name = name.Trim();) and enable CA1806 to catch ignored results.
+= in loopsWhat happens: reports, CSV exports, HTML or SQL built row by row with +=.
Why it is wrong: O(n²) copying, huge garbage, LOH allocations and Gen2 GCs.
Do instead: stream to a TextWriter/PipeWriter, or use StringBuilder, string.Join or string.Create.
What happens: candidates say strings are value types because they behave like values.
Why it is wrong: string is a reference type; it has value-like equality and immutability, but variables hold references and can be null.
Do instead: explain it as an immutable reference type with value equality.
What happens: Split and Substring used to parse every line of a large file or every request header.
Why it is wrong: each piece is a new allocation, even though you only need to read the characters.
Do instead: use ReadOnlySpan<char> slicing and span-based parsing (int.Parse(span), MemoryExtensions.Split).
What happens: fixed/MemoryMarshal used to "optimise" by writing into an existing string.
Why it is wrong: interned literals and dictionary keys are shared; mutation corrupts unrelated code and hash tables.
Do instead: use string.Create to write into a new string once, or work with Span<char> buffers.
What happens: API keys and passwords kept in static strings, logged, or included in exception messages. Why it is wrong: strings cannot be cleared, can appear in memory dumps, and logs persist them. Do instead: load secrets from Key Vault when needed, avoid logging them, and keep their lifetime short.
ReferenceEquals because "literals are interned"What happens: code relies on two strings being the same object.
Why it is wrong: strings built at run time are not interned; reference comparison gives false negatives.
Do instead: use string.Equals with an explicit StringComparison.
StringBuilder everywhereWhat happens: StringBuilder used to join two or three strings.
Why it is wrong: more code and often slower than a single Concat or interpolation, which the compiler already optimises.
Do instead: use interpolation or + for a fixed number of parts; StringBuilder for loops and unknown counts.
ToLower()/ToUpper() for comparisonsWhat happens: if (a.ToLower() == b.ToLower()) in hot code.
Why it is wrong: allocates two new strings per comparison and uses the current culture.
Do instead: string.Equals(a, b, StringComparison.OrdinalIgnoreCase).
What happens: reading entire files or HTTP bodies into one string (ReadToEnd, ReadAsStringAsync) for large payloads.
Why it is wrong: large strings land on the LOH and can cause OutOfMemoryException or long GCs.
Do instead: stream and process incrementally (StreamReader.ReadLineAsync, System.IO.Pipelines, Utf8JsonReader).
Immutability is a design trade-off: safety and simplicity in exchange for allocations.
| Concern | Immutable string | Mutable alternative |
|---|---|---|
| Sharing across threads | free | needs locks |
| Dictionary keys | stable hash | dangerous |
| Building large text | many allocations | StringBuilder, Span<char> buffers |
| Parsing | substrings allocate | ReadOnlySpan<char> slices allocate nothing |
| Secrets | cannot be wiped | byte arrays you can clear |
[MemoryDiagnoser] and dotnet-counters before optimising.ImmutableArray<T> and FrozenDictionary: share freely, copy on change.I use StringBuilder when I'm appending an unknown or large number of pieces, usually in a loop, because += creates a new string and copies everything on every iteration. For a fixed number of pieces, a + b + c or interpolation compiles to a single string.Concat or handler call, which is just as fast. Internally StringBuilder keeps a chunked mutable buffer, so it grows without recopying old text, and ToString() allocates the final string once. Alternatives are string.Join for collections, string.Create when I know the exact length, and streaming to a writer for very large output.
Interning means the runtime keeps one shared instance of each distinct string in a process-wide intern pool. All literals and constant expressions are interned automatically, so two "OK" literals are the same object. Strings created at run time are not, unless you call string.Intern, which returns the pooled copy. It can save memory when you hold many duplicate long-lived strings (like country codes from millions of rows), but interned strings typically live until the process exits, so interning user input is a memory leak. I never compare strings by reference, and I usually prefer a scoped pool or a dictionary.
Last updated 30 Oct 2026.