Globalization in .NET: CultureInfo, CurrentCulture/CurrentUICulture, the Turkish I and ICU on Linux
In .NET, CurrentCulture decides formatting and comparison and CurrentUICulture decides UI text; both flow through async code, and without ICU a container falls into invariant mode. Using Bookstore's catalog sorting and search box we show Turkish sorting, the ı/İ conversion, the culture-dependent dictionary comparer trap, PredefinedCulturesOnly and how tr-TR breaks in chiseled and alpine images, all with real output.
Version note: The code in this post was built and run with .NET 10; the container examples were run with the .NET 10.0.15 runtime images (the Ubuntu 24.04 based aspnet, noble-chiseled and alpine).
Guess First
The catalog service of Bookstore, the online bookstore used as the shared example across this site's series, sorts book titles, upper-cases the query from the search box and compares a city name. The same code runs under the tr-TR and en-US cultures:
string[] titles = ["Zübük", "Çalıkuşu", "Cevdet Bey ve Oğulları", "İnce Memed", "Ivanhoe"];
Array.Sort(titles);
Console.WriteLine(string.Join(", ", titles));
Console.WriteLine("istanbul".ToUpper());
Console.WriteLine("istanbul".Equals("İSTANBUL", StringComparison.OrdinalIgnoreCase));
dotnet run --project samples/Bookstore.Bcl.Samples -- culture-guess --culture tr-TR
Cevdet Bey ve Oğulları, Çalıkuşu, Ivanhoe, İnce Memed, Zübük
İSTANBUL
False
dotnet run --project samples/Bookstore.Bcl.Samples -- culture-guess --culture en-US
Çalıkuşu, Cevdet Bey ve Oğulları, İnce Memed, Ivanhoe, Zübük
ISTANBUL
False
The sort order differs between the two cultures; in Turkish Ç is a separate letter that comes after C, while in English it counts as an accented C and is placed by the rest of the word. ToUpper produces İ or I depending on the culture. The third line is False in both cultures: an ordinal comparison does not treat Turkish İ as the uppercase of i.
In the previous two posts we saw how format strings and string methods depend on culture. In this post we look at culture itself: the CultureInfo hierarchy, the difference between CurrentCulture and CurrentUICulture, how culture flows through async code, Turkish sorting and the ı/İ conversion, and how the same code behaves differently in containers with ICU and in invariant mode. The basics of the Turkish I trap were covered in the C# Fundamentals post "Strings in C#"; here we deal with its production consequences.
The CultureInfo Hierarchy: Invariant, Neutral, Specific
Every culture is a language plus an optional region. tr-TR is a specific culture (Turkish, Türkiye), tr is a neutral culture (language only), and the invariant culture with an empty name is the root of the hierarchy. The Parent chain walks these three levels:
CultureInfo culture = CultureInfo.GetCultureInfo("tr-TR");
while (true)
{
string name = culture.Name.Length == 0 ? "\"\" (invariant)" : culture.Name;
Console.WriteLine($"{name,-16} neutral={culture.IsNeutralCulture,-5} EnglishName={culture.EnglishName}, NativeName={culture.NativeName}");
if (culture.Equals(CultureInfo.InvariantCulture))
break;
culture = culture.Parent;
}
CultureInfo cached = CultureInfo.GetCultureInfo("tr-TR");
CultureInfo created = new("tr-TR");
Console.WriteLine($"GetCultureInfo: ReadOnly={cached.IsReadOnly}, same instance={ReferenceEquals(cached, CultureInfo.GetCultureInfo("tr-TR"))}");
Console.WriteLine($"new CultureInfo: ReadOnly={created.IsReadOnly}, UseUserOverride={created.UseUserOverride}");
// ICU accepts any well-formed name and falls back to root data instead of throwing.
foreach (string name in new[] { "tr-XX", "xx-YY" })
{
try
{
CultureInfo odd = CultureInfo.GetCultureInfo(name);
Console.WriteLine($"{name}: EnglishName={odd.EnglishName}, D={new DateTime(2026, 11, 14).ToString("D", odd)}");
}
catch (CultureNotFoundException ex)
{
Console.WriteLine($"{name}: {ex.GetType().Name}: {ex.Message.Split('\n')[0]} InvalidCultureName={ex.InvalidCultureName}");
}
}
tr-TR neutral=False EnglishName=Turkish (Türkiye), NativeName=Türkçe (Türkiye)
tr neutral=True EnglishName=Turkish, NativeName=Türkçe
"" (invariant) neutral=False EnglishName=Invariant Language (Invariant Country), NativeName=Invariant Language (Invariant Country)
GetCultureInfo: ReadOnly=True, same instance=True
new CultureInfo: ReadOnly=False, UseUserOverride=True
tr-XX: EnglishName=Turkish (XX), D=14 Kasım 2026 Cumartesi
xx-YY: EnglishName=xx (YY), D=Saturday, November 14, 2026
There are two ways to get a culture, and the difference matters. CultureInfo.GetCultureInfo returns a cached read-only instance; every call gives you the same object. new CultureInfo("tr-TR") creates a new, mutable instance each time, and its UseUserOverride is true: on Windows, if a user changed the decimal separator or the date format in Region settings, this instance carries those changes. On a server that means the same code produces different output depending on an administrator's settings. Use GetCultureInfo for formatting and comparison; if you need to change a NumberFormatInfo, take a Clone as in the previous post.
The last two lines are surprising: .NET running on ICU doesn't throw for the non-existent names tr-XX and xx-YY; it falls back to the language's data if the language is known, or to root data if not. A typo in a config file (tr_TR, tr-TK) slips through silently. With the PredefinedCulturesOnly setting turned on, the same program fails for these names:
DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=true dotnet run --project samples/Bookstore.Bcl.Samples -- culture-hierarchy
tr-XX: CultureNotFoundException: Culture is not supported. (Parameter 'name') InvalidCultureName=tr-xx
xx-YY: CultureNotFoundException: Culture is not supported. (Parameter 'name') InvalidCultureName=xx-yy
The same setting can be given in the project file as <PredefinedCulturesOnly>true</PredefinedCulturesOnly>. In an application that reads its list of supported cultures from config, this setting lets you catch the error at startup rather than on the first request.
CurrentCulture and CurrentUICulture
Every thread, or more precisely every async flow, has two cultures. CurrentCulture decides number, date and currency formatting and culture-sensitive string comparison. CurrentUICulture decides which language text is loaded from resource files. Setting them separately is a legitimate scenario: a user in Türkiye may want the UI in English but still expects the price in lira format.
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
decimal total = 1249.50m;
DateTime placed = new(2026, 11, 14);
Console.WriteLine($"CurrentCulture={CultureInfo.CurrentCulture.Name}, CurrentUICulture={CultureInfo.CurrentUICulture.Name}");
Console.WriteLine($"Formatted with CurrentCulture: {total:N2} {placed:D}");
Console.WriteLine($"UI text picked by CurrentUICulture: {OrderPlacedMessage()}");
static string OrderPlacedMessage() => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName switch
{
"tr" => "Siparişiniz alındı",
_ => "Your order has been placed",
};
CurrentCulture=tr-TR, CurrentUICulture=en-US
Formatted with CurrentCulture: 1.249,50 14 Kasım 2026 Cumartesi
UI text picked by CurrentUICulture: Your order has been placed
In a real application you pick text with .resx files and IStringLocalizer, not with a switch; RequestLocalizationMiddleware, which sets the user's culture per request in ASP.NET Core, and resource files are covered in the "Localization in MVC and Razor Pages" post of the ASP.NET Core MVC and Razor Pages series. What matters here is that the two settings are independent and that only CurrentCulture decides formatting.
Culture in Async Code
In .NET Core, CurrentCulture behaves like an AsyncLocal value: it flows with the ExecutionContext past an await, into Task.Run and into new threads. An example that prints the culture at different points to show what flows where:
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo("en-GB");
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
Print("Main, before await");
await Task.Delay(10);
Print("Main, after await");
await Task.Run(() => Print("Task.Run"));
await ChangeInsideAsync();
Print("Main, after ChangeInsideAsync");
var thread = new Thread(() => Print("new Thread"));
thread.Start();
thread.Join();
var done = new TaskCompletionSource();
ThreadPool.UnsafeQueueUserWorkItem(_ =>
{
Print("UnsafeQueueUserWorkItem");
done.SetResult();
}, null);
await done.Task;
static async Task ChangeInsideAsync()
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
await Task.Yield();
Print("Inside ChangeInsideAsync");
}
Main, before await thread 1 tr-TR 1.234,50
Main, after await thread 5 tr-TR 1.234,50
Task.Run thread 7 tr-TR 1.234,50
Inside ChangeInsideAsync thread 7 de-DE 1.234,50
Main, after ChangeInsideAsync thread 7 tr-TR 1.234,50
new Thread thread 9 tr-TR 1.234,50
UnsafeQueueUserWorkItem thread 7 en-GB 1,234.50
There are three things to read here. First, even if you continue on a different thread after an await, the culture is preserved; it's tied to the flow, not the thread. Second, changing the culture inside an async method doesn't leak back to the caller: ChangeInsideAsync set de-DE, but the main flow stayed on tr-TR. That's what lets middleware set the culture at the start of a request and have the actions see it, but it would be wrong to think a helper method can change the culture "permanently". Third, UnsafeQueueUserWorkItem, which doesn't flow the ExecutionContext (and so do the private thread pools of some libraries), falls back to DefaultThreadCurrentCulture. If that isn't set either, you get the operating system's culture. That's why passing the culture explicitly to format calls is more robust than relying on it in background work.
Turkish Sorting: StringComparer and CompareInfo
I sorted the book titles in the catalog with three different comparers:
List<string> ordinal = [.. Titles];
ordinal.Sort(StringComparer.Ordinal);
List<string> invariant = [.. Titles];
invariant.Sort(StringComparer.InvariantCulture);
List<string> turkish = [.. Titles];
turkish.Sort(StringComparer.Create(CultureInfo.GetCultureInfo("tr-TR"), ignoreCase: false));
Ordinal InvariantCulture tr-TR
Cevdet Bey ve Oğulları Çalıkuşu Cevdet Bey ve Oğulları
Dune Cevdet Bey ve Oğulları Çalıkuşu
Ivanhoe Dune Dune
Saatleri Ayarlama Enstitüsü emma emma
Ulysses İnce Memed Ivanhoe
Zübük Ivanhoe İnce Memed
emma Saatleri Ayarlama Enstitüsü Saatleri Ayarlama Enstitüsü
Çalıkuşu Şeker Portakalı Şeker Portakalı
Üç İstanbul Üç İstanbul Ulysses
İnce Memed Ulysses Üç İstanbul
Şeker Portakalı Zübük Zübük
Ordinal sorting looks at the characters' Unicode values: uppercase letters come before lowercase ones (emma after Z), and Turkish letters are pushed to the end of the alphabet. Right for machines (deterministic and fast), meaningless for people. InvariantCulture applies a language-neutral Unicode ordering; it treats Ç as an accented C and places it by the rest of the word, so "Çalıkuşu" comes before "Cevdet". tr-TR applies the rules of the Turkish alphabet: Ç after C, Ş after S and Ü after U are separate letters, and I and İ are different letters.
StringComparer.Create(culture, ignoreCase) builds a comparer for a specific culture; StringComparer.CurrentCulture captures the culture at the moment it is called. When sorting a list shown to users, passing the user's culture explicitly is better than depending on the server's culture. Sorting in the database is the collation's job; SQL Server's Turkish_100_CI_AS collation and the Turkish İ trap are covered in the "SQL Data Types and CREATE TABLE" post of the SQL Fundamentals series. Sorting with different rules in the application and in the database can make the same book show up on two pages when paging.
The Turkish I: Case Conversion and Matching
The Turkish alphabet has two separate letter pairs: i/İ and ı/I. Other languages have only i/I. Let's see the consequences side by side:
Console.WriteLine($"\"istanbul\".ToUpper(tr) = {"istanbul".ToUpper(tr)}");
Console.WriteLine($"\"istanbul\".ToUpper(en) = {"istanbul".ToUpper(en)}");
Console.WriteLine($"\"istanbul\".ToUpperInvariant() = {"istanbul".ToUpperInvariant()}");
Console.WriteLine($"\"ISBN\".ToLower(tr) = {"ISBN".ToLower(tr)}");
Console.WriteLine($"\"ISBN\".ToLowerInvariant() = {"ISBN".ToLowerInvariant()}");
string query = "istanbul";
string stored = "İSTANBUL";
Console.WriteLine($"OrdinalIgnoreCase : {string.Equals(query, stored, StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"InvariantCultureIgnoreCase : {string.Equals(query, stored, StringComparison.InvariantCultureIgnoreCase)}");
Console.WriteLine($"tr-TR IgnoreCase : {string.Compare(query, stored, tr, CompareOptions.IgnoreCase) == 0}");
Console.WriteLine($"en-US IgnoreCase : {string.Compare(query, stored, en, CompareOptions.IgnoreCase) == 0}");
Console.WriteLine($"tr-TR IgnoreCase \"ISTANBUL\" : {string.Compare(query, "ISTANBUL", tr, CompareOptions.IgnoreCase) == 0}");
"istanbul".ToUpper(tr) = İSTANBUL
"istanbul".ToUpper(en) = ISTANBUL
"istanbul".ToUpperInvariant() = ISTANBUL
"ISBN".ToLower(tr) = ısbn
"ISBN".ToLowerInvariant() = isbn
OrdinalIgnoreCase : False
InvariantCultureIgnoreCase : False
tr-TR IgnoreCase : True
en-US IgnoreCase : False
tr-TR IgnoreCase "ISTANBUL" : False
The rule: if you want to compare Turkish text typed by a user using Turkish rules, use the tr-TR culture with IgnoreCase; istanbul matches İSTANBUL, but ISTANBUL doesn't (in Turkish that word would be "ıstanbul"). For machine data ("ISBN", HTTP headers, enum names, file extensions) use ToLowerInvariant, ToUpperInvariant or OrdinalIgnoreCase; otherwise on a Turkish server "ISBN" becomes "ısbn" and matches no switch branch. One more detail: invariant upper-casing doesn't turn ı into I; "çalıkuşu".ToUpperInvariant() gives ÇALıKUŞU. Invariant conversion is for normalizing machine data, not for displaying Turkish text.
Bookstore: "istanbul" and "İSTANBUL" in the Search Box
In the catalog search a user may type "istanbul", "sehir" or "kurk"; someone without a Turkish keyboard, or someone typing fast, skips the accents. I tried the same queries three ways: the tr-TR and invariant CompareInfo with IgnoreCase | IgnoreNonSpace, and folding:
string[] catalog = ["İSTANBUL HATIRALAR VE ŞEHİR", "Kürk Mantolu Madonna", "İnce Memed", "Istanbul: Memories and the City"];
string[] queries = ["istanbul", "sehir", "kurk", "ince"];
CompareInfo turkish = CultureInfo.GetCultureInfo("tr-TR").CompareInfo;
CompareInfo invariant = CultureInfo.InvariantCulture.CompareInfo;
const CompareOptions options = CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace;
foreach (string query in queries)
{
Console.WriteLine($"{query,-9} tr-TR -> {Hits(title => turkish.IndexOf(title, query, options) >= 0)}");
Console.WriteLine($"{"",-9} Invariant -> {Hits(title => invariant.IndexOf(title, query, options) >= 0)}");
string foldedQuery = SearchText.Fold(query);
Console.WriteLine($"{"",-9} Fold -> {Hits(title => SearchText.Fold(title).Contains(foldedQuery, StringComparison.Ordinal))}");
}
istanbul tr-TR -> İSTANBUL HATIRALAR VE ŞEHİR
Invariant -> İSTANBUL HATIRALAR VE ŞEHİR | Istanbul: Memories and the City
Fold -> İSTANBUL HATIRALAR VE ŞEHİR | Istanbul: Memories and the City
sehir tr-TR ->
Invariant -> İSTANBUL HATIRALAR VE ŞEHİR
Fold -> İSTANBUL HATIRALAR VE ŞEHİR
kurk tr-TR ->
Invariant -> Kürk Mantolu Madonna
Fold -> Kürk Mantolu Madonna
ince tr-TR -> İnce Memed
Invariant -> İnce Memed
Fold -> İnce Memed
At first glance the result looks backwards: the Turkish rules don't match "sehir" with "ŞEHİR" or "kurk" with "Kürk". The reason is that ş and ü are separate letters in the Turkish alphabet; IgnoreNonSpace only ignores the mark on top of a letter, it doesn't make one letter equal to another. What we want for a search box is the opposite: a forgiving match. The most predictable way to get it is to fold the text with the same logic as the slug code in the first post:
public static string Fold(string text)
{
ArgumentNullException.ThrowIfNull(text);
string decomposed = text.Normalize(NormalizationForm.FormD);
var folded = new StringBuilder(decomposed.Length);
foreach (char c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
continue;
folded.Append(c == 'ı' ? 'i' : char.ToLowerInvariant(c));
}
return folded.ToString().Normalize(NormalizationForm.FormC);
}
Folding has two advantages over culture-sensitive comparison. You can compute the result once when the book is saved, store it in a separate database column and make the search ordinal and indexed. And since it doesn't depend on ICU, it gives the same result in invariant mode. In a real catalog you'd leave search to a search engine; Turkish analyzers and accent folding are covered in the Search with Elasticsearch series.
ICU, NLS and Invariant Globalization Mode
Before .NET 5, culture data and comparison were done by NLS on Windows and by ICU on Linux, and the two platforms gave different results in some cases. Since .NET 5, Windows also uses the ICU that ships with the operating system, starting with the Windows 10 May 2019 Update; if you need the old behavior you can switch back to NLS with the System.Globalization.UseNls setting. In Linux containers, ICU must be inside the image. When it isn't, .NET runs in invariant globalization mode: every culture behaves like the invariant culture and comparisons become ordinal.
I ran one diagnostic command of the same program in four images. It finds out whether ICU is loaded with the SortVersion check from Microsoft's documentation, then does a few operations with tr-TR:
dotnet publish samples/Bookstore.Bcl.Samples -c Release -o pub
docker run --rm -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Bcl.Samples.dll culture-mode
docker run --rm -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled Bookstore.Bcl.Samples.dll culture-mode
docker run --rm -e DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=false -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled Bookstore.Bcl.Samples.dll culture-mode
docker run --rm -v "${PWD}/pub:/app:ro" -w /app mcr.microsoft.com/dotnet/runtime:10.0-alpine dotnet Bookstore.Bcl.Samples.dll culture-mode
== aspnet:10.0
ICU loaded : True
CurrentCulture : ""
tr-TR date : 14 Kasım 2026 Cumartesi
tr-TR money : ₺1.234,50
ToUpper(tr) : İSTANBUL
Sort(tr) : Cevdet Bey ve Oğulları, Çalıkuşu, Ivanhoe, İnce Memed, Zübük
IndexOf("\0") : 0
ToUpperInvariant : ÇALıKUŞU
== aspnet:10.0-noble-chiseled
ICU loaded : False
CurrentCulture : ""
CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
IndexOf("\0") : 4
ToUpperInvariant : ÇALıKUŞU
== aspnet:10.0-noble-chiseled, PREDEFINED_CULTURES_ONLY=false
ICU loaded : False
CurrentCulture : ""
tr-TR date : Saturday, 14 November 2026
tr-TR money : ¤1,234.50
ToUpper(tr) : ISTANBUL
Sort(tr) : Cevdet Bey ve Oğulları, Ivanhoe, Zübük, Çalıkuşu, İnce Memed
IndexOf("\0") : 4
ToUpperInvariant : ÇALıKUŞU
== runtime:10.0-alpine
ICU loaded : False
CurrentCulture : ""
CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
IndexOf("\0") : 4
ToUpperInvariant : ÇALıKUŞU
The Ubuntu-based aspnet:10.0 image has ICU and everything works as in local development. The chiseled and alpine images, however, come with the DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true environment variable (you can see it with docker image inspect). In this mode PredefinedCulturesOnly is on by default, so GetCultureInfo("tr-TR") throws CultureNotFoundException right away. The application that worked locally crashes at the first Turkish price format once it's in a container. If you set the setting to false, the exception goes away but something worse happens: tr-TR silently behaves like invariant, the date comes out in English, the currency symbol as ¤ and the sort order as ordinal.
In all four images CurrentCulture is empty, that is, invariant. If the LANG environment variable isn't set in the container, .NET's default culture is the invariant culture; running with -e LANG=tr_TR.UTF-8 gives tr-TR. On the server side that's actually a good default: code that passes the culture explicitly to formatting works the same everywhere.
If you need ICU on alpine, you install its package and turn invariant mode off. The Dockerfile in the repo:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish samples/Bookstore.Bcl.Samples -c Release -o /app
FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine
# The alpine image has no ICU and runs in invariant mode by default.
RUN apk add --no-cache icu-data-full icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "Bookstore.Bcl.Samples.dll"]
docker build -f docker/samples-alpine-icu.Dockerfile -t bookstore-bcl-samples:alpine-icu .
docker run --rm bookstore-bcl-samples:alpine-icu culture-mode
ICU loaded : True
CurrentCulture : ""
tr-TR date : 14 Kasım 2026 Cumartesi
tr-TR money : ₺1.234,50
ToUpper(tr) : İSTANBUL
Sort(tr) : Cevdet Bey ve Oğulları, Çalıkuşu, Ivanhoe, İnce Memed, Zübük
IndexOf("\0") : 0
ToUpperInvariant : ÇALıKUŞU
That has a cost: in my measurement the alpine runtime image grew from 142 MB to 196 MB. Chiseled images also have -extra variants that include ICU and tzdata. Which one you choose depends on what the application does: an API that only takes and returns JSON and passes the invariant culture everywhere works fine in invariant mode; an application that shows Turkish dates, money or sorting to users needs ICU. Image choice, chiseled images and globalization are covered in detail in the "Containerizing ASP.NET Core" post of the Docker series.
In Production
Treat the globalization setting as part of the application, not the environment. Write the InvariantGlobalization and PredefinedCulturesOnly values explicitly in the project file so that behavior doesn't change silently when the image changes. Loading the cultures you support (tr-TR, en-US) once with GetCultureInfo at startup and tying that to a health check catches a deployment that fell into invariant mode within the first minute.
Let analyzers catch culture-dependent code. CA1304 and CA1311 flag ToUpper/ToLower calls without a culture, CA1305 flags formatting and parsing without a culture, and CA1309 and CA1310 flag non-ordinal string comparison. Turning these rules off because "they warn too much" means hiding the bugs that will show up on a Turkish server. Enabling rules gradually is covered in the ".NET Analyzers" post of the Static Analysis series.
Keep the test environment the same as production. When the developer machine is Turkish, the CI agent English and the container invariant, the same test can give three different results. In culture-sensitive tests set the culture explicitly inside the test, and run integration tests that run in containers with the same image as production.
Common Pitfalls
Using a culture-dependent comparer for dictionary keys. StringComparer.CurrentCultureIgnoreCase captures the culture at the moment it's created:
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
var templates = new Dictionary<string, string>(StringComparer.CurrentCultureIgnoreCase)
{
["INVOICE"] = "invoice.html",
["SHIPPING"] = "shipping.html",
};
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
Console.WriteLine($"comparer created under tr-TR, lookup \"invoice\": {templates.ContainsKey("invoice")}");
Console.WriteLine($"comparer created under tr-TR, lookup \"shipping\": {templates.ContainsKey("shipping")}");
var safe = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["INVOICE"] = "invoice.html",
};
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
Console.WriteLine($"OrdinalIgnoreCase under tr-TR, lookup \"invoice\": {safe.ContainsKey("invoice")}");
comparer created under tr-TR, lookup "invoice": False
comparer created under tr-TR, lookup "shipping": False
OrdinalIgnoreCase under tr-TR, lookup "invoice": True
Because the application started on a Turkish server, the template dictionary was built with Turkish rules; "INVOICE" and "invoice" became different keys. The same code works fine on an English server, so the bug shows up in only one environment. Choosing comparers is covered in detail in the "C# Dictionary and HashSet" post of the C# Collections and Generics series; the rule is simple: for machine keys use StringComparer.Ordinal or OrdinalIgnoreCase.
The tr-TR culture not being found in a container. Forgetting that invariant mode is on in small images means a CultureNotFoundException at the first Turkish format call. Run the diagnostic command when you change images.
Turning off CA1304, CA1305 and CA1309 wholesale in .editorconfig. Silencing the warnings by passing InvariantCulture everywhere is just as wrong; text shown to users needs the right culture.
Trying to match Turkish text with an ordinal case-insensitive comparison. OrdinalIgnoreCase doesn't match i with İ; user text needs either a culture-sensitive comparison or folding.
A Small Experiment
★☆☆ Add the titles "Ilgaz", "ılık" and "İlk Aşk" to the culture-sort example and look at where they land in the three columns. What changes when you sort with StringComparer.Create(tr, ignoreCase: true)?
★★☆ Write a unit test class for SearchText.Fold: "İSTANBUL", "ıstanbul", "Istanbul" and "istanbul" must give the same result. Run the tests once normally and once with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1.
★★★ Write a check for Bookstore.Api that runs at startup: load the tr-TR and en-US cultures and verify that 1234.5m.ToString("C2", tr) contains ₺; if the app is in invariant mode or a culture can't be loaded, stop the application with a clear log message. Try the check on the chiseled and -extra images.
That closes the text block. In the next post we move to numbers: the rounding modes of Math.Round and why Math.Round(2.5) returns 2, computing a campaign discount to the cent, Random.Shared, time-ordered Guid v7 for order numbers, and BigInteger.