In this article, we are going to examine the decimal type, while focusing on controlling its precision both internally and in output.
When precision and accuracy matter, decimal numbers are essential. This is the recommended type for all financial calculations due to its design. It is designed as a base 10 floating point value that has an accuracy of between 28 and 29 decimal digits, depending on the value.
Understanding the Decimal Data Type
Before diving into controlling the precision of decimal values, let’s review a couple of key points about the data type itself.
First, decimal
is a 128-bit base 10 floating-point value (although only 102 bits are actually used). It consists of 96 bits of mantissa, 5 bits of exponent, 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 IEEE 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.
Having that in mind, let’s explore how we can control the precision of these values.
Controlling Output Precision of Decimal
In many cases, we are not so concerned with the internal precision of our values and want to preserve that, but don’t necessarily need to display all 28 digits of accuracy to our user. In these situations, we can use formatting strings to control the “output precision” of our values. For a deep dive into the available format strings, 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.
It is important to note that 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.
Controlling Internal Precision of Decimal
So far we have only examined how to adjust the “cosmetic appearance” of decimal values. Now let’s take a look at how we can control their internal precision.
Controlling Decimal Precision Through Rounding
Rounding functions are essential for controlling the precision of decimal
numbers, enabling us to how many digits of precision our value holds.
In C#, the Math.Round()
function rounds a value to the nearest integer or a specific number of fractional digits. If our application is targeting .NET 7 or greater, we can also use the equivalent decimal.Round()
function that was added as part of the generic math functionality. 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 next smallest integer value that is greater than the specified value. In contrast, Floor()
returns the next largest integer value that is less than the specified value.
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
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.