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.
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.
.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.
string.Format("Order {0} for {1,-20} total {2,10:C2}", id, name, total);
Each placeholder is {index[,alignment][:formatString]}:
ToString(format, provider).{{ 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.
| 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.
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%
| 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").
IFormatProvider and CultureInfoIFormatProvider.GetFormat(Type) returns a NumberFormatInfo, DateTimeFormatInfo or a custom formatter.CultureInfo implements IFormatProvider and bundles both, plus CompareInfo and TextInfo.value.ToString("N2", CultureInfo.InvariantCulture), string.Format(provider, ...), string.Create(provider, $"...").CultureInfo.CurrentCulture is used.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.
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.
Checks that the candidate will not introduce culture-dependent bugs, which are common in Australian systems that also interact with US defaults.
Green flags
Red flags
MM and mm.When you call value.ToString("C2", provider) on a primitive:
NumberFormatInfo.GetInstance(provider) resolves the provider to a NumberFormatInfo (current culture's if null).Number.Formatting in the runtime, using exact algorithms such as Ryu/Grisu-like shortest round-trip for floating point since .NET Core 3.0).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.
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.
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").
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.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"
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.
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).
1234.5m.ToString("C") under en-AU, en-US, de-DE, ja-JP and invariant.DateTime.Now with "d", "o", "yyyy/MM/dd" under de-DE and notice the separator change.<InvariantGlobalization>true</InvariantGlobalization> and format en-AU currency.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
}
}
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.
Parse and no cultureWhat 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.
mm and MM, hh and HHWhat 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.
/ and : are culture-specific in custom formatsWhat 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").
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.
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.
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.
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.
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.
CurrentCulture and CurrentUICultureWhat 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.
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 |
TimeProvider and DateTimeOffset for times, and only format at the edge in the user's time zone (AEST/AEDT handling).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.
$"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.
Last updated 30 Oct 2026.