Updated on
A numeric format string tells .NET how to turn a number into text. 1652.5899.ToString("F2") returns 1652.59, and "F2" is the whole format string: F is the format specifier and 2 is the precision specifier.
There are two kinds. A standard format string is one letter with an optional number after it: C, D, E, F, G, N, P, R, X and B. A custom format string is a pattern built from 0, #, ., ,, % and literal text, and anything that is not a standard letter is treated as custom. Going the other way, turning text back into a number, is a different job, and we cover it in TryParse and Parse in C#.
Microsoft’s standard numeric format strings reference draws that line by length: “Any numeric format string that contains more than one alphabetic character, including white space, is interpreted as a custom numeric format string.”
What Are Standard Numeric Format Strings in .NET?
A standard numeric format string is one alphabetic character, optionally followed by an integer. The letter is the format specifier and it chooses the shape of the output. The number is the precision specifier and it says how many digits appear. On .NET 7 and later it can run to 999,999,999.
1652.5899.ToString("F2") returns 1652.59: F asks for fixed-point notation and 2 for two digits after the decimal point.
Ten letters are defined. C is currency, D is decimal digits, E is exponential, F is fixed-point, G is general, N is a number with group separators, P is percent, R is round-trip, X is hexadecimal, and B is binary.
Case matters for exactly two of these letters. X gives upper-case hexadecimal digits and x lower-case ones, and E and e set the case of the exponent letter. The other eight are case-insensitive.
Three of the letters are integral-only. D, X and B throw a FormatException when we hand them a double instead of an integer.
Dates and times have their own standard-and-custom split with its own set of letters, and we walk through the same standard-and-custom split for dates and times in a separate article. Everything below is about numbers.
Format Specifier
Format specifiers define the output string format. Moreover, a format specifier is one alphabetic character.
For example, C for currency, D for integral types, E for exponential, etc. Next, we will look at an example and try to understand some of the format specifiers in detail.
Let’s start by creating a StandardFormatStrings class:
public static class StandardFormatStrings
{
public static string CurrencyFormat(double value) => value.ToString("C");
public static string EuroCurrency(double value) => value.ToString("C", new CultureInfo("fr-FR"));
public static string DecimalFormat(int value) => value.ToString("D");
public static string FixedPointFormat(double value) => value.ToString("F");
}
We use the C format specifier to format a number as a currency, we can also specify the currency sign by passing a CultureInfo object to the ToString() method as we did in the EuroCurrency() method. Depending on the currency type, the C format specifier positions the currency symbol either before or after the numeric value.
For example, for the currency EUR, the sign € comes after the numeric value, whereas for USD, the $ sign comes before the numeric value. Moreover, the CultureInfo object informs if the monetary amount separator should be . or ,. For example, for USD the separator is ., for EUR on the other hand, the separator is ,.
In the DecimalFormat() method, we use the D format specifier to define the number of digits for an integer. The D format specifier only works with integral values. Finally, we use the F format specifier to set the number of digits after a decimal point.
Next, let’s call these methods:
Console.WriteLine($"Currency: {StandardFormatStrings.CurrencyFormat(1652.5899)}");
Console.WriteLine($"Euro Currency: {StandardFormatStrings.EuroCurrency(1652.5899)}");
Console.WriteLine($"Decimal: {StandardFormatStrings.DecimalFormat(6546)}");
Console.WriteLine($"Fixed Point: {StandardFormatStrings.FixedPointFormat(1652.5899)}");
And inspect the output:
Currency: $1,652.59 Euro Currency: 1 652,59 € Decimal: 6546 Fixed Point: 1652.59
These outputs are from a machine running the en-US culture. C, N and P follow whatever CultureInfo.CurrentCulture is set to unless we pass a culture explicitly, as EuroCurrency() does.
We must note that we can use the format specifiers independently or combined with precision specifiers.
Precision Specifier
The second part of standard numeric format strings is precision specifiers, which define the number of digits to display in the resulting string. Precision specifiers behave differently when we use them with integers and floating-point numbers. In the case of integers, the number is neither rounded up nor down.
On the other hand, floating-point numbers are rounded to the nearest value. For example, if we use the F0 precision specifier on 1.5, it will result in 2, but if we use the same specifier with 1.4, the result will be 1. Notably, unlike format specifiers, precision specifiers are optional.
Let’s look at an example:
public static string DecimalPrecision(int value) => value.ToString("D5");
public static string FloatingPointPrecision(double value) => value.ToString("F2");
The D5 specifies the number of digits for an integer to be 5. D is the format specifier and 5 specifies the precision.
The same applies to F2. F is the floating-point format specifier, and 2 specifies the precision.
What happens at a midpoint depends on the type. A decimal stores the value exactly as we wrote it, so a midpoint really is a midpoint and it rounds away from zero. A double or a float is formatted from its exact stored binary value, and that value is usually a hair above or below the decimal literal we typed, so what looks like a midpoint often is not one:
(1.005).ToString("F2"); // 1.00
(0.125).ToString("F2"); // 0.12
(2.5).ToString("F0"); // 2
(1.005m).ToString("F2"); // 1.01
(0.125m).ToString("F2"); // 0.13
(2.5m).ToString("F0"); // 3
That difference matters when the number is money, and the storage side of it is a topic of its own, which we cover in controlling the precision of a decimal.
Next, let’s call these methods:
Console.WriteLine($"Decimal Precision: {StandardFormatStrings.DecimalPrecision(6546)}");
Console.WriteLine($"Floating Point Precision: {StandardFormatStrings.FloatingPointPrecision(1652.5899)}");
After that, we can check the output:
Decimal Precision: 06546 Floating Point Precision: 1652.59
The full list of available standard numeric format strings can be found in the Microsoft documentation. Here are all ten letters with a worked example of each, with the C and P rows shown under the en-US culture and the rest under the invariant one:
| Specifier | Meaning | Example | Result |
|---|---|---|---|
C | Currency, using the culture's symbol and separators | 1652.5899.ToString("C") | $1,652.59 |
D | Decimal digits, integral types only | 6546.ToString("D5") | 06546 |
E | Exponential (scientific) notation | 1652.5899.ToString("E2") | 1.65E+003 |
F | Fixed-point, precision digits after the decimal point | 1652.5899.ToString("F2") | 1652.59 |
G | General, the shorter of fixed-point and exponential | 1652.5899.ToString("G") | 1652.5899 |
N | Number with group separators | 1652.5899.ToString("N2") | 1,652.59 |
P | Percent, multiplies by 100 and appends the symbol | 0.54.ToString("P2") | 54.00% |
R | Round-trip, the shortest string that parses back exactly. Recommended for BigInteger; use G17 for double and G9 for float | (0.1 + 0.2).ToString("R") | 0.30000000000000004 |
X | Hexadecimal, integral types only | 6546.ToString("X") | 1992 |
B | Binary, integral types only (.NET 8 and later) | 6546.ToString("B") | 1100110010010 |
Where Standard Format Strings Are Supported
In .NET, standard numeric format strings are supported in different places, and this includes the commonly used methods such as ToString(), TryFormat(), and interpolated strings. In the previous example, we saw how to use standard numeric formats with the ToString() method, and how ToString() decides what a type looks like as text is worth a read on its own.
Next, let’s look at how we can use them with interpolated strings:
public static string Percentage(double value) => $"{value:P2}";
We use the P2 standard format to format a number as a percentage with two decimal places.
At this point, we can call the method:
Console.WriteLine($"Percentage: {StandardFormatStrings.Percentage(0.54)}");
After we run the app, we can check the output:
Percentage: 54.00%
How Do We Write Custom Numeric Format Strings in C#?
A custom numeric format string is a pattern rather than a letter. Anything that is not a standard letter with an optional precision is treated as custom, which is why one stray character can quietly change the rules that apply.
Five characters carry most of the work. 0 is a digit placeholder that pads, so 6546.ToString("00000") returns 06546. # is a digit placeholder that does not pad, so a position with no digit produces nothing at all.
. marks the decimal point, a , between digit placeholders inserts the culture’s group separator, and % multiplies the value by 100 before formatting it.
Literal text goes in single quotes, so 42.ToString("'ID-'00") returns ID-42. A backslash escapes one character the same way.
The # placeholder has one trap worth meeting early. 0.0.ToString("#,###.00") returns .00 rather than 0.00, because there is no digit to the left of the point and # refuses to invent a zero.
The fallback rule has a sharp edge. A single unknown letter is read as a standard specifier and throws, so 1652.5899.ToString("Q") gives a FormatException. Two or more characters fall through to custom and format silently, so 1652.5899.ToString("F2x") returns the literal F2x rather than complaining. That is why a typo in a format string usually produces nonsense instead of an error.
Here are four of them in one class:
public static class CustomFormatStrings
{
public static string Decimal(double number) => number.ToString("00000");
public static string FloatingPoint(double number) => number.ToString("0000.00");
public static string Percentage(double number) => number.ToString("0.00%");
public static string DigitSeparator(double number) => number.ToString("#,###.00");
}
The 0 custom format specifier specifies the number of digits in the resulting string. In our example, that is five digits. The . custom format specifier lets us define the number of digits before and after the decimal point. We use the # custom format specifier as a placeholder for a digit and , as a separator, and the % custom format specifier multiplies the number by 100 and creates a formatted percentage.
The list of custom numeric format strings is quite long. To learn more about them, be sure to check out the Microsoft documentation. These are the ten that come up most often, every result taken from a run under the invariant culture:
| Specifier | What it does | Example | Result |
|---|---|---|---|
0 | Digit placeholder that pads with zeros | 6546.ToString("00000") | 06546 |
# | Digit placeholder that does not pad | 1652.5899.ToString("##.##") | 1652.59 |
. | Decimal point | 1652.5899.ToString("0000.00") | 1652.59 |
, (between placeholders) | Group separator | 1652.5899.ToString("#,###.00") | 1,652.59 |
, (trailing) | Divides by 1,000 per comma, and the result is then rounded by the placeholders in front of it | 1652.5899.ToString("#,##0,K") | 2K |
% | Multiplies by 100 and inserts the percent symbol | 0.54.ToString("0.00%") | 54.00% |
e+0 or E+0 | Exponential notation | 1652.5899.ToString("0.###e+0") | 1.653e+3 |
; | Section separator, positive;negative;zero | 0.0.ToString("0.00;(0.00);-") | - |
'text' | Literal text, copied through as written | 42.ToString("'ID-'00") | ID-42 |
\ | Escapes the next character | 42.ToString(@"\#00") | #42 |
The ; separator has enough rules of its own to need a section; the next one covers it.
Where Custom Format Strings Are Supported
Similar to standard numeric format strings, we can use custom ones in different ways, including the ToString() method, interpolated strings, the string.Format() method, etc.
Let’s look at an example:
public static string Phone(long value) => string.Format("{0:(###) ###-####}", value);
public static string PhoneInterpolated(long value) => $"{value:(###) ###-####}";
These two methods will yield the same result. The # symbol serves as a placeholder for a digit, and when we pass the value:
Console.WriteLine($"Phone: {CustomFormatStrings.Phone(55665228871)}");
Console.WriteLine($"Phone: {CustomFormatStrings.PhoneInterpolated(55665228871)}");
It returns a nicely formatted phone number:
Phone: (5566) 522-8871 Phone: (5566) 522-8871
How Do We Format Positive, Negative, and Zero Values Differently?
A custom format string can carry up to three sections separated by semicolons. The first section formats positive values, the second formats negative values, and the third formats zero.
"#,##0.00;(#,##0.00);Zero" gives 1,234.50, (1,234.50) and Zero for the three cases. The negative section supplies its own sign, which is why the minus disappears once the parentheses take over.
With two sections, zero uses the first one. With a single section, the runtime formats the absolute value and puts a minus in front of negatives.
An empty third section does not blank out zero. "#,##0.00;(#,##0.00);" still formats zero as 0.00, because an empty section is ignored and the value falls back to the first one.
To render zero as an empty string, give the third section an empty literal instead. "0;;''" returns nothing at all for zero, 1235 for 1234.5, and -1235 for -1234.5.
The value’s sign picks the section, and nothing else does.

Let’s put all three cases in one place:
public static string Accounting(double number) => number.ToString("#,##0.00;(#,##0.00);Zero");
public static string BlankZero(double number) => number.ToString("0;;''");
public static string EmptyThirdSection(double number) => number.ToString("#,##0.00;(#,##0.00);");
And run them over a positive value, a negative one and zero:
Accounting(1234.5) 1,234.50 Accounting(-1234.5) (1,234.50) Accounting(0.0) Zero BlankZero(1234.5) 1235 BlankZero(-1234.5) -1235 BlankZero(0.0) EmptyThirdSection(0.0) 0.00
That last line is the one to remember. EmptyThirdSection() looks like it should hide zero and it does not, because an empty section is skipped rather than applied.
How Do We Apply a Format String With String.Format and Interpolation?
Every format string here works anywhere .NET accepts one, and we cover four of those places: ToString(), string.Format(), an interpolated string, and TryFormat().
string.Format() and interpolated strings both use a composite format item, written {index[,alignment][:formatString]}. The index picks the argument, the alignment pads the result out to a field width, and whatever follows the colon is the same format string we would pass to ToString().
string.Format("{0,12:N2}", 1652.5899) returns 1,652.59 right-aligned in a field twelve characters wide. A negative alignment left-aligns instead, so {0,-12:N2} pads on the right.
Inside an interpolated string the syntax is identical, minus the index: $"{value,12:N2}" produces the same twelve characters.
TryFormat() takes a Span<char> and writes into it, returning false when the buffer is too small rather than allocating a string. It is the option to reach for on a hot path, and it takes the same format strings as everything else.
The three parts sit in a fixed order, and only the index is required.

Let’s see the alignment component and TryFormat() side by side:
public static string Aligned(double value) => string.Format("{0,12:N2}", value);
public static string AlignedInterpolated(double value) => $"{value,-12:N2}";
public static bool TryFormatFixedPoint(double value, Span<char> destination, out int charsWritten) =>
value.TryFormat(destination, out charsWritten, "F2", CultureInfo.InvariantCulture);
The first two pad the same number in opposite directions, and TryFormatFixedPoint() reports what it managed to write:
Aligned(1652.5899) | 1,652.59| AlignedInterpolated(1652.5899) |1,652.59 | TryFormatFixedPoint into char[16] -> true, charsWritten = 7, "1652.59" TryFormatFixedPoint into char[2] -> false, charsWritten = 0
The interpolated form is the one most code reaches for, and string interpolation and what else its syntax can carry goes well beyond format strings.
Conclusion
In this article, we looked at the different standard and custom numeric format strings in .NET. Furthermore, we looked at the support for these format strings.
Tested with .NET 10.
