Last Updated: May 17, 2026
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.
string.FormatComposite 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:
| API | Use 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.
Cost: 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 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:
| Specifier | Name | Example input | Output (en-US) |
|---|---|---|---|
C or c | Currency | 1234.5m | $1,234.50 |
D or d | Decimal (integers only) | 42, :D6 | 000042 |
E or e | Exponential (scientific) | 1234.5 | 1.234500E+003 |
F or f | Fixed-point | 1234.5, :F3 | 1234.500 |
G or g | General (compact) | 1234.5 | 1234.5 |
N or n | Number with separators | 1234.5 | 1,234.50 |
P or p | Percent (multiplies by 100) | 0.085 | 8.50 % |
R or r | Round-trip | 0.1 | 0.1 (parses back exactly) |
X or x | Hexadecimal (integers only) | 255 | FF (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.
Cost: 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.
When the standard specifiers don't fit, custom format strings let you spell out the pattern character by character. The vocabulary is small:
| Token | Meaning |
|---|---|
0 | A 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 ;:
| Section | When used | For value 100m | For -42.75m | For 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.
Cost: 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.
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.
Output (en-US culture):
The full reference table:
| Specifier | Name | Example output (en-US) |
|---|---|---|
d | Short date | 5/15/2026 |
D | Long date | Friday, May 15, 2026 |
t | Short time | 2:30 PM |
T | Long time | 2:30:45 PM |
f | Full date + short time | Friday, May 15, 2026 2:30 PM |
F | Full date + long time | Friday, May 15, 2026 2:30:45 PM |
g | General short | 5/15/2026 2:30 PM |
G | General long | 5/15/2026 2:30:45 PM |
o or O | Round-trip ISO 8601 | 2026-05-15T14:30:45.0000000 |
s | Sortable ISO 8601 | 2026-05-15T14:30:45 |
u | UTC sortable | 2026-05-15 14:30:45Z |
R or r | RFC 1123 | Fri, 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.
When the standard formats don't fit, custom tokens give you precise control over every part of the output:
| Token | Meaning | Example |
|---|---|---|
yyyy | 4-digit year | 2026 |
yy | 2-digit year | 26 |
MM | 2-digit month | 05 |
MMM | Abbreviated month name | May |
MMMM | Full month name | May |
dd | 2-digit day | 15 |
ddd | Abbreviated day name | Fri |
dddd | Full day name | Friday |
HH | 2-digit hour (24-hour) | 14 |
hh | 2-digit hour (12-hour) | 02 |
mm | 2-digit minute | 30 |
ss | 2-digit second | 45 |
tt | AM/PM designator | PM |
fff | Milliseconds | 123 |
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.
Cost: 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.
CultureInfoNumber and date formatting depend on culture. The same 1234.5 looks different depending on where the customer is:
| Culture | 1234.5m.ToString("N2") | Currency for 1234.5m |
|---|---|---|
en-US (US English) | 1,234.50 | $1,234.50 |
de-DE (German) | 1.234,50 | 1.234,50 € |
fr-FR (French) | 1 234,50 | 1 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 InvariantCultureC# tracks two important cultures per thread:
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.
Every formatting API has an overload that accepts a culture:
Three patterns worth memorizing:
CurrentCulture (the default for ToString and $"...").InvariantCulture. FormattableString.Invariant($"...") is the cleanest way.CultureInfo for that locale and pass it explicitly.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.
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.
Cost: 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.
IFormattable InterfaceStandard 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.
Cost: 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.
A practical summary of when to reach for each formatting style:
| Need | Use | Why |
|---|---|---|
| Build a string from variables at compile time | $"..." interpolation | Cleanest 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 database | string.Format(template, args) | Template is a runtime string |
| Building a long string incrementally | StringBuilder.AppendFormat | Avoids intermediate allocations |
| Writing formatted output directly to console/file | Console.WriteLine(template, args) or writer.Write(...) | Skip the intermediate string |
| Persist a number/date for another machine to read | value.ToString(format, CultureInfo.InvariantCulture) | Stable, culture-independent output |
| Display a number/date to the user | value.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.
string.Format, Console.WriteLine, StringBuilder.AppendFormat) and string interpolation share the same {index[,alignment][:format]} placeholder syntax.$"...") for new code. Use string.Format when the template is loaded at runtime (resource files, database templates).C currency, D decimal integers, F fixed-point, N number with separators, P percent, X hex, R round-trip.0 (required digit), # (optional digit), . (decimal point), , (thousand separator), % (percent), and ; (section separator for positive/negative/zero).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.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.CultureInfo instances (CultureInfo.GetCultureInfo("de-DE")) instead of constructing new ones in a hot loop. Construction loads culture data and isn't free.