A real-world culture-sensitive comparison scenario: NLS versus ICU, invariant globalisation mode in slim container images, IndexOf and sort-order changes, time zone ids, and how to make string behaviour deterministic across platforms.
Two things changed. First, culture data: .NET Framework on Windows used NLS, while .NET on Linux uses ICU, so any culture-sensitive call (IndexOf(string), StartsWith(string), Compare, default sorting) can give different answers; a classic one is "\r\n".IndexOf("\n") returning -1. Second, the container: slim images may not include ICU, so the app either runs in invariant globalisation mode or fails when it creates en-AU, and an unset LANG makes CurrentCulture invariant. I'd fix the code by using ordinal comparison for anything that isn't user-facing and passing cultures explicitly. Then I'd fix the image by installing ICU, or deliberately choose invariant mode, and add tests that run on Linux under several cultures.
This is almost always a globalisation stack change, not a bug in our code: .NET Framework on Windows used the Windows NLS library for culture data and collation; .NET 5+ uses ICU everywhere, and Linux containers bring their own ICU version, their own default locale, and sometimes no ICU at all (globalisation-invariant mode). Code that relied on implicit culture-sensitive behaviour changes results. I would explain the three causes, how I diagnose each, and the fixes.
IndexOf changed\r\n as a single unit in linguistic search. So s.IndexOf("\n") (which is culture-sensitive by default!) can return -1 on .NET 5+ where it found a match on .NET Framework.IndexOf(string), StartsWith(string), EndsWith(string), string.Compare, CompareTo, OrderBy on strings, List<string>.Sort(), ToUpper/ToLower. Each of these now uses ICU collation.co-op vs coop), accents, digits vs letters, mixed scripts. Paging and "top N" results built on culture sorting change.en-AU). In a Linux container, the culture comes from LANG/LC_ALL environment variables, which are often unset โ invariant culture, or C.UTF-8/en_US.UTF-8 in many base images โ en-US. Dates switch from dd/MM/yyyy to MM/dd/yyyy, currency symbols change.Sept vs Sep in en-AU).InvariantCulture; the currency symbol becomes ยค.Typical causes:
runtime-deps images without the icu-libs/libicu package. .NET fails at start-up with "Couldn't find a valid ICU package installed on the system" unless DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 / <InvariantGlobalization>true</InvariantGlobalization> is set.PredefinedCulturesOnly: since .NET 8, creating new CultureInfo("en-AU") in invariant mode throws CultureNotFoundException ("Only the invariant culture is supported in globalization-invariant mode") instead of silently returning an invariant clone.mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled) intentionally omit ICU; there are -extra variants that include ICU and tzdata.TimeZoneInfo.FindSystemTimeZoneById("AUS Eastern Standard Time") (Windows ID) on Linux needs ICU for ID conversion, plus the tzdata package; slim images without tzdata throw TimeZoneNotFoundException.CultureInfo.CurrentCulture.Name, CurrentUICulture.Name, RuntimeInformation.OSDescription, whether invariant mode is on (AppContext.TryGetSwitch("System.Globalization.Invariant", ...)), and the ICU version (GlobalizationMode isn't public, but a culture-data probe or ldd/package list in the image works).docker run <image> env for LANG, LC_ALL, DOTNET_SYSTEM_GLOBALIZATION_INVARIANT; check whether libicu and tzdata are installed (apt list --installed, apk info).CultureInfo("en-AU"), InvariantCulture and en-US explicitly; run the test suite in the same container image in CI.StringComparison/IFormatProvider โ enable analysers CA1304, CA1305, CA1307, CA1309, CA1310, CA1311 and look at the warnings.<UseNls>true</UseNls> on Windows (or DOTNET_SYSTEM_GLOBALIZATION_USENLS) to confirm ICU is the cause โ a diagnostic switch, not a fix.Ordinal/OrdinalIgnoreCase for identifiers, parsing and protocol text; CultureInfo.InvariantCulture for machine formats; explicit user culture for display.en-AU currency/dates), use an image with ICU (-extra chiseled variant or install icu-libs) and set InvariantGlobalization=false. If it doesn't need cultures (pure API with invariant data), enable invariant mode intentionally for a smaller image and make sure no code creates specific cultures.RequestLocalizationOptions with en-AU default for user-facing apps, or CultureInfo.DefaultThreadCurrentCulture for workers โ never depend on LANG."Australia/Sydney"), which work on all platforms in .NET 6+, and include tzdata.[In my case] a pricing service migrated from .NET Framework on Windows Server to .NET on Linux containers. Product lists were ordered differently, a parser that split lines with IndexOf("\r\n") broke on some files, invoice PDFs showed US dates, and the Alpine-based image crashed on start-up with the ICU error. We made all identifier operations ordinal, added invariant formatting for files and en-AU request localisation for users, switched to the -extra image variant with ICU and tzdata, and added a start-up log line plus a health check that verified culture and time zone data. Analysers were turned on as errors so the issue can't creep back.
The interviewer is checking migration experience: can the candidate explain platform-dependent behaviour and fix it properly?
Green flags
Red flags
IndexOf behaves differently.| NLS (Windows National Language Support) | ICU (International Components for Unicode) | |
|---|---|---|
| Used by | .NET Framework; .NET Core 1โ3.1 on Windows | .NET 5+ on all platforms (Windows 10 May 2019+ ships ICU) |
| Data source | Windows locale data | Unicode CLDR |
| Updated with | Windows updates | ICU version in the OS image (or app-local ICU) |
| Collation | Windows sorting tables | UCA (Unicode Collation Algorithm) + CLDR tailorings |
Because ICU on Linux comes from the OS package, two containers running the same app can behave differently if their base images ship different ICU versions. .NET supports app-local ICU (Microsoft.ICU.ICU4C.Runtime package + System.Globalization.AppLocalIcu setting) to pin a specific version when determinism matters.
At start-up the runtime:
System.Globalization.Invariant (MSBuild InvariantGlobalization, runtimeconfig.json, or DOTNET_SYSTEM_GLOBALIZATION_INVARIANT). If true โ invariant mode, no ICU loaded.UseNls; if false, loads ICU (icu.dll on Windows 10+).libicuuc/libicui18n by probing versioned names (or DOTNET_SYSTEM_GLOBALIZATION_APPLOCALICU). If none found โ fatal error at start-up, unless invariant mode is enabled.The current culture on Unix is derived from LC_ALL, then LC_MESSAGES/LANG; values like C or POSIX map to the invariant culture.
IndexOf with \r\n and nullsLinguistic search operates on collation elements, not code units. In ICU:
\r\n is a single grapheme cluster; searching for \n alone inside it does not match at a linguistic boundary.\0 and various format characters (U+200B zero-width space, U+00AD soft hyphen) have zero weight โ they are ignorable, so "abc".IndexOf("\0") returns 0 (an empty match), and StartsWith("\0") returns true for any string.These are correct linguistic behaviours, but wrong for parsing. Ordinal comparison has none of these effects.
CultureInfo.CurrentCulture is per thread and flows with ExecutionContext across await (since .NET 4.6). In ASP.NET Core, request localisation sets it per request.CultureInfo.DefaultThreadCurrentCulture sets the default for new threads.CurrentCulture, so behaviour depends on the environment unless you are explicit.| Image | ICU | tzdata | Notes |
|---|---|---|---|
aspnet:10.0 (Debian/Ubuntu) |
Yes | Yes | Largest, fewest surprises |
aspnet:10.0-alpine |
Install icu-libs, icu-data-full |
Install tzdata |
Small; Alpine images default to invariant mode via env var |
aspnet:10.0-noble-chiseled |
No (invariant) | No | Smallest, distroless, non-root |
aspnet:10.0-noble-chiseled-extra |
Yes | Yes | Distroless with globalisation |
The Alpine images historically set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true in the image environment; adding ICU requires unsetting it too.
TimeZoneInfo reads /usr/share/zoneinfo on Linux. Windows IDs (AUS Eastern Standard Time) are converted to IANA (Australia/Sydney) using ICU data; without ICU or tzdata, lookups throw. Use IANA IDs in config and TimeZoneInfo.TryConvertIanaIdToWindowsId when you must interoperate.
[UseCulture("en-AU")]-style attributes (xUnit helpers) or set CultureInfo.CurrentCulture in tests to make expectations explicit.\r\n, zero-width characters, Turkish ฤฑ/ฤฐ, accented names, emoji.Console.WriteLine("a\r\nb".IndexOf("\n")); on .NET Framework and .NET 10.alpine without ICU, unset invariant mode, and read the start-up error.docker run --rm -e LANG= mcr.microsoft.com/dotnet/runtime:10.0 dotnet ... with a small program that prints CultureInfo.CurrentCulture.Name and DateTime.Now.ToString("d").<UseNls>true</UseNls> on Windows and compare sorting of a tricky list.using System.Globalization;
namespace Strings;
public static class CultureSafe
{
private static readonly CultureInfo EnAu = CultureInfo.GetCultureInfo("en-AU");
// Ordinal parsing: works with \n and \r\n on every platform
public static IEnumerable<string> Lines(string text)
{
using var reader = new StringReader(text);
while (reader.ReadLine() is { } line) yield return line;
}
public static int FirstNewline(string text) => text.IndexOf('\n'); // char overload is ordinal
// Machine export: ordinal sort, invariant numbers
public static IEnumerable<string> ExportRows(IEnumerable<(string Name, decimal Amount)> rows)
=> rows.OrderBy(r => r.Name, StringComparer.Ordinal)
.Select(r => string.Create(CultureInfo.InvariantCulture, $"{r.Name},{r.Amount}"));
// Human display: explicit culture
public static string DisplayAmount(decimal amount) => amount.ToString("C2", EnAu);
// Start-up check: fail fast if culture data is missing
public static void VerifyGlobalization()
{
Console.WriteLine($"Culture={CultureInfo.CurrentCulture.Name}, UI={CultureInfo.CurrentUICulture.Name}");
try
{
_ = CultureInfo.GetCultureInfo("en-AU");
_ = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");
}
catch (Exception ex) when (ex is CultureNotFoundException or TimeZoneNotFoundException)
{
throw new InvalidOperationException("ICU or tzdata missing from the container image", ex);
}
}
public static void Main()
{
VerifyGlobalization();
Console.WriteLine(FirstNewline("a\r\nb")); // 2
Console.WriteLine("a\r\nb".IndexOf("\n", StringComparison.Ordinal)); // 2
Console.WriteLine(DisplayAmount(1234.5m)); // $1,234.50
foreach (var row in ExportRows([("zoe", 1.5m), ("Adam", 2m)])) Console.WriteLine(row);
}
}
# Alpine image with ICU and time zone data for culture-aware services
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
RUN apk add --no-cache icu-libs tzdata
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
LANG=en_AU.UTF-8
WORKDIR /app
COPY ./publish .
ENTRYPOINT ["dotnet", "Billing.dll"]
What happens: teams set UseNls=true or downgrade to keep old results.
Why it is wrong: NLS is Windows-only and legacy; the real bug is relying on implicit culture behaviour.
Do instead: use NLS only to confirm the diagnosis, then make comparisons and formatting explicit.
What happens: the app assumes en-AU because developers' machines are Australian.
Why it is wrong: containers default to invariant or en-US depending on LANG and the base image.
Do instead: set culture explicitly in code or via request localisation; log it at start-up.
What happens: InvariantGlobalization=true added to make the container start.
Why it is wrong: all culture-specific formatting silently becomes invariant (ยค, US-style dates), and creating cultures may throw.
Do instead: decide based on requirements โ install ICU (or use an -extra image) if the app needs cultures.
What happens: IndexOf("\r\n"), StartsWith("#"), Split + ToUpper() in file or protocol parsers.
Why it is wrong: ICU linguistic rules treat control and ignorable characters differently, breaking parsers.
Do instead: use ordinal comparisons and span-based parsing.
What happens: tests pass on Windows developer machines and the build agent, but the Linux image behaves differently. Why it is wrong: globalisation differences only appear in the actual runtime environment. Do instead: run integration tests inside the same container image in CI.
What happens: "AUS Eastern Standard Time" hard-coded and deployed to Linux.
Why it is wrong: requires ICU-based conversion and tzdata; fails in slim images.
Do instead: use IANA IDs ("Australia/Sydney") and include tzdata.
What happens: tests assert "30/10/2026 8:38 PM".
Why it is wrong: CLDR updates change spaces and abbreviations (e.g. U+202F, pm vs PM), breaking tests after an OS upgrade.
Do instead: assert invariant/ISO formats for data; for UI formatting, set the culture explicitly and avoid brittle literal comparisons.
What happens: a base image tag moves to a new OS release with a newer ICU. Why it is wrong: sort orders and formats can change without any code change. Do instead: pin image digests, review release notes, and consider app-local ICU when deterministic collation is a requirement.
What happens: API pagination uses OrderBy(name) in memory with the current culture while the database sorts with its collation.
Why it is wrong: inconsistent ordering between environments and between pages.
Do instead: sort in the database with a known collation, or use ordinal comparison in memory.
What happens: incidents take days to diagnose because nobody knows which culture the container used. Why it is wrong: the root cause is environmental and invisible. Do instead: log culture, UI culture, OS, invariant mode and time zone at start-up and expose them in a diagnostics endpoint.
Seniors turn a migration surprise into deterministic behaviour by design.
| Decision | Recommendation |
|---|---|
| Comparison of machine data | ordinal everywhere, enforced by analysers |
| User-facing text | explicit culture per request or user preference |
| Container base image | include ICU and tzdata unless the service is proven culture-free |
| Invariant mode | only for services with no human-facing formatting; document it |
| Reproducibility of sorting | pin culture and, if needed, app-local ICU version |
Ordinal comparison compares the raw UTF-16 code units, so it's fast and identical on every machine. Culture-sensitive comparison applies a language's rules through ICU (accents, ignorable characters, ร versus ss, Turkish dotted I), so results can change with the culture or ICU version. The trap is the defaults: ==, Equals and Contains are ordinal, but StartsWith(string), IndexOf(string), Compare and CompareTo are culture-sensitive. I use Ordinal/OrdinalIgnoreCase for identifiers, keys, paths, headers and anything security-related, and CurrentCulture only for sorting or searching text a user sees. For collections I pass a StringComparer.
Formatting turns values into text using a format string and a culture. Composite format items look like {0,10:N2}: the index, an optional alignment, and a format such as C, N2, P, X or a custom "0.00"; dates use d, o or custom "yyyy-MM-dd". Every overload uses CultureInfo.CurrentCulture unless you pass an IFormatProvider, so 1234.5 can print as 1,234.50 in en-AU and 1.234,50 in de-DE. My rule is the user's culture for display and CultureInfo.InvariantCulture for anything machines read or write, with matching culture on parse. In hot paths, TryFormat writes into a span without allocating.
How to tell it (STAR)
**S:** After we moved our billing service [at company] to Linux containers, nightly totals stopped reconciling. **T:** I led the investigation and the fix. **A:** I logged culture settings at start-up, reproduced the parser in the container, found culture-sensitive IndexOf calls behaving differently under ICU, switched to ordinal parsing, set cultures explicitly and fixed the image. **R:** Totals reconciled again, we reprocessed [n] nights of data, and the CI tests now catch culture-sensitive code before release.
Last updated 30 Oct 2026.