How to Handle Monetary Values in Python

Learn how to manage monetary values in Python: their specifics, data types for exact arithmetic operations, storing principles and more.

alt

Working with money in programming requires precise calculations. An error of even one cent can cause problems for you and your users. This is especially important for those who do daily financial calculations, such as economists, accountants, and financial analysts.

In this article, we will discuss how to handle monetary values in Python, and what methods are best to use for accurate calculations.

Peculiarity of work with monetary values

Let’s assume the future service should work with currency values and have a public API and a database. So, one of its important requirements is to perform accurate arithmetic operations. 

We choose the Python programming language because it is one of the best solutions out there in terms of speed of development, ease of deployment, and number of available packages and frameworks. First, let’s check arithmetic operations with integers. The Integer type is represented in Python. We’ll try to add 1 and 2 and check the result with the expected value of 3.

1 + 2 == 3
# True

Everything works as we expect. Now let’s try adding 0.1 and 0.2 and check the result with the expected value of 0.3. For decimal values, Python provides the Float type.

0.1 + 0.2 == 0.3
# False

The expected result could not be obtained. Python does not consider the result of addition and the expected result to be equivalent. Working with fractional numbers in Python (and in most other known programming languages) comes with several peculiarities. One of them is how such values are stored in the computer’s memory.

People are used to dealing with the decimal number system in their calculations. That is when we say “zero point one tenth” we mean “zero point one tenth with base ten”. A computer stores data in the binary number system, that is, with a base of two. If we convert 0.1 from the decimal number system to the binary number system, we get 0.110 = 0.0(0011)2. There is no way to store a value with a period in computer memory, so this value becomes finite. When converted back to decimal, it no longer equals 0.110. For example, If we try to limit the value in the binary number system we will get 0.00011001100112 = 0.099975585937510, which is no longer equal to 0.110.

But the real numbers are different. For example, 0.2510 can be converted from the decimal number system to the binary number system and back without any problems and compatibility will not be broken. This happens because 0.2510 is finite when converted to binary, but 0.110 is not.

Let’s define the problem – most fractional numbers in the decimal number system do not have an exact representation in the binary number system. Here is an interesting resource where you can learn what error Float type has in different programming languages – 0.30000000000000004.com

The question arises – which data type should be used for exact arithmetic operations?

Data types for exact arithmetic operations

There are two basic approaches to dealing with monetary values.

Let’s consider the first approach – to use the Integer type for work. The idea is to multiply a monetary value by a factor divisible by 10 before saving it, and divide it back by this factor when reading it. In this case, arithmetic operations are performed on integers. You can also divide the integer and fractional parts into two separate fields of Integer type. However, you need to multiply each time you save it and divide it by a certain factor when reading it. You should always keep this in mind.

The second approach is to use the Decimal type. It is similar to the Float type, but the fundamental difference is the way such values are stored in memory. For example, Python uses an encoding and decoding method called BCD (Binary-coded Decimal). This encoding method specifies that each digit is stored separately in binary form. In addition to the storage method, Decimal types differ from Float types in implementing some common specifications, such as IBM’s “The General Decimal Arithmetic Specification”.

Unlike the first approach, you don’t need a coefficient for multiplying and dividing. All calculations are performed immediately with the final value. In addition, the Decimal type is represented in most programming languages, including Python. However, it is important to remember that you need to use only this data type in all calculations in your application.

For example, in Python, you can perform math operations between decimal and float, but the output may not be the expected values. And yes, the speed of working with Decimal type in Python is a bit slower than with Float type. However, in the case of financial calculations, accuracy is more important than speed.

Back to our service. We chose the second approach and decided to use the Decimal type for arithmetic operations with monetary values. Let’s make sure that the result of working with values of Decimal type coincides with the expected result.

from decimal import Decimal
Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
# True

Great! We’ve determined that the Decimal type is suitable for us. Now we’ll explore other examples of working with the Decimal type in Python.

Decimal data type in Python

The Decimal type is declared using the Decimal class of the decimal package. It is in the standard decimal library in Python. The declaration can be different, below you can see two ways – using the Float value type and using the String type.

Decimal(0.1) + Decimal(0.2) == Decimal(0.3)
# False

Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
# True

In the examples, the first case works like a normal value of type Float, but in the case of type String, the comparison condition will be fulfilled.  

Why is that?

By default, the value of the Decimal type is stored with precision up to 28 bits, hence the problem. However, if you declare the value through a string, then the digit capacity will be exactly that of the number in the passed string.

Decimal(0.1)
# Decimal('0.100000000000000005551115123125782702118158340…')

Decimal('0.1')
# Decimal('0.1')

We need to limit the number of decimal places. In Python, the settings for the Decimal type are set using context. In the example below, the maximum digit is set to six. This means that the result of arithmetic operations will be rounded to six decimal places. It is important only for arithmetic operations.

If you try to compare the result of an arithmetic operation with the Decimal type obtained from a value of the Float type, the result will be False. This is because the Decimal type is declared to be already an inaccurate value of the Float type. Below we can see that Decimal(0.3) has twenty-eight decimal places. Still, the comparison works correctly with strings again, because accuracy errors can be avoided by using a value of type string.

	from decimal import getcontext

	getcontext().prec = 6
	
	Decimal(0.1) + Decimal(0.2)
	# Decimal ('0.300000')

	Decimal(0.1) + Decimal(0.2) == Decimal(0.3)
	# False

	Decimal(0.3)
	# Decimal('0.2999999999999999888977697537484345957636833190…')

        Decimal(0.1) + Decimal(0.2) == Decimal('0.3')
	# True

By default, all arithmetic operations should be performed only if all values in the expression are of type Decimal. If you try to multiply a value of Decimal type and a value of Float type, an exception TypeError will be raised. Rounding to six is still observed here.

Decimal(0.33) * 0.13
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# TypeError: unsupported operand type(s) for 
# 'decimal.Decimal' and 'float'

Decimal(0.33) * Decimal(0.13)
# Decimal('0.0429000')

There is also a special context manager for Decimal. For example, you can set different limits on the number of decimal places for different code blocks within one script.

from decimal import localcontext

with localcontext() as context:
        context.prec = 2
        Decimal(0.1) + Decimal(0.2)

# Decimal('0.30')
	
	with localcontext() as context:
        context.prec = 6
        Decimal(0.1) + Decimal(0.2)

# Decimal('0.300000')

Let’s check the working speed with the Decimal type in Python programming language. For a million pairs of numbers, we will perform five arithmetic operations – addition, subtraction, multiplication, division, and ascension. Here is a link to the script.

On my laptop (Intel Core i5-1035G1 CPU @ 1.00GHz (4 cores, 8 threads), 16 GB RAM) the script with values of Float type finishes working in 2 seconds on average, and with values of Decimal type the script works for more than 2 minutes! The limitation of the number of decimal places did not affect the work speed.

Oh, great! We’ve got it all sorted out. The next question arises — how to store values of this type in the database?

Using floating-point numbers to represent monetary amounts is almost a crime

Robert C. Martin

Storing monetary values in a database

Fortunately, the Decimal type is represented in most famous databases – Oracle, MySQL, PostgreSQL, MariaDB, ClickHouse, etc.

Let’s choose PostgreSQL as the database for our service. It is an open-source solution that simply integrates with Python and has excellent speed performance. In this database, the Decimal type does not have a representation with the same name. But the PostgreSQL documentation says that you can use the built-in Numeric type. This type fully implements the Decimal type that we are already used to. Let’s try to work with the Numeric type!

Create a table with a field of type numeric(10, 2), where 10 is the total number of significant digits in the whole number, i.e. the number of digits up to both sides of the decimal point, and 2 is the number of significant digits after the decimal point.

CREATE TABLE public.test_decimal (val numeric(10, 2));

Then add two values, 10.123 and 10.129.

INSERT INTO test_decimal (val) VALUES (10.123);
INSERT INTO test_decimal (val) VALUES (10.129);

And check the table.

SELECT * FROM test_decimal;

/*
  val  
-------
 10.12
 10.13
(2 rows)
*/

As you can see, the values are rounded to two decimal places according to the arithmetic rule. Now update the field type and specify the number of significant digits after the decimal point as six – numeric(10, 6).

ALTER TABLE test_decimal ALTER COLUMN val TYPE numeric(10, 6);

Let’s add the value 10.987654321 and check the table.

INSERT INTO test_decimal (val) VALUES (10.987654321);
SELECT * FROM test_decimal;

/*
  val  
-----------
 10.120000
 10.130000
 10.987654
(3 rows)
*/

The digit capacity of previously added values is increased to six decimal places. The last added value is rounded to six decimal places. Once again, specify the number of significant digits after the decimal point as 2 – numeric(10, 2).

ALTER TABLE test_decimal ALTER COLUMN val TYPE numeric(10, 2);

And check the table again.

SELECT * FROM test_decimal;

/*
  val  
-------
 10.12
 10.13
 10.99
(3 rows)
*/

Previously added values are exactly rounded to two digits, without discarding everything after two digits. Again we return the field type numeric(10,6) and check the table.

ALTER TABLE test_decimal ALTER COLUMN val TYPE numeric(10, 6);
SELECT * FROM test_decimal;

/*
  val  
-----------
 10.120000
 10.130000
 10.990000
(3 rows)
*/

The number of decimal places increased again to 6 digits, but the last added value remained rounded.

Monetary values in API

The next question is — how to accept monetary values through the API? It is important to avoid rounding errors.  

JSON data format has a data type Numeric, which habitually allows storing real numbers. But a value of this type can be read in different ways. In the case of Python, a value of type Numeric from JSON can be read as Integer or as a Float.

import json
json_data = json.loads('{"value": 1}')

type(json_data.get('value'))
# <class 'int'>
json_data['value']
# 1

json_data = json.loads('{"value": 1.0}')
type(json_data.get('value'))
# <class 'float'>
json_data['value']
# 1.0

That’s why we consider Numeric is not suitable for passing monetary values to JSON. So, we can distinguish two methods.

The first one is to pass the value of the senior currency and the value of the junior currency separately. They must be accompanied by a third value – currency identifier. This can be a string value.

{
	“currencyCode”: string,
	“units”: string,
	“nanos”: string,
}

Another strategy is to pass a value of the String type and the currency identifier as a separate field. The advantage is that the String type value can be converted to the necessary type – Float, Decimal, and others. The received data structure in PayPal services is a perfect example. It shows that the monetary value is a data structure of two fields – a string value in number format (“value”) and a currency identifier (“currency”) :

{
	“amount”: {
	“currency”: “USD”,
	“value”:  “9.87”
}
}

An example from Shopify services:

{	
	… ,
	“total_price”: “421.88”,
	“total_tax”: “23.88”,
	“total_tip_received”: “0.00”,
	“total_line_items_price”: “398.00”,
        … ,
}

This one is from Twilio services. In this example, you can see that the monetary value is a structure of three fields – number type, base price, and current price. The currency identifier is present, but is stored outside the monetary value structure:

{
    "country": "United States",
    "iso_country": "US",
    "phone_number_prices": [
         {
           "number_type": "local",
           "base_price": "1.00",
           "current_price": "1.00"
         },
         {
           "number_type": "toll free",
           "base_price": "2.00",
           "current_price": "2.00"
         }
     ],
     "price_unit": "USD",
     "url": "https://pricing.twilio.com/v1/PhoneNumbers/Countries/US"
}

Conclusion

Now you know how to handle monetary values in Python. It requires precise arithmetic operations and an understanding of the peculiarities of data storage in computer memory.

After studying the capabilities of the Python programming language, we see that using the Decimal data type is the preferred choice for most tasks related to financial calculations. This data type minimizes rounding errors and ensures that the results are exactly as expected. However, the speed of performing operations with Decimal can be slower compared to other data types such as Float. This requires a balanced approach when selecting tools for developing financial systems. It is also important to consider the issues of storing monetary values in databases and properly transmitting them through APIs. 

Thus, understanding the specifics of working with monetary values in programming allows you to create more reliable and accurate systems that meet the high demands of the financial industry. We hope this article will help you choose effective solutions for handling monetary values in your application.

Looking for a skilled team to create a financial app? Contact us and let’s work together on your next project.

* * *

Written by Mary Moore and Vlad Boiko

  • Link copied!
alt

Subscribe to our blog

Once a month we will send you blog updates