In this article, we are going to examine the decimal type, while focusing on controlling its precision both internally and in output.

To download the source code for this article, you can visit our GitHub repository.

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:

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
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.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!