How to Round Numbers in Python

How to Round Numbers in Python

by David Amos Jul 03, 2023 best-practices intermediate python

With many businesses turning to Python’s powerful data science ecosystem to analyze their data, understanding how to avoid introducing bias into datasets is absolutely vital. If you’ve studied some statistics, then you’re probably familiar with terms like reporting bias, selection bias, and sampling bias. There’s another type of bias that plays an important role when you’re dealing with numeric data: rounding bias.

Understanding how rounding works in Python can help you avoid biasing your dataset. This is an important skill. After all, drawing conclusions from biased data can lead to costly mistakes.

In this tutorial, you’ll learn:

  • Why the way you round numbers is important
  • How to round a number according to various rounding strategies
  • How to implement each strategy in pure Python
  • How rounding affects data and which rounding strategy minimizes this effect
  • How to round numbers in NumPy arrays and pandas DataFrames
  • When to apply different rounding strategies

You won’t get a treatise on numeric precision in computing, although you’ll touch briefly on the subject. Only a familiarity with the fundamentals of Python is necessary, and the math should feel familiar if you’ve had high school algebra.

You’ll start by looking at Python’s built-in rounding mechanism.

Python’s Built-in round() Function

Python has a built-in round() function that takes two numeric arguments, n and ndigits, and returns the number n rounded to ndigits. The ndigits argument defaults to zero, so leaving it out results in a number rounded to an integer. As you’ll see, round() may not work quite as you expect.

The way most people are taught to round a number goes something like this:

  • Round the number n to p decimal places by first shifting the decimal point in n by p places. To do that, multiply n by 10ᵖ (10 raised to the p power) to get a new number, m.

  • Then look at the digit d in the first decimal place of m. If d is less than 5, round m down to the nearest integer. Otherwise, round m up.

  • Finally, shift the decimal point back p places by dividing m by 10ᵖ.

It’s an algorithm! For example, the number 2.5 rounded to the nearest whole number is 3. The number 1.64 rounded to one decimal place is 1.6.

Now open up an interpreter session and round 2.5 to the nearest whole number using Python’s built-in round() function:

Python
>>> round(2.5)
2

Gasp!

Check out how round() handles the number 1.5:

Python
>>> round(1.5)
2

So, round() rounds 1.5 up to 2, and 2.5 down to 2!

Before you go raising an issue on the Python bug tracker, rest assured you that round(2.5) is supposed to return 2. There’s a good reason why round() behaves the way it does.

In this tutorial, you’ll learn that there are more ways to round a number than you might expect, each with unique advantages and disadvantages. round() behaves according to a particular rounding strategy—which may or may not be the one you need for a given situation.

You might be wondering, Can the way I round numbers really have that much of an impact? Next up, take a look at just how extreme the effects of rounding can be.

How Much Impact Can Rounding Have?

Suppose you have an incredibly lucky day and find $100 on the ground. Rather than spending all your money at once, you decide to play it smart and invest your money by buying some shares of different stocks.

The value of a stock depends on supply and demand. The more people there are who want to buy a stock, the more value that stock has, and vice versa. In high-volume stock markets, the value of a particular stock can fluctuate on a second-by-second basis.

Now run a little experiment. Pretend the overall value of the stocks that you purchased fluctuates by some small random number each second, say between $0.05 and -$0.05. This fluctuation may not necessarily be a nice value with only two decimal places. For example, the overall value may increase by $0.031286 one second and decrease the next second by $0.028476.

You don’t want to keep track of your value to the fifth or sixth decimal place, so you decide to chop everything off after the third decimal place. In rounding jargon, this is called truncating the number to the third decimal place. There’s some error to be expected here, but by keeping three decimal places, this error couldn’t be substantial. Right?

To run your experiment using Python, you start by writing a truncate() function that truncates a number to three decimal places:

Python
>>> def truncate(n):
...     return int(n * 1000) / 1000
...

The truncate() function first shifts the decimal point in the number n three places to the right by multiplying n by 1000. You get the integer part of this new number with int(). Finally, you shift the decimal point three places back to the left by dividing n by 1000.

Next, you define the initial parameters of the simulation. You’ll need two variables: one to keep track of the actual value of your stocks after the simulation is complete and one for the value of your stocks after you’ve been truncating to three decimal places at each step.

Start by initializing these variables to 100:

Python
>>> actual_value, truncated_value = 100, 100

Now run the simulation for 1,000,000 seconds (approximately 11.5 days). For each second, generate a random value between -0.05 and 0.05 with the uniform() function in the random module, and then update actual and truncated:

Python
>>> import random
>>> random.seed(100)

>>> for _ in range(1_000_000):
...     delta = random.uniform(-0.05, 0.05)
...     actual_value = actual_value + delta
...     truncated_value = truncate(truncated_value + delta)
...

>>> actual_value
96.45273913513529

>>> truncated_value
0.239

The meat of the simulation takes place in the for loop, which loops over the range of numbers between 0 and 999,999. The value taken from range() at each step is stored in the variable _, which you use here because you don’t actually need this value inside of the loop.

At each step of the loop, random.uniform() generates a new random number between -0.05 and 0.05, which you assign to the variable delta. You calculate the new value of your investment by adding delta to actual_value, and then you calculate the truncated total by adding delta to truncated_value and then truncating this value with truncate().

As you can see by inspecting the actual_value variable after running the loop, you only lost about $3.55. However, if you’d been looking at truncated_value, you’d have thought that you’d lost almost all of your money!

Ignoring for the moment that round() doesn’t behave quite as you expect, go ahead and try rerunning the simulation. You’ll use round() this time to round to three decimal places at each step, and you’ll seed the simulation again to get the same results as before:

Python
>>> random.seed(100)
>>> actual_value, rounded_value = 100, 100

>>> for _ in range(1_000_000):
...     delta = random.uniform(-0.05, 0.05)
...     actual_value = actual_value + delta
...     rounded_value = round(rounded_value + delta, 3)
...

>>> actual_value
96.45273913513529

>>> rounded_value
96.258

What a difference!

Shocking as it may seem, this exact error caused quite a stir in the early 1980s when the system designed for recording the value of the Vancouver Stock Exchange truncated the overall index value to three decimal places instead of rounding. Rounding errors have swayed elections and even resulted in the loss of life.

How you round numbers is important, and as a responsible developer and software designer, you need to know what the common issues are and how to deal with them. So it’s time to dive in and investigate what the different rounding methods are and how you can implement each one in pure Python.

Basic but Biased Rounding Strategies

There’s a plethora of rounding strategies, each with advantages and disadvantages. In this section, you’ll learn about some of the simplest techniques and see how they can influence your data. Later, you’ll learn about better methods.

Truncating

The simplest, albeit crudest, method for rounding a number is to truncate the number to a given number of digits. When you truncate a number, you replace each digit after a given position with 0. Here are some examples:

Value Truncated To Result
12.345 Tens place 10
12.345 Ones place 12
12.345 Tenths place 12.3
12.345 Hundredths place 12.34

You’ve already seen one way to implement this in the truncate() function from the How Much Impact Can Rounding Have? section. In that function, the input number was truncated to three decimal places by:

  • Multiplying the number by 1000 to shift the decimal point three places to the right
  • Taking the integer part of that new number with int()
  • Shifting the decimal place three places back to the left by dividing by 1000

You can generalize this process by replacing 1000 with the number 10ᵖ. That’s 10 raised to the p power, where p is the number of decimal places to truncate to:

Python
# rounding.py

def truncate(n, decimals=0):
    multiplier = 10**decimals
    return int(n * multiplier) / multiplier

In this version of truncate(), the second argument defaults to 0 so that if you don’t pass a second argument to the function, then truncate() returns the integer part of whatever number you passed to it.

The truncate() function works well for both positive and negative numbers:

Python
>>> from rounding import truncate

>>> truncate(12.5)
12.0

>>> truncate(-5.963, 1)
-5.9

>>> truncate(1.625, 2)
1.62

You can even pass a negative number to decimals to truncate digits to the left of the decimal point:

Python
>>> truncate(125.6, -1)
120.0

>>> truncate(-1374.25, -3)
-1000.0

When you truncate a positive number, you’re rounding it down. Likewise, truncating a negative number rounds that number up. In a sense, truncation is a combination of rounding methods depending on the sign of the number that you’re rounding.

Next up, you’ll take a look at each of these rounding methods individually, starting with rounding up.

Rounding Up

The second rounding strategy that you’ll look at is called rounding up. This strategy always rounds a number up to a specified number of digits. The following table summarizes this strategy:

Value Round Up To Result
12.345 Tens place 20
12.345 Ones place 13
12.345 Tenths place 12.4
12.345 Hundredths place 12.35

To implement the rounding up strategy in Python, you’ll use the ceil() function from the math module.

The ceil() function gets its name from the mathematical term ceiling, which describes the nearest integer that’s greater than or equal to a given number.

Every number that’s not an integer lies between two consecutive integers. For example, the number 3.7 lies in the interval between 3 and 4. The ceiling is the greater of the two endpoints of the interval. The lesser of the two endpoints is the floor. Thus, the ceiling of 3.7 is 4, and the floor of 3.7 is 3.

In mathematics, a special function called the ceiling function maps every number to its ceiling. To allow the ceiling function to accept integers, the ceiling of an integer is defined to be the integer itself. So the ceiling of the number 2 is 2.

In Python, math.ceil() implements the ceiling function and always returns the nearest integer that’s greater than or equal to its input:

Python
>>> import math

>>> math.ceil(3.7)
4

>>> math.ceil(2)
2

>>> math.ceil(-0.5)
0

Notice that the ceiling of -0.5 is 0, not -1. This makes sense because 0 is the nearest integer to -0.5 that’s greater than or equal to -0.5.

Now write a function called round_up() that implements the rounding up strategy:

Python
# rounding.py

import math

# ...

def round_up(n, decimals=0):
    multiplier = 10**decimals
    return math.ceil(n * multiplier) / multiplier

You may notice that round_up() looks a lot like truncate(). First, you shift the decimal point in n the correct number of places to the right by multiplying n by 10**decimals. You round this new value up to the nearest integer using math.ceil(), and then you shift the decimal point back to the left by dividing by 10**decimals.

This pattern of shifting the decimal point, applying some rounding method to round to an integer, and then shifting the decimal point back will come up over and over again as you investigate more rounding methods. This is, after all, the mental algorithm that humans use to round numbers by hand.

Take a look at how well round_up() works for different inputs:

Python
>>> from rounding import round_up

>>> round_up(1.1)
2.0

>>> round_up(1.23, 1)
1.3

>>> round_up(1.543, 2)
1.55

Just like with truncate(), you can pass a negative value to decimals:

Python
>>> round_up(22.45, -1)
30.0

>>> round_up(1352, -2)
1400.0

When you pass a negative number to decimals, the number in the first argument of round_up() is rounded to the correct number of digits to the left of the decimal point.

Take a guess at what round_up(-1.5) returns:

Python
>>> round_up(-1.5)
-1.0

Is -1.0 what you expected?

If you examine the logic used in defining round_up()—in particular, the way the math.ceil() function works—then it makes sense that round_up(-1.5) returns -1.0. However, some people naturally expect symmetry around zero when rounding numbers, so that if 1.5 gets rounded up to 2, then -1.5 should get rounded up to -2.

It’s useful to establish some terminology. For this tutorial, you’ll use the terms round up and round down according to the following diagram:

Round up to the right and down to the left
Round up to the right and down to the left. (Image: David Amos)

Rounding up always rounds a number to the right on the number line, and rounding down always rounds a number to the left on the number line.

Rounding Down

The counterpart to rounding up is the rounding down strategy, which always rounds a number down to a specified number of digits. Here are some examples illustrating this strategy:

Value Rounded Down To Result
12.345 Tens place 10
12.345 Ones place 12
12.345 Tenths place 12.3
12.345 Hundredths place 12.34

To implement the rounding down strategy in Python, you can follow the same algorithm that you used for both trunctate() and round_up(). First shift the decimal point, then round to an integer, and finally shift the decimal point back.

In round_up(), you used math.ceil() to round up to the ceiling of the number after shifting the decimal point. For the rounding down strategy, though, you need to round to the floor of the number after shifting the decimal point.

Luckily, the math module has a floor() function that returns the floor of its input:

Python
>>> import math

>>> math.floor(1.2)
1

>>> math.floor(-0.5)
-1

Here’s the definition of round_down():

Python
# rounding.py

# ...

def round_down(n, decimals=0):
    multiplier = 10**decimals
    return math.floor(n * multiplier) / multiplier

That looks just like round_up(), except you’ve replaced math.ceil() with math.floor().

You can test round_down() on a few different values:

Python
>>> from rounding import round_down

>>> round_down(1.5)
1.0

>>> round_down(1.37, 1)
1.3

>>> round_down(-0.5)
-1.0

The effects of round_up() and round_down() can be pretty extreme. By rounding the numbers in a large dataset up or down, you could potentially remove a ton of precision and drastically alter computations made from the data.

Before you discuss any more rounding strategies, take a moment to consider how rounding can make your data biased.

Interlude: Rounding Bias

You’ve now seen three rounding methods: truncate(), round_up(), and round_down(). All three of these techniques are rather crude when it comes to preserving a reasonable amount of precision for a given number.

There’s one important difference in truncate() vs round_up() and round_down() that highlights an important aspect of rounding: symmetry around zero.

Recall that round_up() isn’t symmetric around zero. In mathematical terms, a function f(x) is symmetric around zero if, for all values of x, f(x) + f(-x) = 0. For example, round_up(1.5) returns 2, but round_up(-1.5) returns -1. The round_down() function isn’t symmetric around 0, either.

On the other hand, the truncate() function is symmetric around zero. This is because, after shifting the decimal point to the right, truncate() chops off the remaining digits. When the initial value is positive, this amounts to rounding the number down. Negative numbers are rounded up. So, truncate(1.5) returns 1, and truncate(-1.5) returns -1.

The concept of symmetry introduces the notion of rounding bias, which describes how rounding affects numeric data in a dataset.

The rounding up strategy has a round toward positive infinity bias, because it’ll always round the value up in the direction of positive infinity. Likewise, the rounding down strategy has a round toward negative infinity bias.

The truncation strategy exhibits a round toward negative infinity bias on positive values and a round toward positive infinity for negative values. Rounding functions with this behavior are said to have a round toward zero bias, in general.

To see how this works in practice, consider the following list of floats:

Python
>>> numbers = [1.25, -2.67, 0.43,