DEV Community

Super Funicular
Super Funicular

Posted on

Python's round() and JavaScript's Math.round() Disagree on Half the Days in a Footage Summary

Here is a four-day summary of camera footage, in minutes, rounded to whole hours. Python and Node are given the same numbers and the same job:

# footage.py: hours of footage per day, rounded to whole hours for a summary
minutes = {"Mon": 150, "Tue": 90, "Wed": 210, "Thu": 30}

total = 0
for day, m in minutes.items():
    hours = m / 60
    print(day, hours, "->", round(hours))
    total += round(hours)
print("sum of rounded:", total, "| true total:", sum(minutes.values()) / 60)
Enter fullscreen mode Exit fullscreen mode
// footage.js: the same summary in a Node dashboard
const minutes = { Mon: 150, Tue: 90, Wed: 210, Thu: 30 };

let total = 0;
for (const [day, m] of Object.entries(minutes)) {
  const hours = m / 60;
  console.log(day, hours, "->", Math.round(hours));
  total += Math.round(hours);
}
console.log("sum of rounded:", total, "| true total:", Object.values(minutes).reduce((a, b) => a + b) / 60);
Enter fullscreen mode Exit fullscreen mode
python3 footage.py
node footage.js
Enter fullscreen mode Exit fullscreen mode
Mon 2.5 -> 2
Tue 1.5 -> 2
Wed 3.5 -> 4
Thu 0.5 -> 0
sum of rounded: 8 | true total: 8.0
Mon 2.5 -> 3
Tue 1.5 -> 2
Wed 3.5 -> 4
Thu 0.5 -> 1
sum of rounded: 10 | true total: 8
Enter fullscreen mode Exit fullscreen mode

Two of the four days disagree, and the totals differ by two hours. Neither program has a bug. They round halves by different rules, and every day in this example lands exactly on a half.

Python's total happens to match the true total of eight hours. The per-day values do not look like school rounding at all: Monday's 2.5 hours became 2 and Thursday's half hour became 0. If a reader checks one row by hand, Python looks wrong. If the reader adds up the rows, Node looks wrong.

Disclosure: I make an Android camera app. Nothing below uses its code or describes how it counts or stores anything. The minutes are typed in by hand, the scripts are a desktop Python and Node, and every output is pasted from the scripts shown. Versions:

Python 3.10.12
v22.23.4
Enter fullscreen mode Exit fullscreen mode

What round() is documented to do

Python's built-in docs state the rule plainly: "if two multiples are equally close, rounding is done toward the even choice". Their own examples are that round(0.5) and round(-0.5) are both 0, and round(1.5) is 2. This is often called banker's rounding, or half to even.

Running a few halves through both languages shows each rule:

# cases.py: what round() and format() do with halves
from decimal import Decimal

for x in (0.5, 1.5, 2.5, 3.5, -0.5, -2.5):
    print("round(%s) = %s" % (x, round(x)))
print("round(2.675, 2) =", round(2.675, 2))
print("stored value of 2.675:", Decimal(2.675))
print("f'{2.5:.0f}' =", f"{2.5:.0f}")
print("f'{2.675:.2f}' =", f"{2.675:.2f}")
Enter fullscreen mode Exit fullscreen mode
round(0.5) = 0
round(1.5) = 2
round(2.5) = 2
round(3.5) = 4
round(-0.5) = 0
round(-2.5) = -2
round(2.675, 2) = 2.67
stored value of 2.675: 2.67499999999999982236431605997495353221893310546875
f'{2.5:.0f}' = 2
f'{2.675:.2f}' = 2.67
Enter fullscreen mode Exit fullscreen mode
// cases.js: the JavaScript side of the same halves
for (const x of [0.5, 1.5, 2.5, 3.5, -0.5, -2.5]) {
  console.log(`Math.round(${x}) = ${Math.round(x)}`);
}
console.log("(2.675).toFixed(2) =", (2.675).toFixed(2));
console.log("(2.5).toFixed(0) =", (2.5).toFixed(0));
Enter fullscreen mode Exit fullscreen mode
Math.round(0.5) = 1
Math.round(1.5) = 2
Math.round(2.5) = 3
Math.round(3.5) = 4
Math.round(-0.5) = 0
Math.round(-2.5) = -2
(2.675).toFixed(2) = 2.67
(2.5).toFixed(0) = 3
Enter fullscreen mode Exit fullscreen mode

Read the two lists side by side:

  • Python's round() sends each exact half to the even neighbour: 0.5 to 0, 2.5 to 2, 3.5 to 4, and -2.5 to -2.
  • JavaScript's Math.round sent every positive half up, and sent -2.5 to -2, not -3. So in this run it does not round away from zero either. Halves went toward the larger number.
  • Python's string formatting with .0f printed 2 for 2.5, the same as round(). Switching from round() to an f-string does not change the rule.
  • JavaScript's toFixed(0) printed 3 for 2.5. So even inside one language, two built-in ways to round a half gave different answers here.

The case that is not about halves at all

round(2.675, 2) printed 2.67, and so did JavaScript's toFixed(2). That looks like half to even again, since 7 is odd and 8 is even. It is not. The docs give this exact example in a note: "This is not a bug: it’s a result of the fact that most decimal fractions can’t be represented exactly as a float."

The line in cases.py that prints the stored value shows why. Converting the float to Decimal writes out the binary value it really holds, and that value is a little below 2.675. There is no tie to break. The nearest two-place answer to the stored number is 2.67, and both languages return it.

So there are two separate effects, and they are easy to confuse:

  • A tie rule decides what happens when a value is exactly halfway. Python and JavaScript use different rules.
  • Float storage means many values you type as halves, at two or more decimal places, are not halfway in memory. No tie rule applies to them, because there is no tie.

Halves like 0.5, 1.5 and 2.5 are exact in binary, which is why the first list behaves according to the tie rule. Values like 2.675 are not, which is why they surprise people who already know about the tie rule.

Getting school rounding on purpose

The decimal module names its modes in words. ROUND_HALF_EVEN is documented as "Round to nearest with ties going to nearest even integer." and ROUND_HALF_UP as "Round to nearest with ties going away from zero."

ROUND_HALF_UP is the rule most people learned in school:

# half_up.py: school rounding with decimal, from the string and from the float
from decimal import Decimal, ROUND_HALF_UP

def half_up(x, places="1"):
    return Decimal(str(x)).quantize(Decimal(places), rounding=ROUND_HALF_UP)

for x in (0.5, 1.5, 2.5, -2.5):
    print(x, "->", half_up(x))
print("2.675 via str   ->", half_up(2.675, "0.01"))
print("2.675 via float ->", Decimal(2.675).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
Enter fullscreen mode Exit fullscreen mode
0.5 -> 1
1.5 -> 2
2.5 -> 3
-2.5 -> -3
2.675 via str   -> 2.68
2.675 via float -> 2.67
Enter fullscreen mode Exit fullscreen mode

Building the Decimal from str(x) gives 2.68, because the string is the short decimal 2.675. Building it from the float directly gives 2.67, because the decimal docs say construction from a float "performs an exact conversion", and the exact value is the one just below 2.675. If you want school rounding, the conversion step matters as much as the rounding mode.

That str() step only helps when the float already holds the decimal you meant. When the value comes out of arithmetic, str() faithfully prints the arithmetic's result:

# str_caveat.py: str() only helps when the float already holds the decimal you meant
from decimal import Decimal, ROUND_HALF_UP

x = 1.1 + 1.2 + 0.05
print("x =", x)
print("half up to 0.1 via str ->", Decimal(str(x)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP))
Enter fullscreen mode Exit fullscreen mode
x = 2.3499999999999996
half up to 0.1 via str -> 2.3
Enter fullscreen mode Exit fullscreen mode

On paper, 1.1 + 1.2 + 0.05 is 2.35, which rounds half up to 2.4. The float sum is 2.3499999999999996, str() keeps those digits, and the answer is 2.3. To avoid that, the inputs have to be decimals from the start, for example by reading them from text as Decimal('1.1') rather than converting floats after the fact.

The one-line half up that is not one

A common shortcut is math.floor(x + 0.5). It looks like school rounding:

# floor_trick.py: the one-line "round half up" that is not one
import math

for x in (2.5, -2.5, 0.49999999999999994):
    print(repr(x), "-> floor(x + 0.5) =", math.floor(x + 0.5))
Enter fullscreen mode Exit fullscreen mode
2.5 -> floor(x + 0.5) = 3
-2.5 -> floor(x + 0.5) = -2
0.49999999999999994 -> floor(x + 0.5) = 1
Enter fullscreen mode Exit fullscreen mode

It does round 2.5 to 3. It rounds -2.5 to -2, which is the JavaScript result, not school rounding. And the third line is the one that breaks it: the largest float below one half goes to 1. Adding 0.5 to it produces a sum that cannot be stored exactly, the sum is rounded up to 1.0 by the float addition itself, and the floor of 1.0 is 1. A value that is less than one half rounds up.

Why the default is half to even

Half to even is not an oddity in Python. It exists to stop rounding from pushing totals in one direction. This script rounds every half from 0.5 up to 99.5 both ways and compares the sums:

# bias.py: round every half from 0.5 to 99.5, then compare the sums
from decimal import Decimal, ROUND_HALF_UP

halves = [k + 0.5 for k in range(100)]
true_sum = sum(halves)
even_sum = sum(round(x) for x in halves)
up_sum = sum(int(Decimal(str(x)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) for x in halves)
print("true sum:", true_sum)
print("half-even sum:", even_sum, "| off by", even_sum - true_sum)
print("half-up sum:", up_sum, "| off by", up_sum - true_sum)
Enter fullscreen mode Exit fullscreen mode
true sum: 5000.0
half-even sum: 5000 | off by 0.0
half-up sum: 5050 | off by 50.0
Enter fullscreen mode Exit fullscreen mode

Half up adds half a unit for every tie, so with a hundred ties the total is off by fifty. Half to even sends half of them up and half down, and the errors cancel. That is the same effect as the footage example at the top, where Python's per-day numbers looked wrong but the total came out right.

That is a good property for sums, and a confusing one for a single displayed number. Which one you want depends on who reads the result.

Often the better fix: do not round floats at all

The footage data started as whole minutes. Keeping it in integers until the last step gives an exact answer with a rule you choose:

# integer.py: keep minutes as integers and round once, exactly
minutes = {"Mon": 150, "Tue": 90, "Wed": 210, "Thu": 30}

for day, m in minutes.items():
    print(day, m, "min ->", (m + 30) // 60, "h")
total = sum(minutes.values())
print("total:", total, "min ->", (total + 30) // 60, "h")
Enter fullscreen mode Exit fullscreen mode
Mon 150 min -> 3 h
Tue 90 min -> 2 h
Wed 210 min -> 4 h
Thu 30 min -> 1 h
total: 480 min -> 8 h
Enter fullscreen mode Exit fullscreen mode

(m + 30) // 60 is half up for minutes that are zero or positive, with no float involved. It is not a general rounding function, and it would need a different formula for negative values, but footage durations are not negative. The total is rounded once, from the exact sum, instead of being built from rounded parts.

Checklist

  • Decide which tie rule each number needs, and write it down in the code, not in a comment about what round() is assumed to do.
  • When two programs show the same number, check that they round it the same way. Python's round() and JavaScript's Math.round do not.
  • For display rules like school rounding, use decimal with ROUND_HALF_UP, and build the Decimal from text, not from a float.
  • Do not use math.floor(x + 0.5) as a rounding function.
  • If the data starts as whole units, such as seconds, minutes or bytes, do the arithmetic in integers and round once at the end.
  • For totals, round the sum, not the parts. If the parts must also be shown, expect them to not add up exactly, and say so in the report.

Things I did not test

  • Newer Python. I ran 3.10. The quotes come from the current online documentation. The behaviour matched what I saw, but I did not run a newer interpreter.
  • Browsers. The JavaScript ran in Node. I did not run these lines in a browser.
  • Other tools. Spreadsheets, databases and other languages each have their own rounding functions and rules. I did not test any of them, and I make no claim about what they do.
  • NumPy and pandas. Their rounding functions are separate from the built-in round(). Nothing here covers them.

Sources. Quotes are from the Python documentation pages Built-in functions (the round entry) and decimal — Decimal fixed-point and floating-point arithmetic. Outputs are pasted unedited from the scripts above.

If you want an old Android phone recording in the background so there is footage for a summary like this, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.

When durations come in half-hour steps, as all four days here do, every odd half hour is a tie, and the tie rule, not the footage, decides the total a reader sees.

Top comments (0)