Decimal vs float in Python
When working with decimal numbers in Python, we usually turn to floats. Floats serve us well for most purposes, such as simple divisions, but they do have limitations that can become incredibly problematic for certain use cases. They're simply not precise enough. However, floats aren't our only option, and today we're going to be taking a look at the decimal
module, and why you might use it.
A Quick Look at Binary
Before we can properly understand the problem with floats, we need to do a quick review of how numbers are represented in binary.
The number system that we use in everyday life is a base-10 number system, also known as a decimal number system. We use ten unique numerals in various combinations to represent all numbers. Binary, on the other hand, is a base-2 number system and only uses two unique numerals: generally 0 and 1. When numbers are stored in our computers, they are stored in this binary format.
A binary number might look something like this: 10101101, which happens to be 173.
So how do we get 173 out of 10101101?
Binary works in powers of 2, so the rightmost 1 in 10101101, represents 1 x 2⁰. We then take a step to the left, where we find a 0. This 0 represents 0 x 2¹, which is 0 x 2. Another step to the left and we find another 1, this time representing 1 x 2², which is 4. Every step to the left, the power increases by 1.
In total we have something that looks like this:
(1 × 2⁷) + (0 × 2⁶) + (1 × 2⁵) + (0 × 2⁴) + (1 × 2³) + (1 × 2²) + (0 × 2¹) + (1 × 2⁰)
Which is:
(1 x 128) + (0 x 64) + (1 x 32) + (0 x 16) + (1 x 8) + (1 x 4) + (0 x 2) + (1 x 1)
If we were to add all this up, we'd get 173. As you can see, binary representations of numbers tend to be a great deal longer than decimal representations, but we can ultimately represent any integer in this way.
Fractions in Binary
So, we've done a quick refresher on how integers can be represented in binary, but what about fractions? As it turns out, it works much the same way, just with negative powers.
For example, 2⁻¹ is ½, and 2⁻² is ¼, which means we can now represent 0.75, 0.5, and 0.25. Using progressively greater negative powers we can represent all manner of decimal numbers.
However, just as there are numbers we can't represent with a finite number of decimal numerals (e.g. ⅓), there are also numbers we can't represent in binary. For example, the number 0.1 has no finite binary representation.
Floats in Python
So what happens when we write 0.1
in Python? Let's take a look:
print(f"{0.1:.20f}") # 0.10000000000000000555
For those of you not familiar with the syntax above, the :.20f
is a way of telling Python we want 20 digits after the decimal point for this float. We have a post you can look at below:
https://blog.tecladocode.com/python-formatting-numbers-for-printing/
As we can see, we don't actually get 0.1
: we get a close approximation of 0.1
. Unfortunately, sometimes a close approximation just isn't good enough.
This is particularly prevalent when performing comparisons:
a = 10
b = a / 77
c = b * 77
if a != c:
print("Things got weird...")
# Things got weird...
If we print a
and c
, we can see what happened:
print(f"{a:.20f}") # 10.00000000000000000000
print(f"{c:.20f}") # 9.99999999999999822364
This approximation error can get hugely compounded over a series of operations, meaning we can actually end up with quite significant differences between numbers that should be identical.
You can read more about these issues in the Python documentation: https://docs.python.org/3/tutorial/floatingpoint.html
Enter decimal
As mentioned at the start of this post, Python does have another way of dealing with decimal numbers, which is the decimal
module. Unlike floats, the Decimal
objects defined in the decimal
module are not prone to this loss of precision, because they don't rely on binary fractions.
Creating Decimal
objects
Before we dive into Decimal
objects, let's look at how to defined them. There are actually several ways to do this.
Using an integer
The first way to create a Decimal
object us to use an integer. In this case, we simply pass the integer in as an argument to the Decimal
constructor:
import decimal
x = decimal.Decimal(34) # 34
We can now use x
just like any other number, and we'll get a Decimal
object back when performing mathematical operations:
x = decimal.Decimal(34)
print(x + 7) # 41
print(type(x + 7)) # <class 'decimal.Decimal'>
print(x // 7) # 4
print(type(x // 7)) # <class 'decimal.Decimal'>
Using a string
Perhaps a little surprisingly, one of the easiest ways to make a Decimal
object with fractional components is using a string. We just need to pass a string representation of the number to Decimal
and it'll take care of the rest:
x = decimal.Decimal("0.1")
print(x) # 0.1
print(f"{x:.20f}") # 0.10000000000000000000
As we can see, printing x
to 20 decimal places here gives us 19 zeroes: we don't end up with some random 5s at the end like we did when using float.
If you need a precise decimal representation of a number, using strings to create your Decimal
objects is a very simple way to achieve this.
Using a float
It's also possible to create a Decimal
object from a float, but I'd generally advise against doing this. The example below should make it clear as to why:
x = decimal.Decimal(0.1)
print(x) # 0.1000000000000000055511151231257827021181583404541015625
Converting straight from a float causes our Decimal
object to inherit all of the imprecision that we were trying to avoid in the first place. There may be cases where you want to preserve this imprecision for some reason, but most of the time, that's not the case.
Using a tuple
Perhaps the most complicated method of creating a Decimal
object is using a tuple, and this method gives us some insight into how Decimal
objects work under the hood.
The tuple we provide to Decimal
has three parts. The first element in the tuple is either the number 0
or 1
, and this represents the sign of the number (whether it's positive or negative). A zero in this first position indicates a positive number, while a one represents a negative number.
The second item in the tuple is another tuple, and this contains all of the digits in the resulting number. For example, the number 1.3042
has the digits (1, 3, 0, 4, 2)
.
The third and final item in the tuple is an exponent. This tells the Decimal
object how many places we need to shift the digits around the decimal point. An exponent of -3
is going to cause the digits to shift 3 spaces to the right, giving us 13.042
, while an exponent of 2
is going to give us 1304200
.
A complete example for the number -13.042
looks like this:
x = decimal.Decimal((1, (1, 3, 0, 4, 2), -3))
print(x) # -13.042
A really important thing to note here is that we do need the brackets around the outer tuple, as Decimal
expects this tuple as a single argument. Removing the outer brackets will cause an error.
While relatively complicated, the tuple syntax is best suited to creating Decimal
objects programmatically.
The Context
object
One of the cool things about the decimal
module is that it allows us to define the behaviour of Decimal
objects in various ways. We can specify degrees of precision, limits for exponents, and even rounding rules.
In order to do this, we're generally going to be working with the getcontext
function, which allows us to see and modify the Context
object for the current thread.
Let's take a look at what a default Context
object looks like:
import decimal
print(decimal.getcontext())
# Context(prec=28, rounding=ROUND_HALF_EVEN, Emin=-999999, Emax=999999, capitals=1, clamp=0, flags=[], traps=[InvalidOperation, DivisionByZero, Overflow])
As you can see, we have all kinds of properties that we can set to change how our Decimal
objects work.
For example, we might decide we want 10 significant figures worth of precision, which we'd set like this:
import decimal
decimal.getcontext().prec = 10
x = decimal.Decimal(1) / decimal.Decimal(7)
print(x) # 0.1428571429
Note that this new precision is only relevant during mathematical operations. We can define a Decimal
object with arbitrary precision, even if we set a lower level of precision using getcontext
.
import decimal
decimal.getcontext().prec = 10
x = decimal.Decimal("1.045753453457657657")
y = decimal.Decimal(7)
print(x) # 1.045753453457657657
print(y) # 7
print(x / y) # 0.1493933505
As I mentioned earlier, we can also define how rounding works for Decimal
objects, which can be very useful. By default, Python uses bankers' rounding, which differs somewhat from the kind of rounding we learn about in school.
We talk about bankers' rounding here if you're not familiar with it: https://blog.tecladocode.com/rounding-in-python/
Using getcontext
we might change Python's rounding behaviour to always round decimals ending in 5
away from 0
, which is how we round in everyday life:
import decimal
decimal.getcontext().prec = 1
x = decimal.Decimal(5)
y = decimal.Decimal(2)
print(x / y) # 2
decimal.getcontext().rounding = decimal.ROUND_HALF_UP
print(x / y) # 3
A number of different rounding behaviours are defined in the decimal
module using constants. You can read more about the options available here: https://docs.python.org/3.7/library/decimal.html#module-decimal
Some useful decimal
methods
The decimal
module comes with a number of handy methods for working with Decimal
objects. Here are a few that you might want to check out.
sqrt
The sqrt
method allows us to get the square root of any Decimal
with the designated level of precision.
import decimal
decimal.getcontext().prec = 15
x = decimal.Decimal(2).sqrt()
print(x) # 1.41421356237310
quantize
The quantize
method is used to change a Decimal
object to a new exponent. For example, say we have the number 1.41421356
and we want to change this number to match the pattern 1.000
, we could write the following:
import decimal
x = decimal.Decimal("1.41421356")
y = x.quantize(decimal.Decimal("1.000"))
print(y) # 1.414
One thing to be aware of with the quantize
method is that it will raise an InvalidOperation
exception if the final values exceeds the defined level of precision.
import decimal
decimal.getcontext().prec = 1
x = decimal.Decimal("1.41421356")
y = x.quantize(decimal.Decimal("1.000"))
# decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]
as_tuple
The as_tuple
method gives us a tuple representation of the Decimal
object, just like when we create one using the tuple syntax.
import decimal
x = decimal.Decimal("1.41421356")
print(x.as_tuple())
# DecimalTuple(sign=0, digits=(1, 4, 1, 4, 2, 1, 3, 5, 6), exponent=-8)
There are many other methods available for Decimal
objects, and I'd recommend taking a look at this section of the documentation to learn more: https://docs.python.org/3.7/library/decimal.html#decimal-objects
So, should we just use decimal
?
Not necessarily. While the decimal
module is certainly very neat, and it has the benefit of absolute precision, this precision comes at a cost. In this case, the cost is twofold.
Firstly, using the decimal
module is much harder than using floats. This much is obvious as soon as we look at the DecimalTuple
object above, and we also have things like contexts to consider on top of this. You simply need to know more in order to even make use of the module.
The second issue is perhaps more pressing, however, and that's down to performance. decimal
operations are likely to be around 3 times slower than operations using floats, so if you're dealing with a performance sensitive part of your application, where absolute precision isn't important, you might want to stay clear of decimal
. This is particularly true if you're still working with Python 2, as the operations are then likely to be a couple of hundred times slower!
The decimal
module is a great tool to have in our toolkit, but we should always be aware of whether we truly need it for a particular application.
Wrapping up
That's it for this post on floats and decimals. There's definitely a lot more to learn, so once again, take a look at the documentation! The decimal
module documentation can be a little intimidating, but hopefully what we've talked about here makes you well prepared to dive right in.
If you've been enjoying our posts, and you want to take your Python to the next level, be sure to check out our Complete Python Course.