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.InvariantCulturefor 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:
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:
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
❌ Wrong: comparing files paths culture-aware
✅ Correct: ordinal
❌ Wrong: implicit ToLower() for hashing
✅ Correct: invariant or ordinal
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 — implicitCurrentCulture.decimal.Parse(s)withoutInvariantCulturefor serialized data.DateTime.Parse("4/27/2026")— ambiguous; useParseExactwith 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.