Skip to content

Globalization: ICU vs NLS & Invariant Mode

Key Points

  • ICU (International Components for Unicode) is the default globalization library on .NET 5+ across all platforms (Windows, Linux, macOS).
  • NLS (National Language Support) is the legacy Windows API — used by .NET Framework, opt-in for modern .NET on Windows via app-context switch.
  • Invariant globalization mode strips culture data from the runtime — smaller deployment, deterministic, but breaks all culture-aware ops (CurrentCulture, sorting, casing).
  • Differences ICU vs NLS matter for sorting, casing edge cases (Turkish "I"), date/number formatting; tests written against NLS may fail on ICU and vice versa.
  • Senior rule: explicitly choose ordinal comparisons for code-internal data; use CultureInfo.InvariantCulture for serialization; ICU defaults are fine for user-facing display.

Concepts (deep dive)

Why ICU

Microsoft consolidated the globalization stack on ICU because: - Same library/data on Windows, Linux, macOS. - Continuously updated (Unicode releases, CLDR culture data). - Better Unicode support (UTS #10 collation, casing, normalization).

How to know which is active

using System.Globalization;

var ci = CultureInfo.GetCultureInfo("en-US");
Console.WriteLine(ci.CompareInfo);
// ICU: "CompareInfo - en-US (ICU)"
// NLS: "CompareInfo - en-US (NLS)"

Or via runtime config:

// runtimeconfig.json
{
  "configProperties": {
    "System.Globalization.UseNls": true       // opt back into NLS on Windows
  }
}

Invariant mode

<ItemGroup>
  <RuntimeHostConfigurationOption Include="System.Globalization.Invariant" Value="true" />
</ItemGroup>

Effect: - All cultures collapse to invariant (en-US-like, no localization data). - ICU/NLS dependency removed — smaller container image (~30 MB on Linux). - new CultureInfo("ja-JP") returns invariant; DateTime.Parse("12 Februar 2026", "de-DE") fails.

Use for: backend services that never localize, batch processors, AOT scenarios where deployment size matters.

Don't use for: any UI-facing app, anything that formats currency/dates per user culture.

Common ICU/NLS differences

// Sorting: ICU follows UCA (Unicode Collation Algorithm); NLS uses Windows order.
"coöperate".CompareTo("cooperate")     // umlaut sort position differs

// Turkish "I": dotted vs dotless
"i".ToUpper("tr-TR")                        // ICU and NLS both produce "İ"
// Edge case: lowercase mapping for "İ" varies by version

// German "ß": Unicode 5.1+ defines uppercase as "ẞ", but old NLS produces "SS"
"ß".ToUpper("de-DE")                        // ICU: "ẞ"  NLS: "SS"

// Date formatting tokens
DateTime.Now.ToString("d", new CultureInfo("ru-RU"))
// ICU: "27.04.2026"   NLS: "27.04.2026"  (same here, but parsing tolerance differs)

CultureInfo defaults

CultureInfo.CurrentCulture                   // user's UI locale (server: process default)
CultureInfo.CurrentUICulture                 // resource lookup culture
CultureInfo.InvariantCulture                 // culture-neutral (English-like, deterministic)
CultureInfo.DefaultThreadCurrentCulture      // process default for new threads

Server tip: pin to a known culture in Main:

CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;

Prevents tests passing on dev (en-US) and failing in prod (de-DE).

Ordinal vs culture-aware comparison

"file".StartsWith("File", StringComparison.Ordinal)         // false
"file".StartsWith("File", StringComparison.OrdinalIgnoreCase)// true
"resume".CompareTo("résumé")                                // CurrentCulture; varies
"resume".AsSpan().CompareTo("résumé", StringComparison.Ordinal) // -1 byte-wise

For identifiers, paths, config keys, JSON property names — always Ordinal.

Roundtripping numbers and dates

// Wrong — culture-dependent
decimal.Parse("1,234.56")         // throws on de-DE
DateTime.Parse("04/27/2026")      // ambiguous

// Right — InvariantCulture or explicit format
decimal.Parse("1,234.56", CultureInfo.InvariantCulture);
DateTime.ParseExact("2026-04-27T14:30:00Z", "O", CultureInfo.InvariantCulture);

Always pass IFormatProvider or use ISO 8601 ("O") for date round-tripping.

AOT / trimming with globalization

NativeAOT supports ICU but image size is larger. Invariant mode shrinks AOT outputs significantly:

<InvariantGlobalization>true</InvariantGlobalization>

Container images can drop ~30 MB by going invariant; only do this if your service truly never localizes.

Casing maps

"İSTANBUL".ToLower(new CultureInfo("tr-TR"))   // "istanbul" (dotless lower)
"İSTANBUL".ToLower(CultureInfo.InvariantCulture) // "i̇stanbul" (combining dot)
"İSTANBUL".ToLowerInvariant()                  // same as InvariantCulture

Don't ToLower() without specifying culture — silent locale-dependent behavior.


Code: correct vs wrong

❌ Wrong: serializing dates with culture

var json = order.CreatedAt.ToString();         // CurrentCulture format → varies
File.WriteAllText("out.json", $"{{ \"date\": \"{json}\" }}");

✅ Correct: ISO 8601 invariant

var json = order.CreatedAt.ToString("O", CultureInfo.InvariantCulture);

❌ Wrong: comparing files paths culture-aware

if (path.EndsWith(".jpg")) { /* TURKEY: dotted-I bug if path has "I" */ }

✅ Correct: ordinal

if (path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)) { /* ... */ }

❌ Wrong: implicit ToLower() for hashing

var key = email.ToLower();    // tr-TR locale: "İ" → "i̇"

✅ Correct: invariant or ordinal

var key = email.ToLowerInvariant();

Design patterns for this topic

Pattern 1 — Pin culture in Main

public static void Main()
{
    CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
    CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture;
    // ... bootstrap
}

Server processes pin to invariant for predictability.

Pattern 2 — Invariant for I/O

All persisted data — JSON, XML, CSV — formatted with InvariantCulture. UI formatting only at the rendering boundary.

Pattern 3 — Ordinal for identifiers

Wrap identifier comparisons in helpers:

public static bool IsSameKey(string a, string b) =>
    string.Equals(a, b, StringComparison.OrdinalIgnoreCase);

Pattern 4 — Test under non-default culture

[Fact]
public void Round_trips_under_de_DE()
{
    var prev = CultureInfo.CurrentCulture;
    Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
    try { /* assert */ } finally { Thread.CurrentThread.CurrentCulture = prev; }
}

Catches culture-dependent bugs (decimal point, date order) before production.


Pros & cons / trade-offs

Mode Pros Cons
ICU (default) Cross-platform, modern Unicode ~5 MB libicu dependency
NLS (Windows opt-in) Familiar to .NET Framework devs Windows-only, frozen Unicode
Invariant Smallest, deterministic No localization, throws on culture lookups

When to use / when to avoid

  • Default: ICU, the modern standard.
  • NLS: maintaining .NET Framework compat or specific Windows behavior; rare.
  • Invariant: backend services, AOT with no localization needs.
  • Avoid culture-dependent comparison for identifiers — always Ordinal.

Interview Q&A

Q1. What changed in .NET 5 regarding globalization? A. ICU became the default cross-platform globalization library; previously Windows used NLS and Linux/macOS shipped their own ICU. Standardizing on ICU produced consistent behavior across platforms.

Q2. What's invariant mode? A. A runtime configuration that strips culture-data dependency. All cultures collapse to invariant; saves ~5-30 MB depending on platform; great for AOT/containers; breaks any culture-aware behavior.

Q3. How does Turkish "I" cause bugs? A. In Turkish locale, "I".ToLower() produces "ı" (dotless), not "i". Code that lowercases a flag/setting name (like "on" vs "On") breaks. Always use ToLowerInvariant or ordinal comparison.

Q4. Why does decimal.Parse("1,000.5") throw on de-DE? A. German uses . as thousand separator and , as decimal — your literal looks malformed. Pass CultureInfo.InvariantCulture for fixed-format parsing.

Q5. What's the difference between Ordinal and InvariantCulture comparison? A. Ordinal: byte-wise, ignores locale entirely, fastest. InvariantCulture: locale-neutral but linguistic — "a" and "á" (combining acute) compare equal under InvariantCulture but unequal under Ordinal. Identifiers want Ordinal.

Q6. How do you opt into NLS on .NET 6+ Windows? A. Set System.Globalization.UseNls=true in runtimeconfig.json. Rarely needed except for backward-compat bug repros.

Q7. Why pin CurrentCulture in Main? A. The default depends on the host environment — different containers / OS images / locales produce different CurrentCulture. Pinning makes behavior deterministic across deployments.

Q8. Does ICU support all Windows-specific cultures? A. CLDR is comprehensive but occasionally lags on niche locales. Microsoft supplements ICU data via the Microsoft.ICU.ICU4C.Runtime NuGet for specific scenarios.


Gotchas / common mistakes

  • ToLower() / ToUpper() without culture — implicit CurrentCulture.
  • decimal.Parse(s) without InvariantCulture for serialized data.
  • DateTime.Parse("4/27/2026") — ambiguous; use ParseExact with format.
  • Test data formatted with dev culture, runs fine; prod culture differs.
  • Invariant mode stripping CultureInfo data → silent fallback to invariant for new CultureInfo("fr-FR").
  • ICU image size in containers — measure before committing.
  • Mixed ordinal/culture comparisons in the same code path producing inconsistent results.
  • Hashing lowercased emails with ToLower() → Turkish-I duplicates.

Further reading