Globalization & Localization
Key Points
- Globalization = make code work for any culture (parsing, formatting, comparisons). Localization = translate strings/UI per culture.
UseRequestLocalizationmiddleware setsCultureInfo.CurrentCultureper request fromAccept-Language, query, cookie, or custom provider.IStringLocalizer<T>+.resxfiles for translated strings. Or use a database/MOM (Message Object Model) for dynamic translation.- Always specify culture explicitly when parsing/formatting numbers, dates, currencies.
CultureInfo.InvariantCulturefor wire formats;CurrentCulturefor UI. String.Compare(..., StringComparison.OrdinalIgnoreCase)for non-linguistic comparison. Avoidstring.ToLower()for case folding — useStringComparison.OrdinalIgnoreCase.- Right-to-left languages (Arabic, Hebrew) need
dir="rtl"and CSS support.
Concepts (deep dive)
Setting up request localization
builder.Services.Configure<RequestLocalizationOptions>(o =>
{
var supported = new[] { "en-US", "es-ES", "fr-FR", "ja-JP" };
o.SetDefaultCulture("en-US")
.AddSupportedCultures(supported)
.AddSupportedUICultures(supported);
});
app.UseRequestLocalization(); // before any localization-using middleware
By default, providers (in order): 1. QueryStringRequestCultureProvider — ?culture=es-ES&ui-culture=es-ES 2. CookieRequestCultureProvider — cookie set after user selection 3. AcceptLanguageHeaderRequestCultureProvider — browser default
Custom provider:
o.AddInitialRequestCultureProvider(new CustomRequestCultureProvider(async ctx =>
{
var user = ctx.User.Identity?.Name;
var pref = await GetUserCultureAsync(user);
return new ProviderCultureResult(pref);
}));
CurrentCulture vs CurrentUICulture
CurrentCulture— drives parsing/formatting (numbers, dates, currency).CurrentUICulture— drives resource lookup (translated strings).
Often the same; sometimes different (a user prefers Spanish UI but US date format, e.g.).
Localization with IStringLocalizer<T>
// Resource file: Resources/HomeController.es.resx with key "Welcome" = "Bienvenido"
public class HomeController(IStringLocalizer<HomeController> localizer) : Controller
{
public IActionResult Index()
{
var greeting = localizer["Welcome"]; // "Welcome" or "Bienvenido" by current UI culture
return View(model: greeting.Value);
}
}
builder.Services.AddLocalization(o => o.ResourcesPath = "Resources");
builder.Services.AddControllersWithViews()
.AddViewLocalization()
.AddDataAnnotationsLocalization();
.resx files are XML; tooling (Visual Studio's resource editor) generates accessor classes. For modern dev, IStringLocalizerFactory can also load translations from a database or JSON.
Razor view localization
DataAnnotations localization
public class RegisterModel
{
[Required(ErrorMessage = "EmailRequired")]
[EmailAddress(ErrorMessage = "EmailInvalid")]
public string Email { get; set; } = "";
}
Resource file RegisterModel.resx provides the key EmailRequired → "Email is required".
Format strings — IFormatProvider matters
DateTime.Now.ToString("d"); // depends on CurrentCulture
DateTime.Now.ToString("d", CultureInfo.InvariantCulture); // explicit
decimal.Parse("1,234.56", CultureInfo.InvariantCulture); // 1234.56 in en-US
decimal.Parse("1,234.56", new CultureInfo("de-DE")); // throws — German uses '.' as thousands separator
Rule: for wire formats (JSON, query strings, files), use InvariantCulture. For user-facing display, use CurrentCulture.
StringComparison — avoid culture surprises
"İ".ToLower(); // depends on culture (Turkish 'I' issue)
"İ".ToLower(CultureInfo.InvariantCulture); // explicit but still expensive
// Preferred:
str.Equals("foo", StringComparison.OrdinalIgnoreCase);
str.Contains("foo", StringComparison.OrdinalIgnoreCase);
Ordinal* comparisons are byte-wise, fast, and culture-independent. OrdinalIgnoreCase is the right default for non-linguistic string comparisons (URLs, identifiers, JSON keys).
Time zones (related but separate)
Time zone handling is in DateTime & TimeProvider. Quick rule: store UTC, display in user's zone, never use DateTime.Now in services.
Right-to-left languages
<html lang="@CultureInfo.CurrentUICulture.Name" dir="@(CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? "rtl" : "ltr")">
CSS:
Modern CSS direction-aware properties (padding-inline-start) handle RTL automatically — prefer those.
Number formatting
1234.56.ToString("N", new CultureInfo("en-US")); // "1,234.56"
1234.56.ToString("N", new CultureInfo("de-DE")); // "1.234,56"
1234.56.ToString("C", new CultureInfo("ja-JP")); // "¥1,234.56"
"C" for currency picks the culture's currency symbol. Mind that the user's locale doesn't tell you their currency — a Japanese user paying in USD still wants the "$" sign for that price.
Pluralization
.resx doesn't natively support plurals ({0} item vs {0} items). For real i18n, use PluralRules (e.g., .resx per-form or Microsoft.Extensions.Localization.Plural).
Source-gen alternative
Microsoft.Extensions.Localization is reflection-based. AOT-friendly source-gen alternatives are emerging — keep an eye on LocalizationSourceGenerator for upcoming releases.
Code: correct vs wrong
❌ Wrong: parsing without culture
✅ Correct: explicit
❌ Wrong: ToLower() for case-insensitive compare
✅ Correct: StringComparison
❌ Wrong: hard-coded English error messages
✅ Correct: localized
Design patterns for this topic
Pattern 1 — "InvariantCulture for wire; CurrentCulture for UI"
- Intent: consistent serialization; correct user display.
Pattern 2 — "StringComparison.OrdinalIgnoreCase over ToLower"
- Intent: fast, culture-safe comparisons.
Pattern 3 — "IStringLocalizer<T> for app strings"
- Intent: key-based translation.
Pattern 4 — "Custom culture provider for user-stored preference"
- Intent: persist user choice across requests.
Pattern 5 — "Direction-aware CSS for RTL"
- Intent: layout adapts to language.
Pros & cons / trade-offs
| Choice | Pros | Cons |
|---|---|---|
.resx files | Built-in tooling | XML; static |
| Database localization | Dynamic | Custom infra |
| InvariantCulture | Predictable | Doesn't display nice |
| Cookie-based culture | Persists user choice | Cookie banner laws |
When to use / when to avoid
- Use
IStringLocalizer<T>for any user-facing string. - Use
InvariantCulturefor parsing/formatting in wire formats. - Use
StringComparison.OrdinalIgnoreCasefor non-linguistic compares. - Avoid
DateTime.Now— useTimeProvider. - Avoid
string.ToLower()for case-insensitive compares.
Interview Q&A
Q1. Difference between Globalization and Localization? Globalization = code works for any culture. Localization = translated strings for a specific culture.
Q2. CurrentCulture vs CurrentUICulture? CurrentCulture controls parsing/formatting. CurrentUICulture controls resource lookup (translated strings).
Q3. Why use StringComparison.OrdinalIgnoreCase over ToLower()? Faster (no allocation), culture-independent (no Turkish 'I' issues).
Q4. What's InvariantCulture for? Wire formats, file names, identifiers — anything that should be culture-independent.
Q5. How does UseRequestLocalization decide the culture? By default: query string → cookie → Accept-Language header. Custom providers can be inserted.
Q6. What's IStringLocalizer<T>? DI-injected localizer scoped to type T (its .resx namespace). [key] lookup returns translated string.
Q7. How do you localize validation messages? AddDataAnnotationsLocalization() + resource files matching the model class name.
Q8. RTL handling? Set dir="rtl" on the root element when CurrentUICulture.TextInfo.IsRightToLeft. Use direction-aware CSS.
Q9. How do you handle pluralization? .resx doesn't support plurals natively. Use external libraries with PluralRules (CLDR) for proper plurals.
Q10. Best practice for storing user culture preference? Cookie or DB column. Custom request culture provider reads it on each request.
Gotchas / common mistakes
- ⚠️ Parsing without explicit culture — fails in different regions.
- ⚠️
ToLower()for compare — allocation + culture surprise. - ⚠️ Hard-coded English in
return BadRequest("...")— can't translate. - ⚠️ Currency symbol from culture — culture is locale, not user's bank account.
- ⚠️ Forgetting
dir="rtl"— broken RTL layout.