📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What does it mean that string is immutable in C#? What really happens in memory when you "modify" a string, and why did .NET make strings immutable?

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.

Junior Theory Beginner csharp immutability memory string strings thread-safety

🎯 Key points

  • string is a sealed reference type holding a length-prefixed sequence of UTF-16 code units
  • Once created, a string's contents never change; every 'modifying' method returns a new string
  • s += x inside a loop allocates a new string each time: O(n²) copying and GC pressure
  • Immutability makes strings safe to share across threads and safe as dictionary keys
  • It also enables interning and protects security checks (a validated path or SQL text cannot be changed afterwards)
  • == on strings is overloaded to compare contents ordinally, not references
  • Empty and null differ: use string.IsNullOrEmpty / IsNullOrWhiteSpace
  • Never mutate a string through unsafe code or MemoryMarshal; use string.Create, StringBuilder or spans to build text

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. What happens in memory when you "modify" a string

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:

  1. "Hello" is a literal, so it is an interned string object created when the code is loaded.
  2. s += " World" compiles to s = string.Concat(s, " World");.
  3. Concat calculates the total length, allocates a new string object of length 11, copies both sets of characters into it, and returns it.
  4. s now references the new object. t still references "Hello".
  5. If nothing references a string anymore, the GC reclaims it later.

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.

2. Why .NET made strings immutable

  • Thread safety for free: an immutable object can be shared across threads with no locks.
  • Security: a string passed to a method (a file path, a URL, a permission name, an SQL identifier) cannot be changed by another thread or by the callee after it has been validated — preventing time-of-check/time-of-use attacks.
  • Safe as dictionary keys: a key's hash code can never change, so it can't get lost in a Dictionary or HashSet.
  • Interning and sharing: identical literals can share one object, and Substring(0), Trim() with nothing to trim, or string.Concat(s, "") can return the same instance.
  • Simple semantics: strings behave like values (int, DateTime) even though they are reference types — assignment never creates aliasing surprises.
  • Cheap hash caching opportunities and simpler runtime/GC implementation (strings are often read-only data in the binary).

3. The cost and how to manage it

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.
  • Interpolation ($"..."), 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.

4. A production example

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.

🔍 What the interviewer is checking

A fundamentals check that quickly shows whether the candidate understands allocation behaviour.

Green flags

  • Explains that every "modification" returns a new string.
  • Gives several reasons for immutability (threads, hashing, interning, security).
  • Knows + of a few pieces is one Concat, but loops are O(n²).
  • Mentions LOH or streaming for large text.

Red flags

  • Calls s.Replace(...) without assignment in a code sample.
  • Says strings are value types.
  • Thinks StringBuilder is always faster, even for two pieces.

📚 Learn in depth

1. The System.String object layout

[ header 8B ][ MethodTable* 8B ][ _stringLength int 4B ][ _firstChar char 2B ][ ...chars ][ '\0' ]
  • Characters are stored inline in the object as UTF-16 code units — a string is one contiguous allocation, not a pointer to a separate buffer.
  • The trailing '\0' exists so strings can be passed to native APIs (fixed (char* p = s)) without copying.
  • Because length and data live in the object, the runtime allocates strings with a special fast path (FastAllocateString) and does not zero-initialise twice.
  • Strings of more than ~42,500 characters (85,000 bytes) go on the Large Object Heap, which is collected only with Gen2 and is not compacted by default.

2. Immutability is enforced by the API, not by the hardware

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.

3. Which operations allocate and which don't

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.

4. Why += in a loop is quadratic

Iteration 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.

5. Interning and literal sharing

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.

6. Strings in other runtimes

  • Java and Python also use immutable strings, for the same reasons.
  • C++ std::string is mutable with value semantics (copied on assignment), which avoids aliasing problems at the cost of copies.
  • .NET offers mutable alternatives when you need them: StringBuilder, char[], Span<char>, and for UTF-8, byte[]/Span<byte> with Utf8Formatter and IUtf8SpanFormattable.

7. Strings and security-sensitive data

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.

Try it yourself

  1. Print ReferenceEquals(s, t) before and after s += "x".
  2. Benchmark += vs StringBuilder vs string.Join for 10, 1,000 and 100,000 items with [MemoryDiagnoser].
  3. Call "abc".Trim() and "abc".ToUpperInvariant() on already-trimmed / already-upper input and check whether the same instance is returned.
  4. Watch dotnet-counters loh-size while running a large += loop.

💻 Code

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));
        }
    }
}

⚠ Common mistakes

1. Calling string methods and ignoring the result

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.

2. Building strings with += in loops

What 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.

3. Believing immutability means "value type"

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.

4. Creating substrings just to parse

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).

5. Mutating strings with unsafe code

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.

6. Storing secrets in long-lived strings

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.

7. Comparing strings with 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.

8. Over-engineering with StringBuilder everywhere

What 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.

9. Repeated ToLower()/ToUpper() for comparisons

What 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).

10. Holding huge strings in memory

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).

🏛 Senior insight: trade-offs

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
  • Most string performance problems are really allocation problems; measure with [MemoryDiagnoser] and dotnet-counters before optimising.
  • Streaming output (CSV, JSON, reports) avoids giant strings and the LOH entirely.
  • The same immutability reasoning applies to records, ImmutableArray<T> and FrozenDictionary: share freely, copy on change.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions += in loops. Why: The cost of immutability leads to how you build strings efficiently.
    How to answer

    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.

  2. When: The candidate lists interning as a benefit of immutability. Why: Immutability is what makes interning and sharing strings safe.
    How to answer

    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.

↩ Usually asked after

More in Strings

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me