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

๐Ÿ“š Interview question

How does string formatting work in .NET? Explain composite formatting, standard and custom format strings for numbers and dates, IFormatProvider and CultureInfo, and when to use the invariant culture.

String formatting in .NET 10: composite format items, standard and custom numeric and date format strings, IFormatProvider and CultureInfo (including en-AU), parsing with cultures, invariant globalisation mode, and span-based TryFormat.

Junior Theory Intermediate csharp culture dates formatting globalization strings

๐ŸŽฏ Key points

  • Composite format items are {index[,alignment][:format]}, used by string.Format, Console.WriteLine and interpolation
  • Standard numeric formats include C, D, E, F, G, N, P, R and X (and B for binary since .NET 8); custom ones use 0, #, comma and dot
  • Date formats: standard d, D, o, s, u; custom yyyy-MM-dd HH:mm; use the round-trip o format for machine data
  • Formatting and parsing use CultureInfo.CurrentCulture by default; pass an IFormatProvider to be explicit
  • Use CultureInfo.InvariantCulture for machine-readable data (files, logs, APIs, keys) and the user's culture for display
  • Parse with the same culture you formatted with; prefer TryParse with NumberStyles and a culture
  • en-AU uses dd/MM/yyyy dates and $ currency; month-day confusion with en-US is a classic bug
  • ISpanFormattable.TryFormat writes into a Span<char> without allocating; invariant globalisation mode in containers changes culture behaviour

โฑ 30-second 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.

๐Ÿ—ฃ Full answer (senior)

How I would answer this in an interview

.NET formatting turns values into text using three ingredients: a format string (what shape), a format provider (which culture's rules), and the type's own IFormattable / ISpanFormattable implementation. I explain composite formatting, standard vs custom format strings, how IFormatProvider/CultureInfo plug in, and the rule that decides between current and invariant culture: humans get their culture, machines get invariant.

1. Composite formatting

string.Format("Order {0} for {1,-20} total {2,10:C2}", id, name, total);

Each placeholder is {index[,alignment][:formatString]}:

  • index: argument position.
  • alignment: minimum width; positive right-aligns, negative left-aligns (useful for console tables and fixed-width files).
  • formatString: passed to the value's ToString(format, provider).
  • Literal braces are escaped by doubling: {{ and }}.

The same syntax works in Console.WriteLine, TextWriter.Write, StringBuilder.AppendFormat, interpolated strings ($"{total,10:C2}") and CompositeFormat.Parse (.NET 8), which pre-parses a format once for reuse.

2. Standard numeric format strings

Specifier Meaning 1234.567 in en-AU
C / C2 Currency $1,234.57
N / N2 Number with group separators 1,234.57
F / F2 Fixed-point 1234.57
P / P1 Percent (multiplies by 100) 0.125 โ†’ 12.5%
E Scientific 1.234567E+003
G General (shortest) 1234.567
D6 Integer, zero-padded 42 โ†’ 000042
X / x8 Hexadecimal 255 โ†’ FF / 000000ff
R / default ToString() Round-trippable (for double, default since .NET Core 3.0) 1234.567

Rounding: "F2" rounds away from zero at the midpoint for decimal and correctly for double since .NET Core 3.0 โ€” but don't use formatting as your business rounding policy; round with Math.Round(x, 2, MidpointRounding.AwayFromZero) first.

3. Custom numeric format strings

0 (digit or zero), # (digit if present), . (decimal separator), , (group separator or scaling), %, โ€ฐ, ; (sections for positive;negative;zero), and quoted literals.

1234.5m.ToString("#,##0.00")              // 1,234.50
(-50m).ToString("$#,##0.00;($#,##0.00)")  // ($50.00) accounting style
0.5.ToString("0%")                         // 50%

4. Date and time format strings

Standard Meaning en-AU example
d Short date 30/10/2026
D Long date Friday, 30 October 2026
t / T Short / long time 8:38 pm
g / G General short / long 30/10/2026 8:38 pm
o / O Round-trip ISO 8601 (culture-independent) 2026-10-30T20:38:00.0000000+11:00
s Sortable 2026-10-30T20:38:00
u Universal sortable 2026-10-30 09:38:00Z
R RFC 1123 (HTTP headers) Fri, 30 Oct 2026 09:38:00 GMT

Custom: yyyy-MM-dd, HH:mm:ss (24-hour), hh (12-hour), tt (AM/PM), MMM, dddd, fff, zzz, K. Traps: mm is minutes and MM is months; / and : are culture-specific separators in custom formats unless escaped ("yyyy'/'MM'/'dd").

5. IFormatProvider and CultureInfo

  • IFormatProvider.GetFormat(Type) returns a NumberFormatInfo, DateTimeFormatInfo or a custom formatter.
  • CultureInfo implements IFormatProvider and bundles both, plus CompareInfo and TextInfo.
  • Every formatting API has an overload taking a provider: value.ToString("N2", CultureInfo.InvariantCulture), string.Format(provider, ...), string.Create(provider, $"...").
  • Without a provider, CultureInfo.CurrentCulture is used.

6. When to use the invariant culture

Invariant (CultureInfo.InvariantCulture) for anything a machine will read or that must be stable: CSV/fixed-width files, JSON or XML written by hand, configuration, cache keys, file names, log properties, URLs and query strings, database-bound strings, protocol messages, and parsing of such data. For dates in data, prefer ISO 8601 ("O") and UTC or DateTimeOffset.

Current culture (or an explicit user culture) for anything a human reads in a UI, email or PDF: currency, dates, numbers.

Analysers CA1304/CA1305 flag culture-sensitive calls without a provider, and CA1310/CA1309 cover comparisons.

7. A production example

An invoice import ran fine on developers' machines and failed in a newly provisioned Azure container: decimal.Parse("1234.50") threw FormatException because the container's culture had been set to a European locale by a base image change, and DateTime.Parse("03/04/2026") read some dates as 4 March instead of 3 April. The fix was parsing with CultureInfo.InvariantCulture and an exact format (DateTime.ParseExact(s, "dd/MM/yyyy", CultureInfo.InvariantCulture) for the supplier's documented format), setting en-AU explicitly for user-facing formatting via request localisation, and turning on CA1305 as a warning-as-error.

๐Ÿ” What the interviewer is checking

Checks that the candidate will not introduce culture-dependent bugs, which are common in Australian systems that also interact with US defaults.

Green flags

  • Knows composite format syntax and common standard formats.
  • States clearly when to use invariant versus current culture.
  • Mentions ISO 8601 and the day/month ambiguity.
  • Knows about ICU and invariant globalisation mode in containers.

Red flags

  • Parses dates without a culture or format.
  • Stores formatted values in the database.
  • Confuses MM and mm.

๐Ÿ“š Learn in depth

1. The formatting pipeline

When you call value.ToString("C2", provider) on a primitive:

  1. NumberFormatInfo.GetInstance(provider) resolves the provider to a NumberFormatInfo (current culture's if null).
  2. The number is converted to its decimal digits (Number.Formatting in the runtime, using exact algorithms such as Ryu/Grisu-like shortest round-trip for floating point since .NET Core 3.0).
  3. The format specifier is parsed: one letter plus optional precision = standard format; anything else = custom format.
  4. Digits are laid out using the culture's symbols: CurrencySymbol, CurrencyDecimalSeparator, CurrencyGroupSizes, CurrencyNegativePattern, and so on.

ISpanFormattable.TryFormat(Span<char>, out int, ReadOnlySpan<char> format, IFormatProvider?) runs the same pipeline but writes into a caller buffer โ€” used by interpolated string handlers, StringBuilder and UTF-8 formatting (IUtf8SpanFormattable in .NET 8) without allocating.

2. Where culture data comes from

On Windows, .NET 5+ uses ICU by default (previously NLS); on Linux and macOS, it always uses ICU (the libicu package). Culture data therefore can change between OS/ICU versions โ€” for example, a culture's short date pattern, the narrow no-break space (U+202F) before AM/PM in newer CLDR data, or currency symbols. Code and tests that compare formatted output to hard-coded strings can break after an OS or base-image upgrade.

In globalization-invariant mode (InvariantGlobalization=true, common for small containers), every culture behaves like the invariant culture: CultureInfo("en-AU") still exists but formats like invariant (ยค currency symbol). Since .NET 8 you can have PredefinedCulturesOnly=true so creating non-invariant cultures throws instead of silently degrading.

3. The invariant culture

CultureInfo.InvariantCulture is associated with English but not with any country, and its data never changes between .NET versions or machines โ€” that stability is the entire point. Its short date pattern is MM/dd/yyyy, which is why "invariant" date strings look American; for data, prefer ISO 8601 ("O", "yyyy-MM-dd").

4. Parsing mirrors formatting

  • decimal.Parse(s, NumberStyles.Number, CultureInfo.InvariantCulture) โ€” specify both the styles allowed and the culture.
  • DateTime.ParseExact(s, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None) โ€” never use DateTime.Parse on ambiguous external input.
  • TryParse overloads avoid exceptions on bad input.
  • DateTimeStyles.AssumeUniversal | AdjustToUniversal control time zone interpretation; DateTimeOffset.Parse with "O" keeps the offset.
  • DateOnly and TimeOnly (.NET 6) avoid time-zone confusion for date-only data such as birthdays and due dates.

5. Custom formatters

You can implement IFormatProvider + ICustomFormatter to change how arguments format in string.Format, or implement IFormattable/ISpanFormattable on your own types:

public readonly record struct Abn(string Value) : ISpanFormattable
{
    public string ToString(string? format, IFormatProvider? provider) =>
        format == "S" ? $"{Value[..2]} {Value[2..5]} {Value[5..8]} {Value[8..]}" : Value;

    public bool TryFormat(Span<char> dest, out int written, ReadOnlySpan<char> format, IFormatProvider? provider)
        => dest.TryWrite(provider, $"{ToString(format.ToString(), provider)}", out written);
}

// $"{abn:S}" -> "51 824 753 556"

6. CompositeFormat (.NET 8)

Parsing a composite format string has a cost. For formats used repeatedly (e.g. localised resource strings), parse once:

private static readonly CompositeFormat OrderFmt = CompositeFormat.Parse(Resources.OrderSummary);
string s = string.Format(culture, OrderFmt, id, total);

Analyser CA1863 suggests this for hot paths.

7. Time zones are a separate problem

Formatting does not convert time zones. DateTime.Now.ToString("g") on an Azure App Service in UTC shows UTC time to an Australian user. Convert first with TimeZoneInfo.ConvertTimeBySystemTimeZoneId(utc, "Australia/Sydney") (IANA IDs work on all platforms in .NET 6+), then format with the user's culture, and remember daylight saving changes (AEDT/AEST).

Try it yourself

  1. Print 1234.5m.ToString("C") under en-AU, en-US, de-DE, ja-JP and invariant.
  2. Format DateTime.Now with "d", "o", "yyyy/MM/dd" under de-DE and notice the separator change.
  3. Run a small app with <InvariantGlobalization>true</InvariantGlobalization> and format en-AU currency.
  4. Enable CA1305 as a warning and fix the reported calls.

๐Ÿ’ป Code

using System.Globalization;

namespace Strings;

public static class Formatting
{
    private static readonly CultureInfo EnAu = CultureInfo.GetCultureInfo("en-AU");
    private static readonly CultureInfo DeDe = CultureInfo.GetCultureInfo("de-DE");

    public static void Main()
    {
        decimal price = 1234.567m;

        // Display formatting in an explicit culture
        Console.WriteLine(price.ToString("C2", EnAu));     // $1,234.57
        Console.WriteLine(price.ToString("N2", DeDe));     // 1.234,57
        Console.WriteLine(string.Format(EnAu, "{0,-8}|{1,12:C2}|{2:P1}", "Widget", price, 0.125));

        // Machine-readable: invariant culture and ISO 8601
        var when = new DateTimeOffset(2026, 10, 23, 14, 5, 0, TimeSpan.FromHours(11));
        string iso = when.ToString("o", CultureInfo.InvariantCulture);
        Console.WriteLine(iso);                            // 2026-10-23T14:05:00.0000000+11:00
        Console.WriteLine(price.ToString(CultureInfo.InvariantCulture)); // 1234.567

        // Parsing with an explicit culture and format
        bool ok = DateOnly.TryParseExact("23/10/2026", "dd/MM/yyyy", CultureInfo.InvariantCulture,
                                         DateTimeStyles.None, out var date);
        Console.WriteLine($"{ok} {date:yyyy-MM-dd}");

        if (decimal.TryParse("1,5", NumberStyles.Number, DeDe, out var german))
            Console.WriteLine(german);                     // 1.5

        // Custom numeric format with sections
        Console.WriteLine((-5).ToString("+0;-0;zero", CultureInfo.InvariantCulture)); // -5

        // Allocation-free formatting into a span
        Span<char> buffer = stackalloc char[16];
        if (price.TryFormat(buffer, out int written, "F2", CultureInfo.InvariantCulture))
            Console.WriteLine(buffer[..written].ToString()); // 1234.57
    }
}

โš  Common mistakes

1. Formatting machine-readable data with the current culture

What happens: CSV exports, cache keys, file names or query strings use ToString() or interpolation without a provider. Why it is wrong: decimal separators and date formats differ between servers and requests, breaking consumers and cache hits. Do instead: use CultureInfo.InvariantCulture and ISO 8601 for dates.

2. Parsing external input with Parse and no culture

What happens: decimal.Parse(text) or DateTime.Parse(text) on files, APIs or user input. Why it is wrong: results depend on the server's culture; 03/04/2026 is ambiguous; exceptions on invalid input. Do instead: TryParse/TryParseExact with an explicit culture, NumberStyles and format.

3. Confusing mm and MM, hh and HH

What happens: "yyyy-mm-dd" or "hh:mm" for 24-hour logs. Why it is wrong: mm is minutes and hh is 12-hour time; dates and times come out subtly wrong. Do instead: use MM for months and HH for 24-hour; prefer standard "O"/"s" for data.

4. Forgetting that / and : are culture-specific in custom formats

What happens: date.ToString("dd/MM/yyyy") under a culture whose date separator is . or -. Why it is wrong: / is replaced with the culture's date separator, producing 30.10.2026. Do instead: pass CultureInfo.InvariantCulture or escape separators ("dd'/'MM'/'yyyy").

5. Using formatting as the rounding policy

What happens: totals are displayed with "F2" but stored and summed unrounded, or vice versa. Why it is wrong: displayed totals don't add up and differ from accounting systems. Do instead: round explicitly with Math.Round and a MidpointRounding rule at defined points, then format.

6. Hard-coding currency symbols

What happens: "$" + amount.ToString("0.00"). Why it is wrong: ignores negative patterns and grouping, and is wrong for other currencies (and $ is ambiguous between AUD, USD and NZD). Do instead: use "C" with the right culture, or format with an explicit currency code (AUD 1,234.50) in multi-currency systems.

7. Assuming culture data never changes

What happens: unit tests compare formatted dates/currency to hard-coded strings with the current culture. Why it is wrong: ICU/CLDR updates change separators and spaces (e.g. U+202F before AM/PM), breaking tests and parsers after upgrades. Do instead: test machine formats with invariant culture, and set a specific culture in UI-format tests with tolerant assertions.

8. Formatting server time without time-zone conversion

What happens: DateTime.Now on an Azure server (UTC) displayed to Australian users. Why it is wrong: times are off by 10โ€“11 hours depending on daylight saving. Do instead: store UTC/DateTimeOffset, convert to the user's time zone (Australia/Sydney), then format.

9. Running in invariant globalization mode unknowingly

What happens: a slim container image sets DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 and user-facing currency shows ยค. Why it is wrong: all cultures silently behave like invariant. Do instead: include ICU in the image when user-facing culture formatting is needed, and set PredefinedCulturesOnly to fail loudly.

10. Mixing up CurrentCulture and CurrentUICulture

What happens: localisation sets UI culture only and expects numbers to change format. Why it is wrong: formatting uses CurrentCulture; UI culture selects resources. Do instead: configure both through RequestLocalizationOptions.

๐Ÿ› Senior insight: trade-offs

Seniors separate presentation from data: culture belongs at the UI edge, invariant formats everywhere else.

Layer Culture rule
UI (Razor, Blazor, emails) user's culture via request localisation
API contracts ISO 8601 dates, invariant numbers, typed JSON
Storage typed columns, no formatted strings
Logs and metrics structured values, invariant
Files for other systems documented invariant format
  • Australian teams often discover the en-US/en-AU day-month problem when moving to containers or cloud regions; mention setting culture explicitly and testing with a different culture in CI.
  • Use TimeProvider and DateTimeOffset for times, and only format at the edge in the user's time zone (AEST/AEDT handling).
  • Analysers CA1304, CA1305 and CA1310 catch most culture mistakes; turn them on in libraries.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: The candidate mentions CurrentCulture or ICU. Why: Tests real-world experience with culture data differences across platforms and containers.
    How to 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.

  2. When: The candidate discusses invariant culture for machine data. Why: Interpolation uses the same formatting rules and the current culture.
    How to answer

    $"Hello {name}" is compiled, not parsed at run time. With a string target, C# 10+ uses DefaultInterpolatedStringHandler, which formats each hole straight into a pooled buffer without boxing, then allocates the final string once. Holes take alignment and format ({total,10:C}), and formatting uses the current culture, so for machine-readable text I use string.Create(CultureInfo.InvariantCulture, $"..."). Assigned to FormattableString, it keeps the format and arguments separate, which is how EF Core's FromSql($"...") turns holes into SQL parameters. Raw $$""" strings help with JSON. Two traps: interpolating into raw SQL causes SQL injection, and interpolating into log templates destroys structured logging.

โ†ฉ Usually asked after

More in Strings

Last updated 30 Oct 2026.

๐ŸŒ Sangam's Webring

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