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.
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.
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.
"OK", "Pending", const string values) — the CLR interns them when the method is JIT-compiled (or when ReadyToRun code is loaded)."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:
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)
string.Intern and string.IsInternedstring.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.
string.Equals already short-circuits on reference equality, so the gain is tiny.ReferenceEquals/==-on-object comparisons that silently break when a non-interned string arrives.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.System.Text.Json and EF Core do not intern by default; for huge in-memory caches, de-duplicate at load time.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.
Tests depth on strings and judgement about memory optimisations.
Green flags
string.Intern and IsInterned and the lifetime risk.Red flags
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":
.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.
string.InternEach call:
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.
CompilationRelaxations.NoStringInterningThe 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.
string.Equalsstring.Equals(a, b) (and ==) first checks ReferenceEquals(a, b) and returns true immediately, then checks lengths, then compares characters with vectorised SIMD code. So:
The practical benefit of interning for comparison speed is therefore small; its real benefit is memory.
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.
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.
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.List<string>, take a snapshot with dotMemory or dotnet-gcdump, then de-duplicate with a pool and compare.string.IsInterned on a literal and on a runtime-built string.string.Intern vs a HashSet<string> pool with 8 threads.GetAlternateLookup<ReadOnlySpan<char>> to look up strings from a span without allocating.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']));
}
}
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.
== 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.
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.
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.
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.
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.
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.
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.
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.
constWhat 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.
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 |
ReferenceEquals results in tests, a common interview trap.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.
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.
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.
Last updated 30 Oct 2026.