Updated on
A C# decimal stores a number as a 96-bit whole number plus a scale that says where the decimal point goes. That is why it counts in tenths and hundredths the way we do, and why 0.1m + 0.2m is exactly 0.3m while the same sum in double is not.
The trade is range for exactness. A decimal reaches 79,228,162,514,264,337,593,543,950,335 and no further, which is 28 or 29 significant digits depending on the number. Everything below is about spending those digits deliberately, either in what we display or in what we store.
What Is the decimal Type in C#?
The decimal type in C# is a 128-bit number built from a sign bit, a 96-bit whole number, and a scale that records how many places to shift the decimal point left. A decimal is written with an m or M suffix, because an unsuffixed literal such as 1.5 is a double.
Because the scale is a power of ten rather than a power of two, decimal represents the values people write down. 0.1m is exactly one tenth. In double, 0.1 is the nearest binary fraction to one tenth, which is why 0.1 + 0.2 produces 0.30000000000000004 and 0.1m + 0.2m produces 0.3.
That exactness is what makes it the type for money and for anything a person will audit. The cost is range and storage: a decimal stops at 79,228,162,514,264,337,593,543,950,335, and it takes 16 bytes of memory where a double takes only 8.
First, decimal is a 128-bit base 10 floating-point value (although only 102 bits are actually used). It consists of 96 bits for a whole number, 5 bits for a scale, and a sign bit. The main advantage of decimal when we speak about financial or certain scientific calculations is the fact that it is a base 10 floating-point value rather than binary. This helps prevent many of the rounding errors that are seen with types such as float and double. For a deep dive into rounding issues, be sure to check out our article dealing with floating-point equality.
Let’s illustrate this with a simple numeric example:
decimal highPrecisionValue = 123456789.1234567890123456789012345M;
double regularDouble = 123456789.1234567890123456789012345;
float regularFloat = 123456789.1234567890123456789012345f;
Console.WriteLine($"Decimal: {highPrecisionValue}");
Console.WriteLine($"Double: {regularDouble}");
Console.WriteLine($"Float: {regularFloat}");
Here we define three “identical” values, but assign them to three different C# floating-point types: decimal, double, and float respectively. Also, note how we use M to define a decimal constant and f to denote a float constant. This is because by default in C# a floating-point constant is considered a double.
Now let’s review the output:
Decimal: 123456789.12345678901234567890 Double: 123456789.12345679 Float: 123456790
Here we clearly see the difference in the available precision between decimal, double and float. This example highlights why decimal is the goto type for financial calculations and other computations where we need a great deal of precision. For the full picture of how decimal compares with double and float, including where each one is the right choice, we have a dedicated article.
Having that in mind, let’s explore how we can control the precision of these values.
Why Does a C# decimal Hold 28-29 Significant Digits?
The 96-bit integer inside a decimal can hold any value up to 79,228,162,514,264,337,593,543,950,335. That number has 29 digits, so 29 digits is the ceiling. But not every 29-digit number fits: anything above that limit needs a 30th bit, so it rounds. Every 28-digit number fits without exception.
That is the whole of “28 or 29, depending on the value”. Twenty-eight digits are always available, and the twenty-ninth is available only while the number stays under the limit.
The scale is a separate budget. It runs from 0 to 28 and decides how many of those digits sit after the point, not how many exist. 1.10m and 1.1m compare as equal and are the same quantity, but they carry different scales and print differently, because a decimal remembers the trailing zero it was given.
Assign a longer literal and the compiler simply rounds it to fit.
Division shows the limit directly: 907m / 31m gives 29.258064516129032258064516129, exactly 29 significant digits, because the quotient never terminates and the type fills every digit it has.
| Property | Value |
|---|---|
| .NET type | System.Decimal |
| Size | 128 bits, of which 102 carry information |
| Layout | sign bit + 96-bit integer + scale (0 to 28) |
| Significant digits | 28 or 29, depending on the value |
decimal.MaxValue | 79228162514264337593543950335 |
decimal.MinValue | -79228162514264337593543950335 |
| Smallest non-zero value | 0.0000000000000000000000000001 (1 x 10^-28) |
| Literal suffix | m or M, as in 123.45m |
| Default value | 0m |
| Default rounding | MidpointRounding.ToEven (banker's rounding) |
How Do We Control the Displayed Precision of a decimal?
Displaying a decimal with fewer digits does not change the value. ToString() produces a new string; the decimal in the variable is untouched, and the next calculation still uses every digit it had.
There are two ways to say how many digits we want. A custom format string spells out the shape directly: myDecimal.ToString("0.00") gives two places, "0.0000" gives four. A standard format string names a style instead, and takes the digit count from a NumberFormatInfo we supply, so ToString("F", format) with NumberDecimalDigits set to 3 gives three places.
The custom string is the right default. It is shorter, it needs no extra object, and what it produces is visible in the string itself. The NumberFormatInfo route earns its keep when the digit count is a variable rather than a constant, or when the same settings are reused across many values.
Both round rather than cut.
For a deep dive into the available format strings themselves, be sure to check out our article ‘Standard and Custom Numeric Format Strings in .NET‘.
Controlling Decimal Precision Using Custom Format Strings
First, let’s see how we can use custom format strings to control decimal precision. Let’s test this out by restricting the fractional part of our value to two significant digits using the custom format string "0.00":
const decimal myDecimal = 123.456789M;
Console.WriteLine($"Value (\"0.00\"): {myDecimal.ToString("0.00")}");
Console.WriteLine($"Value (default format): {myDecimal}");
Here we define a decimal value myDecimal having more than 2 significant digits. We then call ToString() with our custom format string "0.00" and print the resultant value to the console. Following that we print the original value without any custom formatting.
Upon examining the output we see that the internal value has remained unchanged (as observed in the second WriteLine() call), but when printed with our custom formatting string, the number displayed significant digits is restricted:
Value ("0.00"): 123.46
Value (default format): 123.456789
To display more digits, we simply need to adjust the format string. For instance, to display 4 significant digits:
Console.WriteLine($"Value (\"0.0000\"): {myDecimal.ToString("0.0000")}");
Which produces:
Value ("0.0000"): 123.4568
Using NumberFormatInfo
Another option for formatting the output of decimal values is to make use of the NumberFormatInfo class. While this class has a plethora of options that we can use to control the output of our value, including even changing the character set for digits, for our purposes, we will focus solely on controlling the number of digits output. We do this by setting the NumberDecimalDigits property:
public static string ToStringXDecimalPlaces(this decimal val, int decimalPlaces)
{
var format = new NumberFormatInfo
{
NumberDecimalDigits = decimalPlaces
};
return val.ToString("F", format);
}
First, we initialize a new instance of NumberFormatInfo, setting its NumberDecimalDigits based on our specified precision. We then return the decimal as a string formatted using the Fixed-point standard numeric format and our NumberFormatInfo object.
The NumberDecimalDigits property only applies when using the standard numeric format strings “N” (Number) or “F” (Fixed-point). For more information on these and other format strings, we can consult the .NET documentation for Standard Numeric Format strings.
Let’s see our extension method in action:
Console.WriteLine($"Value (NumberFormatInfo 3 digits): {myDecimal.ToStringXDecimalPlaces(3)}");
Which produces:
Value (NumberFormatInfo 3 digits): 123.457
Through the use of formatting strings, we can print decimal numbers without altering their numeric value. However, we can also use rounding to control their internal precision.
How Do We Control the Stored Precision of a decimal?
Changing the stored value means calling one of four methods, and each returns a new decimal rather than altering the one we passed in.
decimal.Round() is the one that takes a digit count: decimal.Round(value, 2) keeps two places. By default it breaks ties to the nearest even digit, so 2.5m rounds to 2 and 3.5m rounds to 4. Passing MidpointRounding.AwayFromZero gives the behaviour most people expect from school, where 2.5m becomes 3.
Math.Round() produces the same result for a decimal argument, so the choice between the two is style rather than behaviour.
The other three take no digit count and always land on a whole number. Truncate() drops the fraction and moves towards zero. Floor() moves towards negative infinity. Ceiling() moves towards positive infinity.
On positive numbers all three look identical. On -15.6789m they split three ways: Truncate() gives -15, Floor() gives -16, Ceiling() gives -15. That split is the only thing worth memorising about them.
Controlling Decimal Precision Through Rounding
Rounding functions are essential for controlling the precision of decimal numbers, enabling us to choose how many digits of precision our value holds.
Rounding stays necessary even with an exact base-10 type. Microsoft’s reference for the System.Decimal structure says it plainly: “The Decimal type does not eliminate the need for rounding.”
In C#, the Math.Round() function, one of the Math class and its rounding methods, rounds a value to the nearest integer or a specific number of fractional digits. We can also use the equivalent decimal.Round() function, a long-standing static method on System.Decimal that calls through to the same behaviour. By default, the Round() method uses the MidpointRounding.ToEven strategy:
public static decimal Round(this decimal value, int decimalPlaces)
=> decimal.Round(value, decimalPlaces);
Here we create a simple extension method that will invoke decimal.Round() to round our value to the specified number of places.
Now, let’s see it in action:
Console.WriteLine($"Value (default format): {myDecimal}");
Console.WriteLine($"Value (round 2 places): {myDecimal.Round(2)}");
Notice here that we are printing both values using the default decimal format:
Value (default format): 123.456789 Value (round 2 places): 123.46
The Truncate Method
We can also use the Truncate() method to control the precision of our value. Truncate() removes the fractional part, leaving us with only the integral piece of our value. Let’s create an extension method for exercising it:
public static decimal Truncate(this decimal value) => decimal.Truncate(value);
And calling it with our decimal value (123.456789):
Console.WriteLine($"Value (truncate): {myDecimal.Truncate()}");
Yields:
Value (truncate): 123
The Ceiling and Floor Methods
Ceiling() and Floor() are similar to Truncate() in that they both return a value with the fractional part removed. The difference is that while Truncate() simply strips the fractional part off, Ceiling() and Floor() act more like Round(). Ceiling() returns the smallest integer that is not less than the value, and Floor() returns the largest integer that is not greater than it. When the target is a whole number rather than a set number of places, our article on rounding a number down to the nearest integer covers that case on its own.
Let’s create a couple more extension methods to exercise these operations:
public static decimal Ceiling(this decimal value) => decimal.Ceiling(value); public static decimal Floor(this decimal value) => decimal.Floor(value);
There isn’t much to these methods other than some syntactic sugar that allows us to call them directly on our example decimal value:
Console.WriteLine($"Value (ceiling): {myDecimal.Ceiling()}");
Console.WriteLine($"Value (floor): {myDecimal.Floor()}");
Which yields:
Value (ceiling): 124 Value (floor): 123
| Method | 12.3456 | 7.0 | -15.6789 | What it does |
|---|---|---|---|---|
decimal.Round(v, 2) | 12.35 | 7.0 | -15.68 | Rounds to 2 decimal places, ties to even |
decimal.Truncate(v) | 12 | 7 | -15 | Drops the fractional part, towards zero |
decimal.Floor(v) | 12 | 7 | -16 | Largest integer that is not greater than the value |
decimal.Ceiling(v) | 13 | 7 | -15 | Smallest integer that is not less than the value |
The 12.3456 and -15.6789 columns are the values the sample project’s tests assert, so the table and the repository cannot drift.
What Happens When We Cast a float or double to decimal?
A cast to decimal does not recover precision that was never there, and it does not preserve everything that was. It rounds, and how much it keeps depends on which type it started from.
Casting from float keeps 7 significant digits. Casting from double keeps 15. That is why (decimal)(float)2222.998 gives 2222.998 even though the float actually holds 2222.998046875: seven digits is 2222.998, and the rest is discarded. Route the same value through double instead, with (decimal)(double)(float)2222.998, and all of 2222.998046875 survives.
The lesson is not about casting. It is that a value which passes through a float has already lost accuracy, and converting it to decimal afterwards only decides how much of the damage is visible.
So a value that must be exact is declared decimal at the point it enters the program, from a literal or a parsed string, and never travels through float or double on the way.
When that string arrives at runtime, decimal.TryParse() and the parsing methods are how we turn it into a decimal without a detour through a binary type.
Conclusion
In this article, we explored various techniques for controlling the precision of decimal values. We first examined how to control the output format without modifying the internal storage of our values. We then examined techniques for controlling the internal precision of our values. Ultimately, the option we choose is dependent upon our use case. If we wish to maintain a high amount of precision, we should probably focus on simply adjusting the display formatting of our data. On the other hand, when we have less precise data, we may wish to use one of the rounding techniques to reduce the internal precision of our values. For more information regarding the decimal type, be sure to check out Jon Skeet’s excellent article on the topic.
Tested with .NET 10.
