📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

After moving a .NET service from Windows (.NET Framework) to Linux containers on .NET 10, string sorting, IndexOf results and some culture formatting changed, and one image failed to start with a culture error. Why, and how do you diagnose and fix culture-sensitive behaviour?

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.

Senior RealWorldScenario Advanced containers csharp culture globalization icu strings

๐ŸŽฏ Key points

  • Windows .NET Framework used NLS for culture data; .NET 5+ uses ICU on Linux and on modern Windows, so linguistic results can differ
  • Culture-sensitive APIs (IndexOf(string), StartsWith(string), Compare, Sort, ToUpper) are where behaviour changes; ordinal APIs do not change
  • Example: "\r\n".IndexOf("\n") returns -1 under ICU because \r\n is treated as one unit
  • Slim images (Alpine, chiseled) may lack ICU; InvariantGlobalization mode then gives only invariant culture data, and creating other cultures throws by default since .NET 8
  • Fix logic by making machine comparisons ordinal and culture explicit; fix images by installing ICU or deliberately enabling invariant mode
  • Containers often default to no culture settings (LANG unset), so CurrentCulture becomes invariant unless set
  • Time zones: use IANA ids or TimeZoneInfo.TryFindSystemTimeZoneById, which converts Windows ids when ICU is available
  • Prevent with analysers (CA1307/CA1309/CA1310), tests that run under several cultures and on Linux in CI

โฑ 30-second answer

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.

๐Ÿ—ฃ Full answer (senior)

How I would answer this in an interview

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.

1. Why sorting and IndexOf changed

  • NLS โ†’ ICU: different collation tables and rules. ICU treats some characters as ignorable (zero-width characters, some control characters, and embedded nulls) and treats \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.
  • Default APIs that are culture-sensitive: IndexOf(string), StartsWith(string), EndsWith(string), string.Compare, CompareTo, OrderBy on strings, List<string>.Sort(), ToUpper/ToLower. Each of these now uses ICU collation.
  • Linguistic sort order differs in edge cases: punctuation, hyphens (co-op vs coop), accents, digits vs letters, mixed scripts. Paging and "top N" results built on culture sorting change.

2. Why formatting changed

  • Default culture: on Windows the process culture came from the user/OS region (e.g. 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.
  • CLDR data differences: ICU uses newer Unicode CLDR data than Windows NLS: e.g. some cultures' short time formats, the narrow no-break space (U+202F) before AM/PM in newer data, currency patterns, month abbreviations (Sept vs Sep in en-AU).
  • Invariant globalisation mode: in invariant mode every culture behaves like InvariantCulture; the currency symbol becomes ยค.

3. Why one image failed with a culture error

Typical causes:

  • ICU missing: Alpine or 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.
  • Invariant mode + 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.
  • Chiseled / distroless images (e.g. mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled) intentionally omit ICU; there are -extra variants that include ICU and tzdata.
  • Time zone data: 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.

4. How I diagnose

  1. Log the environment at start-up: 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).
  2. Diff container images: compare base image tags and docker run <image> env for LANG, LC_ALL, DOTNET_SYSTEM_GLOBALIZATION_INVARIANT; check whether libicu and tzdata are installed (apt list --installed, apk info).
  3. Reproduce with tests: run the failing code under CultureInfo("en-AU"), InvariantCulture and en-US explicitly; run the test suite in the same container image in CI.
  4. Search the code for culture-sensitive calls without a StringComparison/IFormatProvider โ€” enable analysers CA1304, CA1305, CA1307, CA1309, CA1310, CA1311 and look at the warnings.
  5. Temporarily set <UseNls>true</UseNls> on Windows (or DOTNET_SYSTEM_GLOBALIZATION_USENLS) to confirm ICU is the cause โ€” a diagnostic switch, not a fix.

5. How I fix it

  • Make intent explicit in code: Ordinal/OrdinalIgnoreCase for identifiers, parsing and protocol text; CultureInfo.InvariantCulture for machine formats; explicit user culture for display.
  • Decide the image's globalisation mode deliberately: if the app formats for users (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.
  • Set the culture explicitly: RequestLocalizationOptions with en-AU default for user-facing apps, or CultureInfo.DefaultThreadCurrentCulture for workers โ€” never depend on LANG.
  • Use IANA time zone IDs ("Australia/Sydney"), which work on all platforms in .NET 6+, and include tzdata.
  • Pin and test the base image: run integration tests inside the production image in CI.

6. A production example (how I'd tell it)

[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.

๐Ÿ” What the interviewer is checking

The interviewer is checking migration experience: can the candidate explain platform-dependent behaviour and fix it properly?

Green flags

  • Names NLS versus ICU and culture-sensitive defaults.
  • Knows invariant globalisation mode and container locale issues.
  • Fixes code with ordinal comparisons and explicit cultures, not by reverting platforms.
  • Mentions time zones and testing on Linux in CI.

Red flags

  • "Linux is just different, we added a workaround."
  • Cannot explain why IndexOf behaves differently.
  • Enables invariant mode without understanding the consequences.

๐Ÿ“š Learn in depth

1. NLS vs ICU

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.

2. How the runtime decides its globalisation mode

At start-up the runtime:

  1. Checks System.Globalization.Invariant (MSBuild InvariantGlobalization, runtimeconfig.json, or DOTNET_SYSTEM_GLOBALIZATION_INVARIANT). If true โ†’ invariant mode, no ICU loaded.
  2. Otherwise, on Windows checks UseNls; if false, loads ICU (icu.dll on Windows 10+).
  3. On Linux, tries to load 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.

3. Why ICU changes IndexOf with \r\n and nulls

Linguistic 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.

4. The defaults that make code culture-dependent

  • 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.
  • String APIs without a comparison/provider silently use CurrentCulture, so behaviour depends on the environment unless you are explicit.

5. Container image choices

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.

6. Time zones on Linux

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.

7. Testing for globalisation regressions

  • Run tests in the production container image in CI (multi-stage Dockerfile with a test stage).
  • Use [UseCulture("en-AU")]-style attributes (xUnit helpers) or set CultureInfo.CurrentCulture in tests to make expectations explicit.
  • Add tests with tricky data: \r\n, zero-width characters, Turkish ฤฑ/ฤฐ, accented names, emoji.
  • Add a start-up self-check endpoint or log line that reports culture, ICU availability and a sample formatted date and currency.

Try it yourself

  1. Run Console.WriteLine("a\r\nb".IndexOf("\n")); on .NET Framework and .NET 10.
  2. Build an app on alpine without ICU, unset invariant mode, and read the start-up error.
  3. Run 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").
  4. Enable <UseNls>true</UseNls> on Windows and compare sorting of a tricky list.

๐Ÿ’ป Code

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"]

โš  Common mistakes

1. Treating it as a .NET bug and pinning old behaviour forever

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.

2. Relying on the container's default culture

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.

3. Enabling invariant mode to "fix" the ICU start-up error without checking behaviour

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.

4. Using culture-sensitive string APIs for parsing

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.

5. Not running tests in the production image

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.

6. Using Windows time zone IDs in configuration

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.

7. Hard-coding expected formatted strings in tests

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.

8. Upgrading base images without reviewing globalisation changes

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.

9. Sorting data for paging with culture rules

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.

10. Not logging culture and globalisation mode

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.

๐Ÿ› Senior insight: trade-offs

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
  • Add globalisation to the migration checklist for .NET Framework to .NET moves alongside configuration, file paths (case sensitivity) and line endings.
  • A one-line start-up log of culture and globalisation mode saves hours of guessing in incidents.
  • These issues are quiet data-quality bugs (skipped lines, wrong order), so golden-file comparisons between old and new systems are the best detection tool.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: The candidate proposes switching to ordinal comparisons. Why: Checks the theory behind the fix: ordinal versus linguistic comparison.
    How to answer

    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.

  2. When: The candidate mentions the currency symbol issue. Why: Probes how formatting should be split between user culture and invariant culture.
    How to answer

    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.

๐Ÿ›  Real-world problems: how you handle them

Invoice parser skipped lines after moving to Linux containers
Situation
A billing service processing about 1 million invoice lines a night was migrated from .NET Framework on Windows Server to .NET on AKS Linux containers.
Signals
Nightly totals were lower than the ERP's; reconciliation showed specific invoice files with CRLF line endings losing lines; emails displayed ¤ instead of $.
Investigation
Logged culture and globalisation mode at start-up (invariant culture, ICU loaded), reproduced the parser in the same container image, enabled CA1307/CA1310 and found IndexOf("\n") and several StartsWith(string) calls.
Resolution
Rewrote parsing with StringReader.ReadLine and ordinal comparisons, set en-AU explicitly for email formatting, switched the Alpine image to include icu-libs and tzdata, and reprocessed the affected nights.
Prevention
Analyser rules as errors, CI tests on Linux under en-AU, tr-TR and invariant cultures, a start-up globalisation check, and golden-file comparisons in migration runbooks.

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.

โ†ฉ Usually asked after

More in Strings

Last updated 30 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me