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

📚 Interview question

What is string interning in .NET? Which strings are interned automatically, how do string.Intern and string.IsInterned work, and when is interning useful or harmful?

String interning in .NET 10: the intern pool, automatic interning of literals and constant expressions, explicit string.Intern, reference equality surprises, memory lifetime, and safer alternatives such as custom string pools.

Mid-Level Theory Intermediate csharp memory performance reference-equality string-interning strings

🎯 Key points

  • Interning keeps one shared instance per distinct string value in a process-wide intern pool
  • String literals and compile-time constant expressions are interned automatically
  • Strings built at run time (concatenation, ReadLine, deserialisation) are not interned unless you call string.Intern
  • string.Intern returns the pooled instance (adding it if needed); string.IsInterned returns it or null
  • Interned strings normally live until the process ends, so interning untrusted or unbounded input leaks memory
  • Interning only helps when many duplicate long-lived strings exist; it costs a lookup per call
  • Never rely on reference equality (ReferenceEquals or (object)a == (object)b) for string comparison
  • Alternatives: a scoped Dictionary or HashSet pool, a bounded pool such as CommunityToolkit StringPool, or enums/ids instead of repeated strings

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

String interning means keeping one canonical instance per distinct string value in a process-wide table (the intern pool), so equal strings can share one object. .NET interns string literals automatically; runtime-created strings are interned only if you ask. It can save memory when the same values repeat millions of times, but the intern pool is never cleaned up, so it is easy to turn it into a memory leak.

1. What is interned automatically

  • All string literals in your code ("OK", "Pending", const string values) — the CLR interns them when the method is JIT-compiled (or when ReadyToRun code is loaded).
  • Compile-time constant expressions: "Hello, " + "World" is folded by the compiler into one literal, so it is interned. const string interpolation (C# 10) is too.
  • nameof(...) results (they are literals).

Not interned automatically:

  • Anything built at run time: concatenation of variables, ToString(), Substring, StringBuilder.ToString(), data read from files, databases, HTTP or JSON.
string a = "order";
string b = "ord" + "er";                 // folded at compile time -> interned
string c = new string("order".ToCharArray()); // runtime -> not interned

Console.WriteLine(ReferenceEquals(a, b)); // True
Console.WriteLine(ReferenceEquals(a, c)); // False
Console.WriteLine(a == c);                // True (value equality)

2. string.Intern and string.IsInterned

  • string.Intern(s): if a string equal to s is in the pool, return that instance; otherwise add s and return it.
  • string.IsInterned(s): return the pooled instance if present, otherwise null — it never adds.
string c2 = string.Intern(c);
Console.WriteLine(ReferenceEquals(a, c2)); // True

Using it costs a hash computation and a lookup in a global, lock-protected table every time.

3. When interning is useful

  • Loading millions of records with a small set of repeated values (country codes, status names, currency codes, tag names) where you keep them in memory long-term. Sharing instances can cut memory dramatically.
  • Parsers and compilers (Roslyn uses its own name tables) that see the same identifiers repeatedly.
  • Very occasionally, to allow reference comparisons in extreme hot paths — though string.Equals already short-circuits on reference equality, so the gain is tiny.

4. When it is harmful

  • Interned strings live until the process ends (in .NET Core there are no AppDomains to unload). Interning user input, IDs, emails, URLs or anything high-cardinality is an unbounded memory leak.
  • Every intern call takes a global lock and hashes the full string — contention in multi-threaded services.
  • Security: interning user-controlled data lets attackers grow the pool and exhaust memory.
  • It encourages ReferenceEquals/==-on-object comparisons that silently break when a non-interned string arrives.

5. Better alternatives for de-duplication

  • A scoped string pool you control: Dictionary<string,string> or HashSet<string> with TryGetValue to return the canonical instance, discarded when the import finishes.
  • Microsoft.Toolkit.HighPerformance.StringPool (CommunityToolkit) — a bounded, thread-safe pool that can create strings from spans without allocating duplicates.
  • FrozenSet<string> / FrozenDictionary for fixed vocabularies.
  • Enums or small IDs instead of repeated strings in the domain model.
  • System.Text.Json and EF Core do not intern by default; for huge in-memory caches, de-duplicate at load time.

6. A production example

An in-memory product catalogue cache held ~4 million attribute values like "Colour", "Size", "AUD", "In Stock", each deserialised as a new string. A memory dump analysed with dotnet-dump (dumpheap -stat) showed System.String as the top type, and duplicate-string analysis in Visual Studio's dump viewer showed the same few hundred values repeated millions of times. Adding a scoped de-duplication pool during cache load (not string.Intern, so the pool is dropped with the old cache on refresh) reduced heap size by several hundred MB. A different team had previously tried string.Intern on product SKUs — high-cardinality, constantly changing — and the process memory grew until containers were OOM-killed; that is the classic misuse.

🔍 What the interviewer is checking

Tests depth on strings and judgement about memory optimisations.

Green flags

  • Knows literals and constants are interned automatically, runtime strings are not.
  • Explains string.Intern and IsInterned and the lifetime risk.
  • Never relies on reference equality.
  • Suggests scoped pools or dump analysis before interning.

Red flags

  • Thinks all equal strings are the same object.
  • Recommends interning everything for performance.
  • Unaware that interned strings live for the process lifetime.

📚 Learn in depth

1. Where the intern pool lives

The CLR maintains a global string literal map (historically per AppDomain in .NET Framework, process-wide in .NET Core). Entries are strong references, so interned strings are effectively GC roots — they are never collected while the runtime is alive.

When the JIT compiles a method containing ldstr "OK":

  1. It looks up the literal in the module's string table.
  2. It checks the global map; if absent, it allocates the string and registers it.
  3. The JIT embeds the address (or a handle) of the canonical object into the machine code, so loading a literal at run time is essentially a constant load — no lookup per execution.

.NET 8 moved string literals onto the Frozen Object Heap (a non-GC heap): they are never moved or collected, so the JIT can hard-code their address directly.

2. Cost of string.Intern

Each call:

  • Computes a hash over the entire string (O(length)).
  • Takes a lock on the global table.
  • Compares against existing entries in the bucket.
  • Possibly registers a new entry.

For a 50-character string this is far more expensive than a plain equality comparison, and under concurrency the lock becomes a contention point. Interning only pays off when the memory savings over the lifetime of the data dwarf the lookup cost.

3. CompilationRelaxations.NoStringInterning

The C# compiler marks assemblies with [CompilationRelaxations(8)] (NoStringInterning), which permits the runtime not to intern literals across assemblies. In practice CoreCLR still interns literals, but the specification does not guarantee that literals from two different assemblies are the same instance. Never write code that depends on cross-assembly reference identity of literals.

4. Reference equality and string.Equals

string.Equals(a, b) (and ==) first checks ReferenceEquals(a, b) and returns true immediately, then checks lengths, then compares characters with vectorised SIMD code. So:

  • Equal interned strings compare in one pointer check.
  • Equal non-interned strings compare in O(length) with SIMD, which is extremely fast for typical lengths.
  • Unequal strings of different length are rejected after the length check.

The practical benefit of interning for comparison speed is therefore small; its real benefit is memory.

5. Building your own de-duplication pool

public sealed class StringDeduplicator
{
    private readonly HashSet<string> _pool = new(StringComparer.Ordinal);

    public string Get(string value)
    {
        if (_pool.TryGetValue(value, out var existing)) return existing;   // canonical instance
        _pool.Add(value);
        return value;
    }

    public string Get(ReadOnlySpan<char> span)
    {
        var lookup = _pool.GetAlternateLookup<ReadOnlySpan<char>>();       // .NET 9+
        if (lookup.TryGetValue(span, out var existing)) return existing;   // no allocation on a hit
        var created = span.ToString();
        _pool.Add(created);
        return created;
    }
}

.NET 9's alternate lookup (GetAlternateLookup<ReadOnlySpan<char>>) lets you look up strings by span without allocating a temporary string first — ideal when parsing from a buffer. Because the pool is an ordinary object, it is collected when you drop it (for example with the old cache on refresh), unlike the intern pool. For multi-threaded use, wrap it in a lock or use a concurrent/bounded pool such as CommunityToolkit's StringPool.

6. Runtime-level de-duplication

Java's G1 garbage collector can de-duplicate string contents automatically. The .NET GC does not do this by default, so in .NET de-duplication is an application-level decision: scoped pools, FrozenSet/enums for closed vocabularies, or interning for small fixed sets. If you see a runtime option advertised for this in a newer .NET release, treat it as something to verify against the official runtime documentation and measure under load before enabling it in production.

7. Diagnosing duplicate strings

  • dotnet-dump collect then dotnet-dump analyze → dumpheap -stat shows total System.String count and size; dumpheap -type System.String -min 100 lists large ones.
  • Visual Studio's managed memory analysis has a duplicate strings view.
  • JetBrains dotMemory has a "Duplicate strings" inspection showing wasted bytes per value.

Try it yourself

  1. Read 1,000,000 lines with repeated values into a List<string>, take a snapshot with dotMemory or dotnet-gcdump, then de-duplicate with a pool and compare.
  2. Call string.IsInterned on a literal and on a runtime-built string.
  3. Benchmark string.Intern vs a HashSet<string> pool with 8 threads.
  4. Use GetAlternateLookup<ReadOnlySpan<char>> to look up strings from a span without allocating.

💻 Code

namespace Strings;

public sealed record Product(string Sku, string Brand, string State);

// Scoped pool: deduplicates values for one load and is then collected
public sealed class LoadScopedStringPool
{
    private readonly HashSet<string> _pool = new(StringComparer.Ordinal);

    public string Get(string value)
    {
        if (_pool.TryGetValue(value, out var existing)) return existing;
        _pool.Add(value);
        return value;
    }
}

public static class Interning
{
    public static void Main()
    {
        string literal1 = "NSW";
        string literal2 = "NSW";
        string runtime = new string(['N', 'S', 'W']);             // built at run time

        Console.WriteLine(ReferenceEquals(literal1, literal2));   // True
        Console.WriteLine(ReferenceEquals(literal1, runtime));    // False
        Console.WriteLine(literal1 == runtime);                   // True (content)
        Console.WriteLine(string.IsInterned(runtime) is not null); // True: an equal literal is pooled

        string pooled = string.Intern(runtime);
        Console.WriteLine(ReferenceEquals(literal1, pooled));     // True

        // Reference comparison bug: works for literals, fails for runtime strings
        object x = literal1, y = runtime;
        Console.WriteLine(x == y);                                // False: object == compares references

        // Safer deduplication for a bulk load
        var pool = new LoadScopedStringPool();
        var products = new List<Product>();
        foreach (var (sku, brand, state) in ReadRows())
        {
            products.Add(new Product(sku, pool.Get(brand), pool.Get(state)));
        }
        Console.WriteLine(ReferenceEquals(products[0].State, products[1].State)); // True
    }

    private static IEnumerable<(string Sku, string Brand, string State)> ReadRows()
    {
        // Simulates values coming from a file or database: a new instance on every row
        for (int i = 0; i < 3; i++)
            yield return ($"SKU{i}", new string(['A', 'c', 'm', 'e']), new string(['V', 'I', 'C']));
    }
}

⚠ Common mistakes

1. Interning user input or high-cardinality data

What happens: string.Intern(request.Email), string.Intern(sku) or interning IDs from a database. Why it is wrong: interned strings are never released, so memory grows forever — a leak and a denial-of-service vector. Do instead: only intern small, fixed vocabularies; use a scoped de-duplication pool for large loads.

2. Using == on object or ReferenceEquals because "strings are interned"

What happens: comparisons that work in tests with literals fail with strings read from files or the database. Why it is wrong: runtime strings are not interned, so reference equality is false. Do instead: use string.Equals with an explicit StringComparison.

3. Expecting runtime concatenations to be interned

What happens: var key = prefix + id; assumed to share memory with an existing literal. Why it is wrong: only compile-time constant expressions are folded and interned. Do instead: don't rely on interning for correctness at all.

4. Interning for comparison speed

What happens: strings are interned to make equality checks "faster". Why it is wrong: string.Equals already short-circuits on reference equality and uses SIMD; the intern lookup costs more than it saves. Do instead: benchmark; usually use plain ordinal comparisons, or map to enums/IDs for hot comparisons.

5. Global interning in multi-threaded hot paths

What happens: string.Intern called per message in a high-throughput consumer. Why it is wrong: the global table takes a lock, causing contention and CPU overhead. Do instead: use a lock-free or per-thread pool, or de-duplicate at a coarser stage.

6. Assuming literals in different assemblies are the same instance

What happens: plugin code compares a host literal by reference. Why it is wrong: cross-assembly interning is not guaranteed by the specification (NoStringInterning relaxation). Do instead: compare by value.

7. Ignoring duplicate strings in large caches

What happens: in-memory caches hold millions of identical small strings without anyone noticing. Why it is wrong: memory cost per string includes ~20+ bytes of overhead, so duplicates can dominate the heap. Do instead: analyse dumps for duplicate strings and de-duplicate at load time or use enums.

8. Using interning instead of better modelling

What happens: repeated status or category strings in domain models are interned to save memory. Why it is wrong: strings for closed sets are error-prone (typos, case) and larger than needed. Do instead: use enums, small integer IDs, or FrozenDictionary lookups.

9. Expecting the intern pool to be cleared on cache refresh

What happens: a cache refresh interns a new set of values every hour. Why it is wrong: old values stay in the intern pool forever; memory grows with each refresh. Do instead: use an object-scoped pool that is dropped with the old cache.

10. Confusing interning with const

What happens: assuming a static readonly string is interned the same way as a const. Why it is wrong: a static readonly initialised from a literal is the interned literal, but one built at run time (e.g. from configuration) is not. Do instead: treat interning as an implementation detail and never depend on it for correctness.

🏛 Senior insight: trade-offs

Seniors see interning as a memory deduplication tool with a lifetime cost, and reach for scoped pools first.

Situation Recommendation
Huge in-memory dataset with repeated values, loaded once string.Intern acceptable if the vocabulary is bounded
Data reloaded periodically scoped HashSet/Dictionary pool per load
High-volume parsing from spans bounded StringPool
Fixed known values enum or FrozenSet<string>
Request-scoped data no pooling
  • Find the problem first: dump analysis showing duplicate strings is the evidence that justifies pooling.
  • Interning and pooling reduce retained memory, not allocation rate; if the issue is GC churn, look at spans and streaming instead.
  • Knowing that literals are interned explains surprising ReferenceEquals results in tests, a common interview trap.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate explains the intern pool. Why: Checks the candidate understands why sharing a single instance is safe.
    How to 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.

  2. When: The candidate warns against ReferenceEquals for strings. Why: Moves from reference equality to choosing the right content comparison.
    How to answer

    Ordinal comparison compares the raw UTF-16 code units, so it's fast and identical on every machine. Culture-sensitive comparison applies a language's rules through ICU (accents, ignorable characters, ß versus ss, Turkish dotted I), so results can change with the culture or ICU version. The trap is the defaults: ==, Equals and Contains are ordinal, but StartsWith(string), IndexOf(string), Compare and CompareTo are culture-sensitive. I use Ordinal/OrdinalIgnoreCase for identifiers, keys, paths, headers and anything security-related, and CurrentCulture only for sorting or searching text a user sees. For collections I pass a StringComparer.

🛠 Real-world problems: how you handle them

Duplicate strings consumed a gigabyte in an in-memory catalogue
Situation
A pricing service kept a product catalogue of 4 million rows in memory for fast lookups and reloaded it hourly from SQL Server.
Signals
Pods used about 3 GB of memory and were close to their Kubernetes limit; gen-2 GCs after each reload were long.
Investigation
Collected a dump with dotnet-dump, ran dumpheap -stat (String was the top type), and used dotMemory's duplicate strings inspection: Brand, Category and State values were repeated millions of times.
Resolution
Added a load-scoped HashSet<string> pool used while materialising rows, so each distinct value had one instance per load; avoided string.Intern because each hourly load would add values to the permanent pool.
Prevention
A memory budget test in CI that loads a production-sized sample and asserts heap size, plus memory alerts on the pods.

How to tell it (STAR)

**S:** Our pricing service [at company] was close to its memory limit after each catalogue reload. **T:** I needed to cut memory without changing the lookup logic. **A:** I analysed a dump, found most of the heap was duplicate brand, category and state strings, and added a load-scoped string pool instead of global interning, because the data reloaded hourly. **R:** Heap size dropped by about [900 MB] and reload GC pauses shortened noticeably.

↩ Usually asked after

More in Strings

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me