AlgoMaster Logo

String Formatting

Last Updated: May 17, 2026

8 min read

A formatted string is what the customer sees on a receipt, what a log file records for a server, and what a CSV export ships to another system. The same number, 1234.5, has to render as $1,234.50 in one place, 1.234,50 EUR in another, and 1234.5 in a third. C# bundles all of that into one composite-format engine shared by string.Format, Console.WriteLine, StringBuilder.AppendFormat, and (since C# 6) the interpolation syntax. This chapter is the reference for the format specifiers and culture rules that make that work.

Composite Formatting and string.Format

Composite formatting is the old name for the placeholder-and-format-string pattern that powers most of C#'s formatting APIs. The template is a string with numbered {index} placeholders. The arguments fill those slots in order.

{0} is the first argument, {1} is the second. After the index, an optional :format clause picks how the value is rendered. The C format specifier renders total as currency using the current culture (US English in the example).

Indexed placeholders are useful because the same argument can appear more than once, and the order in the template doesn't have to match the argument order:

{0} appears twice; both copies pull from the same argument. You can also mix indices with literal text (Thanks, , your cart has ) freely.

The same composite-format engine powers several APIs:

APIUse case
string.Format(template, args)Builds and returns a string
Console.WriteLine(template, args)Writes formatted output to the console
StringBuilder.AppendFormat(template, args)Appends formatted text to a builder
TextWriter.Write(template, args)Writes to any writer (file, network)

Output (time will vary):

So when should you prefer string.Format over interpolation? For almost all new code, interpolation ($"...") is shorter and easier to read. string.Format earns its place when the format template isn't known at compile time. Resource files (.resx), database-driven message templates, and localization systems all hand you a string at runtime with {0}, {1} already in place. You can't put $ in front of a string you read from disk and have it interpolate, so string.Format is the tool.

Standard Numeric Format Strings

Standard format strings are one or two characters that map to a built-in formatting rule. They cover the cases you reach for daily: currency, percentages, fixed-point decimals, hex.

The full table of standard numeric specifiers:

SpecifierNameExample inputOutput (en-US)
C or cCurrency1234.5m$1,234.50
D or dDecimal (integers only)42, :D6000042
E or eExponential (scientific)1234.51.234500E+003
F or fFixed-point1234.5, :F31234.500
G or gGeneral (compact)1234.51234.5
N or nNumber with separators1234.51,234.50
P or pPercent (multiplies by 100)0.0858.50 %
R or rRound-trip0.10.1 (parses back exactly)
X or xHexadecimal (integers only)255FF (or ff for x)

A few details worth pinning down:

Precision. Most specifiers accept an optional precision digit. F2 means two decimals (1234.50). D6 pads an integer to six digits with leading zeros (000042). N0 is "number, zero decimals" (1,235).

`C` uses culture. :C reads the currency symbol, thousands separator, and decimal separator from the current culture. We'll cover what happens with non-US cultures in the culture section.

`D` is integers only. Trying decimal.ToString("D2") throws FormatException. Use F or N for decimals/doubles.

`P` multiplies by 100. 0.085 formatted as :P becomes 8.50 %, not 0.09 %. If you already have the percent value, divide by 100 first or use F with a literal % in the template.

`R` for round-trip. When you save a double or float and need to parse it back exactly, use R. The default G format may lose precision on the boundary cases.

A worked example for a receipt line:

The ,10 before the colon is the alignment width. Positive numbers right-align, negative numbers (,-10) left-align. That's how you get the prices to line up in a column on a receipt.

Custom Numeric Format Strings

When the standard specifiers don't fit, custom format strings let you spell out the pattern character by character. The vocabulary is small:

TokenMeaning
0A digit. If the number doesn't have one here, pad with 0.
#A digit. If the number doesn't have one here, leave blank.
.Decimal point (uses culture's separator).
,Thousand separator (when between digit placeholders).
, (trailing)Scale the number down by 1,000 per trailing comma.
%Multiply by 100 and append a percent symbol.
;Section separator: positive;negative;zero.
\Escape the next character (treat as a literal).
'...' or "..."Literal text inside the pattern.

A demonstration of each:

The difference between 0 and # matters most at the edges. 0.50 keeps the leading and trailing zeros because the pattern 0.00 says "always show a digit here." #.## keeps only the digits the number actually has, so 0.5 becomes .5.

Section separators are the cleanest way to handle positive, negative, and zero in one template. The pattern "$#,##0.00;($#,##0.00);Free" reads as three sub-patterns separated by ;:

SectionWhen usedFor value 100mFor -42.75mFor 0m
Positive (first)Value > 0$100.00(not used)(not used)
Negative (second)Value < 0(not used)($42.75)(not used)
Zero (third)Value == 0(not used)(not used)Free

The negative pattern is applied to the absolute value, which is why the second sub-pattern doesn't need its own minus sign; the parentheses do the work. The accounting world prefers parentheses over a minus sign for negative numbers, and this is how you produce that style.

Custom patterns combine with composite formatting just like standard ones:

The ,12 aligns each value to a 12-character column, right-justified. That's a clean receipt-style table.

Date and Time Format Strings

Dates and times have their own set of specifiers, because the formatting choices are different: month name vs. number, 12-hour vs. 24-hour, time zone or no time zone, local or UTC.

Standard Date and Time Specifiers

Output (en-US culture):

The full reference table:

SpecifierNameExample output (en-US)
dShort date5/15/2026
DLong dateFriday, May 15, 2026
tShort time2:30 PM
TLong time2:30:45 PM
fFull date + short timeFriday, May 15, 2026 2:30 PM
FFull date + long timeFriday, May 15, 2026 2:30:45 PM
gGeneral short5/15/2026 2:30 PM
GGeneral long5/15/2026 2:30:45 PM
o or ORound-trip ISO 86012026-05-15T14:30:45.0000000
sSortable ISO 86012026-05-15T14:30:45
uUTC sortable2026-05-15 14:30:45Z
R or rRFC 1123Fri, 15 May 2026 14:30:45 GMT

Three of these are special because they don't change with culture: o, s, and R. They are machine-readable formats. Pick them whenever the output is going into a log, a database, JSON, or any place a program (not a human) will read it.

Custom Date and Time Tokens

When the standard formats don't fit, custom tokens give you precise control over every part of the output:

TokenMeaningExample
yyyy4-digit year2026
yy2-digit year26
MM2-digit month05
MMMAbbreviated month nameMay
MMMMFull month nameMay
dd2-digit day15
dddAbbreviated day nameFri
ddddFull day nameFriday
HH2-digit hour (24-hour)14
hh2-digit hour (12-hour)02
mm2-digit minute30
ss2-digit second45
ttAM/PM designatorPM
fffMilliseconds123

A practical pair: a customer-facing timestamp on a receipt, and the same instant formatted for the server log.

The single quotes around at mark it as literal text. Without them, the parser would try to interpret a and t as format tokens (t is the AM/PM designator). When you have any letter that overlaps with a format token, quote it.

For the log format, prefer the standard o or s specifier when you can. They're guaranteed to round-trip back through DateTime.Parse and never depend on culture. Custom tokens are for the cases where the output has to match an external format you don't control.

Culture and CultureInfo

Number and date formatting depend on culture. The same 1234.5 looks different depending on where the customer is:

Culture1234.5m.ToString("N2")Currency for 1234.5m
en-US (US English)1,234.50$1,234.50
de-DE (German)1.234,501.234,50 €
fr-FR (French)1 234,501 234,50 €
en-IN (Indian English)1,234.50₹1,234.50
ja-JP (Japanese)1,234.50¥1,235

. and , swap roles in many European cultures. France uses a space for the thousands separator. Japanese yen has no fractional unit, so the currency formatter rounds. Currency symbols change. Decimal separators change. Grouping changes. Day-month order changes. All of this is what CultureInfo represents.

CurrentCulture and InvariantCulture

C# tracks two important cultures per thread:

  • `CultureInfo.CurrentCulture` is the culture used for formatting and parsing by default. It defaults to the OS's regional settings.
  • `CultureInfo.InvariantCulture` is a culture-neutral baseline. It's based on English but isn't tied to any region. Use it for machine-readable output.

Output (assuming current culture is en-US):

The invariant culture's currency symbol is ¤, the generic currency placeholder. That's a hint that you should never format currency with the invariant culture for display; pick a real culture for the user, and use the invariant culture only for storage and transport.

Passing Culture to Formatters

Every formatting API has an overload that accepts a culture:

Three patterns worth memorizing:

  1. Display to user: Use CurrentCulture (the default for ToString and $"...").
  2. Write to disk, network, log, JSON, CSV: Use InvariantCulture. FormattableString.Invariant($"...") is the cleanest way.
  3. Display in a specific locale that isn't the current user's: Build a CultureInfo for that locale and pass it explicitly.

The Diagram

A useful way to think about formatting is as a small pipeline. The value, the format string, and the culture all flow in; the output string flows out.

Three inputs, one output. The format string controls the shape; the culture controls the symbols and separators. Get any one of them wrong and the output is wrong. Pick them deliberately for each output, not by default.

The Classic Bug

A bug worth seeing once so you can avoid it forever. Imagine an e-commerce server that takes a decimal price, formats it for a JSON payload using the current culture, sends it across the wire, and another server parses it back:

Server A wrote 1234,5 (German decimal). Server B reads it with US rules where , is a thousand separator, so 1234,5 parses as 12345. The fix is to always serialize with the invariant culture:

The same lesson applies to dates, numbers, and any other formatted value that crosses a process boundary. Display uses the user's culture; transport uses the invariant culture.

The IFormattable Interface

Standard format specifiers like C, N, and D aren't magic. They're implemented by each type that wants to support formatting. The contract is IFormattable:

int, double, decimal, DateTime, Guid, and most other built-in types implement it. That's how {value:C} knows what to do for each type. The composite formatter looks at each argument, sees if it implements IFormattable, and if so calls ToString(format, provider) with the part after the colon as the format argument.

Custom types can implement it too, so they slot into the same {value:format} syntax. A small example: a Money struct that supports two custom format specifiers, S (short, just the amount) and L (long, with currency code):

The struct does three things. It implements IFormattable.ToString(format, provider). It overrides object.ToString() to delegate with a sensible default. And it honors the provider, so callers can pass a culture and have it propagate through to the inner decimal.ToString calls.

That third part is the one people miss. If your IFormattable.ToString ignores the provider and always uses CultureInfo.CurrentCulture, then string.Format(de, "{0}", money) won't actually produce German output. Pass the provider down.

Choosing the Right Tool

A practical summary of when to reach for each formatting style:

NeedUseWhy
Build a string from variables at compile time$"..." interpolationCleanest syntax, modern C# style
Build with a non-current culture$"...".ToString(culture) or FormattableString.Invariant($"...")Interpolation defaults to CurrentCulture; this forces a different one
Template comes from a resource file or databasestring.Format(template, args)Template is a runtime string
Building a long string incrementallyStringBuilder.AppendFormatAvoids intermediate allocations
Writing formatted output directly to console/fileConsole.WriteLine(template, args) or writer.Write(...)Skip the intermediate string
Persist a number/date for another machine to readvalue.ToString(format, CultureInfo.InvariantCulture)Stable, culture-independent output
Display a number/date to the uservalue.ToString(format) (defaults to CurrentCulture)Matches the user's locale

The rule of thumb: interpolation for code, string.Format for templates, invariant for storage, current for display.

Summary

  • Composite formatting (string.Format, Console.WriteLine, StringBuilder.AppendFormat) and string interpolation share the same {index[,alignment][:format]} placeholder syntax.
  • Prefer interpolation ($"...") for new code. Use string.Format when the template is loaded at runtime (resource files, database templates).
  • Standard numeric specifiers cover the common cases: C currency, D decimal integers, F fixed-point, N number with separators, P percent, X hex, R round-trip.
  • Custom numeric patterns use 0 (required digit), # (optional digit), . (decimal point), , (thousand separator), % (percent), and ; (section separator for positive/negative/zero).
  • Standard date specifiers like o, s, R, and u are culture-invariant and ideal for logs and data transport. d, D, t, T, f, F, g, G are culture-dependent and good for display.
  • Culture controls separators, currency symbols, and date order. Use CurrentCulture for display, InvariantCulture for storage and transport. FormattableString.Invariant($"...") is the cleanest way to interpolate without the current culture.
  • IFormattable is the contract that lets custom types respond to {value:format} syntax. Implement it when your type has multiple useful representations, and always honor the provider so callers can control culture.
  • Cache CultureInfo instances (CultureInfo.GetCultureInfo("de-DE")) instead of constructing new ones in a hot loop. Construction loads culture data and isn't free.