AlgoMaster Logo

String Formatting

Last Updated: May 22, 2026

Medium Priority
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 is the appropriate choice 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.

Each string.Format call parses the template, allocates an intermediate StringBuilder, and produces a new string. For one-off messages it's fine. Inside a tight loop, prefer a reusable StringBuilder and AppendFormat, or build the parts directly.

Standard Numeric Format Strings

Standard format strings are one or two characters that map to a built-in formatting rule. They cover the everyday cases: 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:

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.

Standard numeric formatting is fast, but decimal and double paths differ. decimal uses base-10 internally and rounds predictably. double is binary floating-point; values like 0.1 aren't representable exactly, so 0.1 + 0.2 formatted as :F17 shows 0.30000000000000004. For money, always use decimal.

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

Custom format strings are parsed on every call. If you format the same pattern millions of times in a hot loop, consider building a NumberFormatInfo once and passing it in, or caching the result if the inputs repeat.

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.

Date formatting touches the calendar, the culture's day/month names, and (for R and u) time zone conversion. It's not free. If you format millions of timestamps, cache the formatter or build the string from parts.

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:

  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 standard 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 to learn from. Consider 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.

new CultureInfo("de-DE") isn't free. It loads culture data from the runtime. If you format with the same culture repeatedly, store it in a static field once and reuse it. CultureInfo.GetCultureInfo("de-DE") returns a cached, read-only instance and is the preferred way to look up a named 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.

The third part is easy to 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.

Implementing IFormattable on a struct is normally a few decimal/string operations. Avoid heavy work inside it; the formatter calls ToString once per placeholder, and your callers expect it to be cheap.

Choosing the Right Approach

A practical summary of when to use 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.