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-snippet-20-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. It would also be a big help if you signed up to our mailing list below. We're going to be regularly posting coupons for our online courses, so it's a great way to snag a deal while keeping up to date with all our content.