Skip to content

Globalization & localization

Targets: net10.0, csharp-14 · Last reviewed: 2026-08-21 · Sources: ms-learn, meziantou, andrew-lock, khalid, steve-gordon, jon-skeet, avalonia-blog

The mechanism is settled: .resx resources behind IStringLocalizer<T>, culture selection via request localization middleware, ICU as the culture-data engine on every platform. The decisions that cause trouble are all defaults — culture leaking into machine-facing strings, invariant mode arriving by template rather than by choice, and container images that ship without the data the runtime needs.

Opinions

Split every format, parse, and compare call by audience

Human-facing text uses the current culture; machine-facing text uses CultureInfo.InvariantCulture explicitly, and machine-facing comparison uses StringComparison.Ordinal. This one rule prevents most globalization bugs, and the second half is the one that gets skipped. The failure is silent until someone runs the service under de-DE and 1.5 round-trips as 15. (Microsoft Learn: Best practices for comparing strings, Microsoft Learn: Double.Parse)

Audience Formatting & parsing Comparison
A human reading a screen CurrentCulture (the default) CurrentCulture, for sorting a displayed list
A machine: JSON, logs, URLs, file names, config, DB round-trips CultureInfo.InvariantCulture, explicitly StringComparison.Ordinal

Never use StringComparison.InvariantCulture or InvariantCultureIgnoreCase. They look like the machine-facing answer and are not: only the culture data is invariant, while the collation still tracks whichever ICU (or NLS) version the host carries, so the same comparison can return different answers on two machines. InvariantCulture is right for formatting and parsing and wrong for comparison — the two halves of the type are not the same promise. (Meziantou: StringComparison.InvariantCulture is not always invariant, Microsoft Learn: Globalization and ICU)

// Wrong: the host's collation decides, and hosts disagree
if (string.Equals(header, "application/json", StringComparison.InvariantCultureIgnoreCase))

// Right: a protocol token is machine-facing
if (string.Equals(header, "application/json", StringComparison.OrdinalIgnoreCase))

The analyzers find the call sites: CA1304/CA1305 for culture-less formatting, CA1309 for InvariantCulture comparison, CA1310/CA1311 for culture-less IndexOf and casing. All five warn at the latest-recommended level templates/Directory.Build.props sets. CA1307 is the exception and needs an explicit severity, which templates/.editorconfig now carries:

dotnet_diagnostic.CA1307.severity = warning

With TreatWarningsAsErrors already on, that turns "someone forgot a StringComparison" into a build failure. Meziantou.Analyzer (see csharp.md) covers the same ground from the other direction; a BannedApiAnalyzers list for the two invariant comparisons is the optional second step, and lost here because CA1309 already catches them.

Treat InvariantGlobalization as an explicit decision, never an inherited template line

Decide invariant mode on its own merits, and comment the property where you set it. <InvariantGlobalization>true</InvariantGlobalization> makes the runtime skip ICU entirely and use built-in invariant data — a legitimate size and startup win for a service that renders no localized output, and a trap when it arrives attached to an unrelated decision. Verified on SDK 10.0.400: dotnet new webapi does not set it, dotnet new webapiaot does, sitting in the generated project file next to <PublishAot>true</PublishAot>. So it typically enters a codebase because someone went Native AOT (see runtime-performance.md), and then changes how every ToString and Compare in the app behaves.

What turning it on costs:

  • Only the invariant culture exists. Constructing any other CultureInfo throws, unless you also set PredefinedCulturesOnly=false.
  • TimeZoneInfo.TryConvertIanaIdToWindowsId and TryConvertWindowsIdToIanaId fail, because both are ICU-dependent.
  • Casing and collation stop being linguistic. This is not a substitute for Ordinal: the audience split above still applies.

(Microsoft Learn: Globalization config settings, Microsoft Learn: Globalization and ICU)

Pin ICU only when reproducible comparison beats current comparison

Take the default, system ICU, unless you have a stated reason not to. Globalization has run on ICU on every platform since .NET 5, including Windows, which ships icu.dll. Three alternatives exist, in descending order of how often they are right:

  • System.Globalization.AppLocalIcu plus a Microsoft.ICU.ICU4C.Runtime package reference carries a pinned ICU with the app, so collation and CLDR data are byte-identical across every deployment. The right answer when a sort order is part of your contract.
  • DOTNET_ICU_VERSION_OVERRIDE pins a system ICU version on Linux. It was CLR_ICU_VERSION_OVERRIDE before .NET 10, and it only applies to Microsoft-built .NET, not distro builds. (Microsoft Learn: Breaking changes in .NET 10)
  • System.Globalization.UseNls goes back to Windows NLS. Bug-compatibility with a legacy app only; it forfeits the IANA/Windows time-zone-id conversion APIs. Since .NET 9 the environment variable wins over the runtimeconfig.json value for this setting; before, it was the other way round.

(Microsoft Learn: Globalization config settings)

Call AddLocalization with a ResourcesPath, and select culture with UseRequestLocalization early in the pipeline.

builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

var supportedCultures = new[] { "en-GB", "fr", "de" };
app.UseRequestLocalization(new RequestLocalizationOptions()
    .SetDefaultCulture(supportedCultures[0])
    .AddSupportedCultures(supportedCultures)
    .AddSupportedUICultures(supportedCultures));

Four things decide whether this works in production:

  • SupportedCultures and SupportedUICultures are separate axes. CurrentCulture drives number, date, and currency formatting and sorting; CurrentUICulture drives which .resx the ResourceManager resolves. English text with German number formatting is a supported configuration, not a bug. (Microsoft Learn: Globalization and localization in ASP.NET Core)
  • Provider order is query string → cookie → Accept-Language, first match wins. Drive production off the cookie and keep the query string for debugging: Accept-Language reflects the user's OS, not a choice they made, so a production app needs a way for a user to set their own culture. (Lock: Adding Localisation to an ASP.NET Core application)
  • Middleware order matters. UseRequestLocalization runs before anything that reads the culture, and after routing if you use RouteDataRequestCultureProvider. (Microsoft Learn: Globalization and localization in ASP.NET Core)
  • Culture fallback is per-resource and ends at the default file: Welcome.fr-CA.resx → Welcome.fr.resx → Welcome.resx. If you want a missing translation to surface as its key rather than silently render English, ship no default .resx at all.

One failure worth recognising on sight: if RootNamespace and AssemblyName differ (a project directory named my-project-name, say), resource lookup fails entirely. Fix it with [assembly: RootNamespace] and [assembly: ResourceLocation], not by renaming resources. (Microsoft Learn: Globalization and localization in ASP.NET Core)

Name resource keys with a constants class, not magic strings

Reach IStringLocalizer through a static class ResourceKeys of const string fields. IStringLocalizer deliberately accepts the default-language string as the key (_localizer["About Title"]), which removes the up-front .resx work and scatters magic strings through the codebase in exchange. A constants class costs one file, keeps the key in one place, and works in attributes such as [Display(Name = ResourceKeys.AboutTitle)]. (Lock: Localising the DisplayAttribute and avoiding magic strings)

Designer-generated strongly-typed resource classes are the compile-time-safe alternative and lost on portability. .NET still ships no cross-platform strongly-typed resource generator: <GenerateSource>true</GenerateSource> on an EmbeddedResource builds clean and emits nothing on net10.0. The designer route is therefore Visual Studio tooling with a checked-in .Designer.cs, and the cross-platform route is Microsoft.CodeAnalysis.ResxSourceGenerator, which dotnet/runtime itself uses but Microsoft still publishes only as a prerelease. Revisit when it ships stable. (Abuhakmeh: Getting Started With .NET Localization)

Client-side: ship the globalization data the app can actually reach

  • Blazor WebAssembly loads only the app's own culture data by default. Set <BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData> if the user can switch culture at runtime. Time-zone data trims separately via <InvariantTimezone>true</InvariantTimezone>; <BlazorEnableTimeZoneSupport> is superseded, so delete it. In .NET 10, standalone WASM apps also load globalization data for CultureInfo.DefaultThreadCurrentUICulture, where .NET 9 and earlier honoured only DefaultThreadCurrentCulture. (Microsoft Learn: Blazor globalization and localization)
  • .NET MAUI has no localization abstraction: plain per-culture .resx plus CultureInfo.DefaultThreadCurrentUICulture, and no RequestLocalization equivalent, so switching culture at runtime means re-resolving bindings yourself. (Microsoft Learn: .NET MAUI localization)
  • Avalonia reaches .resx from XAML via {x:Static} against a generated resources class. Switching language without a restart has no first-party answer: it needs a custom markup extension, and the community packages that offer one are unvetted. This is the project's own documentation, so take it for mechanism rather than for whether you should want it; the source-redundancy caveat in ui-frameworks.md applies. (Avalonia Docs: Localizing using ResX)

Containers: chiseled and Alpine images drop this data

If the app formats dates, sorts text, or resolves a time zone, use the -extra image tag. project-structure.md steers container builds to noble-chiseled or alpine for size, and the size-optimised images (Alpine, Ubuntu chiseled, Azure Linux distroless) are precisely the ones that "don't include globalization dependencies such as ICU or tzdata … only work with apps that are configured for globalization invariant mode". Every one of them has an -extra counterpart (10.0-alpine-extra, 10.0-noble-chiseled-extra) that adds ICU, tzdata, and stdc++ back. (Microsoft Learn: .NET container images)

The two missing pieces fail differently, and so does the ICU one depending on what the app asks for:

  • No ICU, and the image has already decided for you. The base images set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true as an image environment variable, so the app runs in invariant mode however it was built. An app that names a culture throws CultureNotFoundException at startup: PredefinedCulturesOnly defaults to true here, so the RequestLocalizationOptions above dies before serving a request. An app that only leans on CurrentCulture fails without an exception instead, formatting and comparing as invariant. Because the setting lives in the environment rather than the project file, <InvariantGlobalization>false</InvariantGlobalization> does not undo it, and installing icu-libs and icu-data-full by hand means clearing the variable too. Taking -extra is the version that works, because those images simply omit the variable. (Microsoft Learn: Environment variables take precedence in app runtime configuration settings, Microsoft Learn: Globalization config settings, dotnet-docker: runtime-deps 10.0 Dockerfiles)
  • No tzdata: a loud failure. TimeZoneInfo.FindSystemTimeZoneById throws TimeZoneNotFoundException, which is unaffected by invariant mode and so survives as a real exception. RUN apk add --no-cache tzdata fixes that one alone. (Gordon: TimeZoneNotFoundException in Alpine Based Docker Images)

Localization makes dates look right, not be right

Store instants as UTC, store a user's IANA time-zone id rather than a fixed offset, and convert at the edge. The full storage and type-choice stance, including when a future local event should not collapse to UTC, is in datetime.md. A local date and time can legitimately occur twice or not at all, and no amount of culture-correct formatting fixes a value that was ambiguous before it was formatted. That is the argument behind Noda Time's separate Instant, LocalDateTime, and ZonedDateTime types. (Skeet: More fun with DateTime)

TimeZoneInfo.TryConvertIanaIdToWindowsId bridges the two id families, but only outside invariant and NLS modes, so the storage decision above and the two configuration decisions above are one decision.

Coming next (preview, not yet the opinion)

WebAssemblyComponentsOptions.UseCultureFromServer lets a Blazor Web App's WASM client pick a culture independently of the server during prerendering. The documentation carries it under the aspnetcore-11.0 moniker only. It is a .NET 11 preview, not a .NET 10 feature, despite being summarised elsewhere as one. (Microsoft Learn: Blazor globalization and localization)