8.5. Use decimal When Precision Is Paramount¶
Python is an excellent language for writing code that interacts with numerical data. Python’s integer type can represent values of any practical size. Its double-precision floating point type complies with the IEEE 754 standard. The language also provides a standard complex number type for imaginary values. However, these aren’t enough for every situation.
For example, say that I want to compute the amount to charge a customer for an international phone call. I know the time in minutes and seconds that the customer was on the phone (say, 3 minutes 42 seconds). I also have a set rate for the cost of calling Antarctica from the United States ($1.45/minute). What should the charge be?
With floating point math, the computed charge seems reasonable
>>> rate = 1.45
>>> seconds = 3*60 + 42
>>> cost = rate * seconds / 60
>>> print(cost)
5.364999999999999
The result is 0.0001 short of the correct value (5.365) due to how IEEE 754 floating point numbers are represented. I might want to round up this value to 5.37 to properly cover all costs incurred by the customer. However, due to floating point error, rounding to the nearest whole cent actually reduces the final charge (from 5.364 to 5.36) instead of increasing it (from 5.365 to 5.37):
>>> print(round(cost, 2))
5.36
The solution is to use the Decimal class from the decimal built-in module. The Decimal class provides fixed point math of 28 decimal places by default. It can go even higher, if required. This works around the precision issues in IEEE 754 floating point numbers. The class also gives you more control over rounding behaviors.
For example, redoing the Antarctica calculation with Decimal results in the exact expected charge instead of an approximation:
>>> from decimal import Decimal
>>> rate = Decimal('1.45')
>>> seconds = Decimal(3*60 + 42)
>>> cost = rate * seconds / Decimal(60)
>>> print(cost)
5.365
Decimal instances can be given starting values in two different ways. The first way is by passing a str containing the number to the Decimal constructor. This ensures that there is no loss of precision due to the inherent nature of Python floating point numbers. The second way is by directly passing a float or an int instance to the constructor. Here, you can see that the two construction methods result in different behavior.
Click here to view code image
>>> print(Decimal('1.45'))
>>> print(Decimal(1.45))
1.45
1.4499999999999999555910790149937383830547332763671875
The same problem doesn’t happen if I supply integers to the Decimal constructor:
>>> print('456')
>>> print(456)
456
456
If you care about exact answers, err on the side of caution and use the str constructor for the Decimal type.
Getting back to the phone call example, say that I also want to support very short phone calls between places that are much cheaper to connect (like Toledo and Detroit). Here, I compute the charge for a phone call that was 5 seconds long with a rate of $0.05/minute:
Click here to view code image
>>> rate = Decimal('0.05')
>>> seconds = Decimal('5')
>>> small_cost = rate * seconds / Decimal(60)
>>> print(small_cost)
0.004166666666666666666666666667
The result is so low that it is decreased to zero when I try to round it to the nearest whole cent. This won’t do!
>>> print(round(small_cost, 2))
0.00
Luckily, the Decimal class has a built-in function for rounding to exactly the decimal place needed with the desired rounding behavior. This works for the higher cost case from earlier:
Click here to view code image
>>> from decimal import ROUND_UP
>>>
>>> rounded = cost.quantize(Decimal('0.01'), rounding=ROUND_UP)
>>> print(f'Rounded {cost} to {rounded}')
Rounded 5.365 to 5.37
Using the quantize method this way also properly handles the small usage case for short, cheap phone calls:.
Click here to view code image
>>> rounded = small_cost.quantize(Decimal('0.01'),
>>> rounding=ROUND_UP)
>>> print(f'Rounded {small_cost} to {rounded}')
Rounded 0.004166666666666666666666666667 to 0.01
While Decimal works great for fixed point numbers, it still has limitations in its precision (e.g., 1/3 will be an approximation). For representing rational numbers with no limit to precision, consider using the Fraction class from the fractions built-in module.
8.5.1. Things to Remember¶
✦ Python has built-in types and classes in modules that can represent practically every type of numerical value.
✦ The Decimal class is ideal for situations that require high precision and control over rounding behavior, such as computations of monetary values.
✦ Pass str instances to the Decimal constructor instead of float instances if it’s important to compute exact answers and not floating point approximations.