Skip to content

Pragmatic.Internationalization

Comprehensive internationalization (i18n) and localization (l10n) for .NET 10. Culture-aware formatting, translations with plural support, and humanizers.

.NET provides low-level globalization primitives (CultureInfo, NumberFormatInfo, .resx files), but building a real multilingual application exposes several gaps:

  • Thread culture is fragile: CultureInfo.CurrentCulture is thread-local. After an await, your continuation may run on a different thread with a different culture. Setting DefaultThreadCurrentCulture is process-wide — a multi-tenant disaster.
  • No multi-scope culture: A request often needs Italian for UI, English for API responses, and German for invoices. .NET gives you two slots (CurrentCulture, CurrentUICulture). For anything more, you are on your own.
  • Money is not a type: Every e-commerce app reinvents a Money struct. Without it, you can silently add USD to EUR, rounding rules vary by developer, and formatting is inconsistent.
  • Plurals are language-specific: English has two forms (1 item, 5 items). Russian has three. Arabic has six. The CLDR defines rules that cannot be reduced to count == 1 ? singular : plural.
  • Resource files do not compose: .resx scatters translations across XML files with no build-time completeness checking. You can ship a release with half your Italian translations missing.

Pragmatic.Internationalization solves these with five pillars:

  1. I18NContext: An AsyncLocal-based ambient context that flows across await boundaries, supports multiple culture scopes (UI, Data, custom), and syncs with .NET thread cultures.
  2. Money & CurrencyCode: Value types enforcing same-currency arithmetic, ISO 4217 metadata, and culture-aware formatting.
  3. Formatting: Extension methods and injectable GlobalizationFormatter for numbers, dates, money, percentages, and file sizes.
  4. Translation Keys (SG): A source generator reads JSON files at compile time and produces a static T class with strongly-typed LocalizedString properties. Missing translations produce build warnings (PRAG1802).
  5. Humanizers: Duration, ordinal, quantity, and relative time formatters with language-specific rules for 17+ languages.
// Middleware sets I18NContext from Accept-Language / query string / provider chain
// It flows across all await boundaries automatically
var price = Money.From(99.99m, CurrencyCode.EUR);
price.Format(); // "99,99 EUR" (it-IT) / "$99.99" (en-US)
// Compile-time safe translations
return new NotFoundError(T.Errors.OrderNotFound.Value);

For the full architecture guide, see docs/concepts.md.

PackageDescription
Pragmatic.InternationalizationCore i18n: Money, Currency, Formatters, Humanizers
Pragmatic.Internationalization.AspNetCoreMiddleware, JSON converters, DI integration
Pragmatic.Internationalization.EFCoreValue converters for Money and Currency
Pragmatic.Internationalization.SourceGeneratorGenerates T class with embedded translations
Terminal window
dotnet add package Pragmatic.Internationalization
dotnet add package Pragmatic.Internationalization.AspNetCore
FeatureDescription
Money & CurrencyType-safe monetary values with 180+ ISO 4217 currencies
Culture ContextThread-safe ambient culture via AsyncLocal
FormattersNumbers, dates, money with culture-specific rules
TranslationsKey-based localization with fallback chains
Strongly-Typed KeysSource-generated T class with embedded translations
Plural RulesCLDR-compliant plural support for 40+ languages
HumanizersDuration, ordinals, quantities, relative time
ProvidersIn-memory, JSON files, composite with priority
using Pragmatic.Internationalization;
// Set ambient culture
I18NContext.SetCulture("de-DE");
// Get current culture
var culture = I18N.Culture; // CultureInfo for de-DE
// Scoped culture change
I18N.WithCulture("it-IT", () =>
{
var formatted = 1234.56m.FormatNumber(); // "1.234,56"
});
// Async scoped
await I18NContext.WithCultureAsync("fr-FR", async () =>
{
await ProcessOrderAsync();
});
using Pragmatic.Internationalization;
// Create money values
var price = Money.From(99.99m, CurrencyCode.USD);
var tax = Money.From(7.50m, CurrencyCode.USD);
// Arithmetic (same currency enforced)
var total = price + tax; // $107.49
var discounted = total * 0.9m; // $96.74
// Formatting (culture-aware)
I18NContext.SetCulture("en-US");
Console.WriteLine(total.Format()); // "$107.49"
I18NContext.SetCulture("de-DE");
Console.WriteLine(total.Format()); // "107,49 $"
// Currency safety
var usd = Money.From(100m, CurrencyCode.USD);
var eur = Money.From(100m, CurrencyCode.EUR);
// var mixed = usd + eur; // InvalidOperationException!
// Number formatting
1234.56m.FormatNumber(); // "1,234.56" (en-US) / "1.234,56" (de-DE)
0.15m.FormatPercent(); // "15%"
1_500_000L.FormatFileSize(); // "1.43 MB"
// Date formatting
var now = DateTimeOffset.Now;
now.FormatDate(); // "1/10/2026" (en-US) / "10.01.2026" (de-DE)
now.FormatTime(); // "3:45 PM" / "15:45"
now.FormatDateTime(); // "1/10/2026 3:45 PM"
// From decimal + currency
var price = Money.From(99.99m, CurrencyCode.EUR);
// From string currency code
var price2 = Money.From(99.99m, "EUR");
// Zero value
var zero = Money.Zero(CurrencyCode.USD);
// Safe parsing
if (Money.TryFrom(99.99m, "XYZ", out var money))
Console.WriteLine(money);
var a = Money.From(100m, CurrencyCode.USD);
var b = Money.From(25m, CurrencyCode.USD);
var sum = a + b; // $125.00
var diff = a - b; // $75.00
var scaled = a * 1.5m; // $150.00
var divided = a / 4; // $25.00
var negated = -a; // -$100.00
var price = Money.From(99.999m, CurrencyCode.USD);
// Round to specific decimals
var rounded = price.Round(2); // $100.00
// Round to currency's minor units
var currencyRounded = price.RoundToMinorUnit(); // $100.00 (USD has 2 minor units)
// JPY has 0 minor units
var yen = Money.From(999.5m, CurrencyCode.JPY);
var yenRounded = yen.RoundToMinorUnit(); // 1000
var money = Money.From(-50m, CurrencyCode.EUR);
money.Amount; // -50m
money.Currency; // CurrencyCode.EUR
money.IsZero; // false
money.IsPositive; // false
money.IsNegative; // true

All 180+ ISO 4217 currencies available as static properties:

CurrencyCode.USD // US Dollar ($, 2 minor units)
CurrencyCode.EUR // Euro (€, 2 minor units)
CurrencyCode.JPY // Japanese Yen (¥, 0 minor units)
CurrencyCode.GBP // British Pound (£, 2 minor units)
CurrencyCode.CHF // Swiss Franc (Fr., 2 minor units)
// ... 175+ more
var usd = CurrencyCode.USD;
usd.Code; // "USD"
usd.Name; // "US Dollar"
usd.Symbol; // "$"
usd.MinorUnits; // 2
// Parse with exception
var eur = CurrencyCode.FromCode("EUR");
// Safe parse
if (CurrencyCode.TryFromCode("XYZ", out var currency))
Console.WriteLine(currency.Name);
// Validation
bool valid = CurrencyCode.IsValid("USD"); // true
bool invalid = CurrencyCode.IsValid("XYZ"); // false
// All currencies
foreach (var c in CurrencyCode.All)
Console.WriteLine($"{c.Code}: {c.Name}");

LocalizedString stores translations per-entity in the database (as JSON column). Use for dynamic content like product names, descriptions, etc.

// Create localized string with multiple cultures
var greeting = LocalizedString.From(
("en", "Hello, {name}!"),
("de", "Hallo, {name}!"),
("it", "Ciao, {name}!"));
// Access for current culture
I18NContext.SetCulture("de-DE");
var text = greeting.Value; // "Hallo, {name}!"
// Access specific culture
var italian = greeting["it"]; // "Ciao, {name}!"
// Fallback chain (de-AT → de → en)
I18NContext.SetCulture("de-AT");
var withFallback = greeting.Get("de-AT"); // "Hallo, {name}!" (fallback to de)

Note: For application strings (UI labels, error messages), use the generated T class instead. See Strongly-Typed Translation Keys.

// In-memory provider
var provider = new InMemoryLocalizationProvider()
.AddString("en", "welcome", "Welcome!")
.AddString("de", "welcome", "Willkommen!")
.AddPlural("en", "items",
(PluralCategory.One, "1 item"),
(PluralCategory.Other, "{count} items"));
// JSON file provider
var jsonProvider = new JsonLocalizationProvider("Resources/Translations", options);
// Composite (priority-based)
var composite = new CompositeLocalizationProvider(
new[] { jsonProvider, provider });
var localizer = new StringLocalizer(provider, options);
// Simple lookup
var welcome = localizer["welcome"]; // "Welcome!" or "Willkommen!"
// With interpolation
var greeting = localizer["hello", "John"]; // "Hello, John!"
// Plural support
var items = localizer.Plural("items", 5); // "5 items"
// Culture switching
var german = localizer.WithCulture("de");
Resources/
├── en.json
├── de.json
└── it.json
en.json
{
"welcome": "Welcome!",
"items": {
"one": "1 item",
"other": "{count} items"
},
"errors": {
"notFound": "{entity} not found"
}
}

Source generator that creates a static T class with LocalizedString properties. Translations are embedded at compile-time - no runtime file loading required.

  1. Add translation JSON files to your project:
translations/
├── en.json
└── it.json
  1. Include as AdditionalFiles in .csproj:
<ItemGroup>
<AdditionalFiles Include="translations/*.json" />
</ItemGroup>
  1. Reference the source generator:
<ItemGroup>
<ProjectReference Include="..\Pragmatic.Internationalization.SourceGenerator\..."
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
// Generated from translations/en.json + translations/it.json
public static class T
{
/// <summary>Key: "welcome"</summary>
public static LocalizedString Welcome => LocalizedString.From(
("en", "Welcome to our app!"),
("it", "Benvenuto nella nostra app!"));
public static class Errors
{
public static LocalizedString NotFound => LocalizedString.From(
("en", "Resource not found"),
("it", "Risorsa non trovata"));
}
}
using Pragmatic.Internationalization.Context;
// Current culture access (via I18NContext)
I18NContext.SetCulture("en");
Console.WriteLine(T.Welcome.Value); // "Welcome to our app!"
I18NContext.SetCulture("it");
Console.WriteLine(T.Welcome.Value); // "Benvenuto nella nostra app!"
// Direct culture access via indexer
Console.WriteLine(T.Welcome["en"]); // "Welcome to our app!"
Console.WriteLine(T.Welcome["it"]); // "Benvenuto nella nostra app!"
// Implicit string conversion (uses current culture)
string message = T.Welcome; // Works!
// LocalizedString properties
var cultures = T.Welcome.Cultures; // ["en", "it"]
var count = T.Welcome.Count; // 2
var isEmpty = T.Welcome.IsEmpty; // false

The source generator automatically detects three folder layouts from the first AdditionalFile path. No configuration is needed — just organize your JSON files and the generator figures out the rest.

Detection runs against the first file in the AdditionalFiles list. It checks, in order:

  1. Is the parent directory a culture code? (e.g., en, it, de-DE) —> Folder per culture
  2. Does the filename contain a dot where the last segment is a culture code? (e.g., common.en.json) —> Flat with suffix
  3. Otherwise —> Simple (filename itself is the culture code)

Culture codes match the pattern xx, xx-YY, xx_YY, or xx-Hant (2 lowercase letters + optional separator + 2-4 chars).

Each culture has its own subdirectory. Files with the same name across cultures are matched together.

translations/
├── en/
│ ├── common.json
│ └── errors.json
└── it/
├── common.json
└── errors.json
<AdditionalFiles Include="translations/**/*.json" />
SourceCulture extracted fromBase nameGenerated key (ByFile=true)
translations/en/common.jsonParent dir encommonT.Common.Welcome
translations/it/errors.jsonParent dir iterrorsT.Errors.NotFound

Best for projects with many files per culture. Each file becomes a nested class in the generated T class.

All files in one folder. The culture code is the last dot-segment before .json.

translations/
├── common.en.json
├── common.it.json
├── errors.en.json
└── errors.it.json
<AdditionalFiles Include="translations/*.json" />
SourceCulture extracted fromBase nameGenerated key (ByFile=true)
common.en.jsonLast dot-segment encommonT.Common.Welcome
errors.it.jsonLast dot-segment iterrorsT.Errors.NotFound

Good when you prefer a flat directory but still want to split translations by topic.

Structure C: Simple (one file per culture)

Section titled “Structure C: Simple (one file per culture)”

One JSON file per culture. The filename (without .json) is the culture code. All keys live in a single file.

translations/
├── en.json
└── it.json
<AdditionalFiles Include="translations/*.json" />
SourceCulture extracted fromBase nameGenerated key
en.jsonFilename en(none)T.Welcome
it.jsonFilename it(none)T.Welcome

Simplest layout. Since there is no base name, ByFile grouping has no effect — all keys are placed directly on the root T class (nested classes still appear from dotted JSON keys like errors.notFound).

Files are picked up when they sit under any of these directory names:

Folder nameExample
translationstranslations/en.json
i18ni18n/en/common.json
localeslocales/common.en.json
langlang/en.json

Files matching *.translations.json or *.i18n.json are also included regardless of folder.

[assembly: TranslationKeys(
ClassName = "T", // Default: "T"
Namespace = "", // Default: auto-derived from folder
EmbedTranslations = true, // Default: true (LocalizedString with values)
ByFile = true, // Default: true (nested classes per file)
DefaultCulture = "en")] // Default: "en"

When ByFile = true (default), each JSON file becomes a nested class:

translations/en/common.json → T.Common.Welcome
translations/en/errors.json → T.Errors.NotFound

When Pragmatic.Composition is referenced, the generator emits assembly metadata for discovery:

[assembly: PragmaticMetadata(MetadataCategory.Translations, "1.0.0", """
{
"className": "T",
"namespace": "MyApp",
"totalKeys": 25,
"cultures": ["en", "it", "de"],
"files": ["common", "errors", "validation"]
}
""")]

This enables host applications to discover translation metadata from referenced assemblies.

CLDR-compliant plural rules for 40+ languages:

// English: one/other
PluralRules.GetCategory("en", 1); // One
PluralRules.GetCategory("en", 5); // Other
// Russian: one/few/many/other
PluralRules.GetCategory("ru", 1); // One
PluralRules.GetCategory("ru", 2); // Few
PluralRules.GetCategory("ru", 5); // Many
// Arabic: zero/one/two/few/many/other
PluralRules.GetCategory("ar", 0); // Zero
PluralRules.GetCategory("ar", 1); // One
PluralRules.GetCategory("ar", 2); // Two
CategoryLanguages Example
ZeroArabic, Welsh
OneEnglish, German, Italian
TwoArabic, Welsh
FewRussian, Polish, Czech (2-4)
ManyRussian, Polish (5-20)
OtherAll (default plural)
var formatter = new DurationFormatter("en", DurationFormat.Short);
formatter.Format(TimeSpan.FromMinutes(150)); // "2h 30m"
formatter.Format(TimeSpan.FromDays(3.5)); // "3d 12h"
// Long format
var long = DurationFormatter.Long("en");
long.Format(TimeSpan.FromHours(2.5)); // "2 hours 30 minutes"
// Compact format
var compact = DurationFormatter.Compact("en");
compact.Format(TimeSpan.FromHours(2.5)); // "2:30:00"

Supported languages: en, de, fr, it, es, pt, ru, zh, ja, ko, ar, nl, pl, sv, no, da, fi

var ordinal = new OrdinalFormatter("en");
ordinal.Format(1); // "1st"
ordinal.Format(2); // "2nd"
ordinal.Format(3); // "3rd"
ordinal.Format(11); // "11th"
ordinal.Format(21); // "21st"
// German
var german = OrdinalFormatter.ForCulture("de");
german.Format(1); // "1."
german.Format(2); // "2."
// French with gender
var feminine = new OrdinalFormatter("fr", OrdinalGender.Feminine);
feminine.Format(1); // "1ère"
var qty = new QuantityFormatter("en");
qty.Format(1_500); // "1.5K"
qty.Format(2_300_000); // "2.3M"
qty.Format(1_200_000_000); // "1.2B"
// German
var german = QuantityFormatter.ForCulture("de");
german.Format(1_500_000); // "1,5 Mio"
// Chinese
var chinese = QuantityFormatter.ForCulture("zh");
chinese.Format(1_000_000); // "100万"
var relativeTime = new RelativeTimeFormatter(localizer);
relativeTime.Format(TimeSpan.FromSeconds(30)); // "30 seconds ago"
relativeTime.Format(TimeSpan.FromMinutes(5)); // "5 minutes ago"
relativeTime.Format(TimeSpan.FromHours(3)); // "3 hours ago"
relativeTime.Format(TimeSpan.FromDays(1)); // "yesterday"
relativeTime.Format(TimeSpan.FromDays(5)); // "5 days ago"
relativeTime.Format(TimeSpan.FromDays(14)); // "2 weeks ago"
Program.cs
builder.Services.AddPragmaticInternationalization(options =>
{
options.DefaultCulture = CultureInfo.GetCultureInfo("en-US");
options.SupportedCultures = ["en-US", "de-DE", "it-IT", "fr-FR"];
options.QueryStringKey = "culture"; // ?culture=de-DE
});
var app = builder.Build();
app.UsePragmaticInternationalization();

Order of precedence:

  1. Query string: ?culture=de-DE
  2. Accept-Language header
  3. Default culture

Money and Currency types serialize automatically:

{
"price": {
"amount": 99.99,
"currency": "EUR"
}
}
// DbContext configuration
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyPragmaticInternationalization();
}
// Or with conventions
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.ApplyPragmaticInternationalizationConventions();
}
  • CurrencyCodevarchar(3)
  • Money → Configured with amount and currency columns

DI-injectable formatter service:

public class OrderService(GlobalizationFormatter formatter)
{
public string FormatOrderTotal(Order order)
{
return formatter.FormatMoney(order.Total);
}
public string FormatOrderDate(Order order)
{
return formatter.FormatDateTime(order.CreatedAt);
}
}
MethodExample Output (en-US)
FormatMoney(money)”$99.99”
FormatNumber(decimal, decimals)”1,234.56”
FormatInteger(long)”1,234”
FormatPercent(decimal)”15%“
FormatFileSize(bytes)”1.43 MB”
FormatDate(date)”1/10/2026”
FormatTime(time)”3:45 PM”
FormatDateTime(datetime)”1/10/2026 3:45 PM”
FormatDateTimeLong(datetime)”January 10, 2026 3:45 PM”
1234.56m.FormatNumber(); // Culture-aware
1234.56m.FormatNumber(3); // With decimals
0.156m.FormatPercent(); // As percentage
1_500_000L.FormatFileSize(); // Human-readable bytes
DateTimeOffset.Now.FormatDate();
DateTimeOffset.Now.FormatTime();
DateTimeOffset.Now.FormatDateTime();
DateOnly.FromDateTime(DateTime.Now).FormatDateLong();
TimeOnly.FromDateTime(DateTime.Now).FormatTimeLong();
money.Format(); // Using current culture
money.Format("de-DE"); // Specific culture
money.RoundToCurrency(); // Round to minor units
money.FormatOrDefault("-"); // Nullable with default

Decision Guide: LocalizedString vs T Class

Section titled “Decision Guide: LocalizedString vs T Class”
AspectLocalizedStringT Class
StorageDatabase (JSON column)Assembly (embedded at compile-time)
ScopePer-entity instanceApplication-wide
ContentDynamic, user-generatedFixed, developer-controlled
MutabilityAdd/remove translations at runtimeRecompile to change
Type SafetyDictionary-like access (greeting["de"])Strongly-typed properties (T.Errors.NotFound)
PerformanceLazy loading from DBZero-cost (in-memory, no file I/O)
FallbackAutomatic culture chain via I18NContextSame chain (returns LocalizedString internally)
DeploymentNew translations without redeployRequires recompile and redeploy
EF CoreValue converter to JSON columnNot applicable (static class)

Use for database-driven, per-entity content where translations vary per record and may be edited at runtime:

// Each product has its own translations -- stored as JSON in the DB
public class Product
{
public LocalizedString Name { get; set; } // "Widget" / "Gerät" / "Gadget"
public LocalizedString Description { get; set; } // User-generated, per product
}
// Runtime flexibility: add a new language without recompiling
product.Name.Set("ja", "ウィジェット");
// Access uses current culture automatically (via I18NContext)
var name = product.Name.Value;
// Or access a specific culture directly
var german = product.Name["de"];
// Exact access without fallback
var exact = product.Name.GetExact("de-AT"); // null if de-AT not present
// Scope-specific access (e.g., invoicing in a different language)
var invoiceName = product.Name.GetForScope("invoicing");

Examples: product names, category descriptions, CMS content, invoice notes, user-generated content.

Use for compile-time safe application strings where translations are developer-controlled and known at build time:

// translations/en.json → source-generated static T class
// All translations are embedded in the assembly -- no runtime file loading
return new NotFoundError(T.Errors.ProductNotFound); // Compile-time safe
Console.WriteLine(T.Common.Save); // "Speichern" in de-DE
// The T class returns LocalizedString, so the same culture resolution applies
string message = T.Welcome; // Implicit conversion uses current culture
var cultures = T.Welcome.Cultures; // ["en", "it"] -- all embedded cultures

When EmbedTranslations = false, the T class generates LocalizationKey references instead. These are lightweight key strings resolved at runtime through an ILocalizationProvider, which allows swapping translation sources without recompiling.

Examples: UI labels, error messages, email subjects, validation messages, system notifications.

Most applications use both approaches together:

[Endpoint(HttpVerb.Get, "/products/{id}")]
public partial class GetProductEndpoint : Endpoint<ProductResponse>
{
public override async Task<Result<ProductResponse>> HandleAsync(CancellationToken ct)
{
var product = await _repo.GetByIdAsync(Id, ct);
if (product is null)
return new NotFoundError(T.Errors.ProductNotFound); // T class (fixed)
return new ProductResponse
{
Name = product.Name.Value, // LocalizedString (per-entity, from DB)
Description = product.Description.Value
};
}
}

I18NContext stores the current internationalization context using AsyncLocal<I18NContext?>, making it thread-safe and compatible with async/await. The context automatically flows across await boundaries.

The context is an immutable object stored in AsyncLocal:

// Internal storage (from I18NContext.cs)
private static readonly AsyncLocal<I18NContext?> SCurrent = new();

When you set a culture, a new immutable I18NContext instance is created and assigned to the AsyncLocal. The previous instance is never mutated. This guarantees thread safety without locks.

// Setting culture creates a new context instance
I18NContext.SetCulture("de-DE");
// The context is available anywhere in the same async flow
var culture = I18NContext.Current.UICulture; // de-DE

If no context has been set explicitly, I18NContext.Current falls back to CultureInfo.CurrentCulture from the current thread. This means the system works out of the box in console applications and tests without any setup.

When you set a culture via I18NContext.SetCulture(), the context also syncs with .NET’s built-in thread cultures:

  • UICulture syncs with Thread.CurrentThread.CurrentUICulture
  • DataCulture syncs with Thread.CurrentThread.CurrentCulture

This ensures third-party libraries that read Thread.CurrentThread.CurrentCulture (e.g., ToString() formatting) behave consistently with the I18NContext.

The context supports separate cultures for different purposes within the same request:

ScopePurposeExample
UICultureDisplay: labels, messages, user-facing contentit-IT
DataCultureStorage: API responses, exports, databaseen-US
Custom scopesSpecific needs like invoicing, reportingde-DE for invoicing
// UI in Italian, Data in English
I18NContext.SetCulture("it-IT");
I18NContext.SetDataCulture(CultureCode.FromString("en-US"));
// Access via I18N shortcuts
var uiCulture = I18N.UI; // it-IT
var dataCulture = I18N.Data; // en-US
// Custom scope for invoicing
I18N.SetScope("invoicing", CultureCode.FromString("de-DE"));
var invoiceCulture = I18N.Scope["invoicing"]; // de-DE

When SyncScopes is true (set via I18NConfig), changing the UI culture automatically updates the Data culture to match.

For temporary culture changes, use the WithCulture / WithCultureAsync methods. These save the current context, execute the delegate with the new culture, and restore the original context in a finally block:

// Synchronous scope
I18NContext.WithCulture("de-DE", () =>
{
var formatted = 1234.56m.FormatNumber(); // "1.234,56"
});
// Original culture is restored here
// Async scope
await I18NContext.WithCultureAsync("fr-FR", async () =>
{
await ProcessOrderAsync(); // Runs with fr-FR culture
});
// Scoped with return value
var result = I18NContext.WithCulture<string>("it-IT", () =>
{
return T.Welcome.Value; // "Benvenuto nella nostra app!"
});
// Data culture scope (independent of UI)
I18NContext.WithDataCulture(CultureCode.FromString("en-US"), () =>
{
var apiDate = DateTime.Now.ToString("O"); // ISO 8601 format
});
// Custom scope execution
I18NContext.WithScope("invoicing", CultureCode.FromString("de-DE"), () =>
{
var invoiceTitle = product.Name.GetForScope("invoicing");
});

AsyncLocal flows automatically with the ExecutionContext, so the culture is preserved across await points:

I18NContext.SetCulture("de-DE");
await Task.Delay(100);
// Still de-DE here -- AsyncLocal flows across await
await Task.Run(() =>
{
// Also de-DE -- AsyncLocal flows into Task.Run
var culture = I18NContext.Current.UICulture;
});

Limitation with ConfigureAwait(false): The WithCultureAsync methods use ConfigureAwait(false) internally. This means the finally block that restores the previous context may run on a different thread. However, because AsyncLocal is tied to the ExecutionContext (not the thread), the context restore still works correctly. The thread culture properties (Thread.CurrentThread.CurrentUICulture) are restored in the finally block explicitly, ensuring proper cleanup regardless of which thread the continuation runs on.

Limitation with unsynchronized parallel work: If you spawn fire-and-forget tasks (e.g., _ = Task.Run(...)) inside a WithCultureAsync scope, those tasks capture the AsyncLocal value at spawn time. Changing the culture inside the spawned task does not affect the parent scope, and vice versa. This is standard AsyncLocal copy-on-write behavior.

For tests, use Capture() and Restore() to ensure isolation between test cases:

// Capture before test
var snapshot = I18NContext.Capture();
try
{
I18NContext.SetCulture("de-DE");
// ... test code ...
}
finally
{
// Restore original state (AsyncLocal + thread cultures)
I18NContext.Restore(snapshot);
}

The I18NContextSnapshot record captures three things: the AsyncLocal context, Thread.CurrentThread.CurrentUICulture, and Thread.CurrentThread.CurrentCulture. Restoring the snapshot resets all three.

PluralRules implements CLDR-compliant plural category selection for 40+ languages. The rules are organized by language family in partial classes:

  • PluralRules.cs — Core dispatch logic and language code extraction
  • PluralRules.Germanic.cs — English, German, Dutch, Swedish, etc.
  • PluralRules.Romance.cs — French, Italian, Spanish, Romanian, etc.
  • PluralRules.Slavic.cs — Russian, Polish, Czech, Slovenian, etc.
  • PluralRules.Other.cs — Arabic, Welsh, Irish, Baltic, Hebrew, Indic, etc.

PluralRules.GetCategory(culture, count) extracts the language code from the culture string before applying rules. A culture like "de-AT" (Austrian German) is normalized to "de", so it uses the same plural rules as "de-DE" or plain "de". Region-specific cultures always inherit the base language’s plural behavior:

// All map to "en" → Germanic rules
PluralRules.GetCategory("en", 1); // One
PluralRules.GetCategory("en-US", 1); // One
PluralRules.GetCategory("en-GB", 1); // One
// All map to "ru" → East Slavic rules
PluralRules.GetCategory("ru", 5); // Many
PluralRules.GetCategory("ru-RU", 5); // Many

All six CLDR categories are defined in the PluralCategory enum. Not every language uses all categories:

CategoryDescriptionExample Languages
ZeroExplicit zero formArabic (ar), Welsh (cy), Latvian (lv)
OneSingular formEnglish, German, Russian, Polish, most languages
TwoDual formArabic (ar), Welsh (cy), Hebrew (he), Slovenian (sl)
FewPaucal / small number formRussian (2-4), Polish (2-4), Czech (2-4), Arabic (3-10), Irish (3-6)
ManyLarge number formRussian (5-20), Arabic (11-99), Welsh (6), Irish (7-10)
OtherDefault / general pluralAll languages (always present as fallback)

Germanic (en, de, nl, sv, da, no, nb, nn, fi, et, hu, el): simple one/other split.

PluralRules.GetCategory("en", 1); // One -- "1 item"
PluralRules.GetCategory("en", 0); // Other -- "0 items"
PluralRules.GetCategory("en", 5); // Other -- "5 items"

Romance (fr, it, es, pt, ca): treat both 0 and 1 as singular.

PluralRules.GetCategory("fr", 0); // One -- "0 élément"
PluralRules.GetCategory("fr", 1); // One -- "1 élément"
PluralRules.GetCategory("fr", 2); // Other -- "2 éléments"

East Slavic (ru, uk, be): one/few/many based on last digits.

PluralRules.GetCategory("ru", 1); // One -- "1 файл" (ends in 1, not 11)
PluralRules.GetCategory("ru", 21); // One -- "21 файл"
PluralRules.GetCategory("ru", 2); // Few -- "2 файла" (ends in 2-4, not 12-14)
PluralRules.GetCategory("ru", 5); // Many -- "5 файлов"
PluralRules.GetCategory("ru", 11); // Many -- "11 файлов" (special case: 11-14)
PluralRules.GetCategory("ru", 101); // One -- ends in 1, not 11
PluralRules.GetCategory("ru", 111); // Many -- ends in 11 (special case)

Polish (pl): similar to East Slavic but 1 is the only One value.

PluralRules.GetCategory("pl", 1); // One -- "1 plik"
PluralRules.GetCategory("pl", 2); // Few -- "2 pliki" (ends in 2-4, not 12-14)
PluralRules.GetCategory("pl", 5); // Many -- "5 plików"
PluralRules.GetCategory("pl", 22); // Few -- "22 pliki"

Czech/Slovak (cs, sk): one/few/other.

PluralRules.GetCategory("cs", 1); // One -- "1 soubor"
PluralRules.GetCategory("cs", 3); // Few -- "3 soubory" (2-4)
PluralRules.GetCategory("cs", 5); // Other -- "5 souborů"

Arabic (ar): uses all six CLDR categories.

PluralRules.GetCategory("ar", 0); // Zero -- special form for 0
PluralRules.GetCategory("ar", 1); // One -- singular
PluralRules.GetCategory("ar", 2); // Two -- dual form
PluralRules.GetCategory("ar", 5); // Few -- 3-10 (mod 100)
PluralRules.GetCategory("ar", 15); // Many -- 11-99 (mod 100)
PluralRules.GetCategory("ar", 100); // Other -- 100, 200, etc.

East Asian (ja, zh, ko, vi, th, id, ms) and Turkish-family (tr, az, ka, ky, kk, uz): no plural forms, always Other.

PluralRules.GetCategory("ja", 1); // Other -- always
PluralRules.GetCategory("zh", 100); // Other -- always
PluralRules.GetCategory("tr", 1); // Other -- always

Plural rules integrate with PluralString and StringLocalizer:

// Define plural forms in JSON
// en.json: { "items": { "one": "1 item", "other": "{count} items" } }
// Via StringLocalizer
var localizer = new StringLocalizer(provider, options);
localizer.Plural("items", 1); // "1 item"
localizer.Plural("items", 5); // "5 items"
// Via InMemoryLocalizationProvider
var provider = new InMemoryLocalizationProvider()
.AddPlural("en", "files",
(PluralCategory.One, "1 file"),
(PluralCategory.Other, "{count} files"))
.AddPlural("ru", "files",
(PluralCategory.One, "{count} файл"),
(PluralCategory.Few, "{count} файла"),
(PluralCategory.Many, "{count} файлов"));

When a specific plural category is not defined in the translation data, PluralString falls back to the Other category. If Other is also missing, it returns an empty string.

[Fact]
public void LocalizedString_ReturnsCorrectTranslation()
{
var str = new LocalizedString(new Dictionary<string, string>
{
["en"] = "Hello",
["it"] = "Ciao"
});
CultureContext.Current = new CultureInfo("it");
Assert.Equal("Ciao", str.Value);
CultureContext.Current = new CultureInfo("en");
Assert.Equal("Hello", str.Value);
}
[Fact]
public void PluralString_SelectsCorrectForm()
{
var plural = new PluralString(
(PluralCategory.One, "{count} item"),
(PluralCategory.Other, "{count} items"));
Assert.Equal("1 item", plural.Format(1));
Assert.Equal("5 items", plural.Format(5));
}

The source generator emits the following diagnostics:

IDSeverityDescriptionSolution
PRAG1800ErrorTranslation JSON file could not be parsedFix JSON syntax errors in the translation file
PRAG1801WarningDuplicate translation key detected for same cultureConsolidate duplicate keys into a single file per culture
PRAG1802WarningTranslation key exists in default culture but is missing in another cultureAdd the missing key to the incomplete translation file
PRAG1803InfoTranslation file contains no valid translation keysAdd string key-value pairs to the file, or remove the empty file
  • .NET 10.0 or later

MIT