OneBug All write-ups

Python ·

Python round(2.5) Returns 2 (Banker's Rounding Explained)

Your leaderboard math checked out, and round() still shorted two teams by a point. Python 3 rounds .5 toward even, not up. Here is the fix.

Test yourself before reading - the write-up below gives it away.

Spot the bug Python
1
# Build the leaderboard: each team's average score, in whole points.
2
scores = {"red": [2, 3], "blue": [3, 4], "green": [4, 5]}
3
board = {}
4
for team, pts in scores.items():
5
    avg = sum(pts) / len(pts)
6
    board[team] = round(avg)
7
ranked = dict(sorted(board.items(), key=lambda kv: -kv[1]))
8
print(ranked)
9
# expected: {'green': 5, 'blue': 4, 'red': 3}
10
# actual:   {'blue': 4, 'green': 4, 'red': 2}  (!)

The coach held the printed standings sheet up for the team: blue and green tied for first at four points, red trailing two back in last. A parent working the same two rounds of scores on a pocket calculator got green alone in first, five clear of blue's four. The coach's sheet was self-consistent and wrong.

Here is the script behind that sheet, one average per team, ranked high to low:

# Build the leaderboard: each team's average score, in whole points.
scores = {"red": [2, 3], "blue": [3, 4], "green": [4, 5]}
board = {}
for team, pts in scores.items():
    avg = sum(pts) / len(pts)
    board[team] = round(avg)
ranked = dict(sorted(board.items(), key=lambda kv: -kv[1]))
print(ranked)
# expected: {'green': 5, 'blue': 4, 'red': 3}
# actual:   {'blue': 4, 'green': 4, 'red': 2}  (!)

Why does Python's round(2.5) return 2 instead of 3?

Line 6's round(avg) uses Python 3's tie-breaking rule, round half to even: a value sitting squarely between two integers rounds to whichever integer is even, so round(2.5) gives 2 and round(4.5) gives 4, while round(3.5) still gives 4 because 4 is already even. For half-up rounding, use math.floor(avg + 0.5) or decimal.Decimal with ROUND_HALF_UP.

Round half to even, straight from the Python docs

Red's average is 2.5, blue's is 3.5, green's is 4.5 - every team lands on a tie, and line 6 is where each one gets settled. Python's own reference for round() states the rule plainly:

"If two multiples are equally close, rounding is done toward the even choice (so, for example, both round(0.5) and round(-0.5) are 0, and round(1.5) is 2)." - Python docs: round()

Red's 2.5 rounds down to the even 2. Green's 4.5 rounds down to the even 4. Blue's 3.5 rounds up, also to the even 4 - blue is the only team whose tie-break happens to match what a person expects, which is why the bug reads as "two teams lost a point" instead of "the rounding is broken." Line 7, the sorted(..., key=lambda kv: -kv[1]) call, draws the eye with its brackets and its lambda, and it is doing its job correctly: it ranks the numbers it was handed, and by the time it runs those numbers are already wrong.

None of the three averages are float noise, either. 2.5, 3.5, and 4.5 are all exact in binary, so this is genuinely round-half-to-even at work, not the float-representation surprise that trips up 0.1 + 0.2 in JavaScript. The two traps can still stack: round(2.675, 2) gives 2.67, not 2.68, because 2.675 itself has no exact binary value, so a number that looks safely off the tie can still misround for the other reason entirely.

The bug hides because most test fixtures do not land on exact halves. Change red's scores to [2, 4] and the average is 3.0, no tie, no surprise; the round-half-to-even rule only fires on values that are precisely .5 after the division, which happens routinely with small integer point totals and average-of-two-things math, and rarely with three-round tournaments or odd-numbered samples. A team scoring engine tested with three rounds per team can ship for months before two rounds produce the exact tie that exposes it.

The scoreboard that shorted two teams by a point

A weekend trivia league runs the same script after every round: sum each team's points, divide by rounds played, round to a whole number, post the board. For most weekends the averages land on ordinary decimals and nobody thinks about round() at all. Then a slow weekend gives two teams averages that land square on a tie, and the posted board reorders the standings - first place turns into a tie, and a team sitting in solid last only needed one more real point to catch up. The league runs the numbers again by hand before the trophy gets awarded, because a parent's manual tally does not agree with the printed sheet, and the printed sheet is the one that is wrong. Nothing about the script crashed or logged an error; it did exactly what round() promises, on values nobody thought to check for landing on a tie.

The fix: round half up, and the naive fix that breaks on negatives

For scores and other non-negative half-up rounding, floor the value after adding 0.5:

import math
board[team] = math.floor(avg + 0.5)
# {'green': 5, 'blue': 4, 'red': 3}

For money or anything where "half up" needs to be exact and auditable, use Decimal with an explicit rounding mode instead of round():

from decimal import Decimal, ROUND_HALF_UP
Decimal(str(avg)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)

The tempting shortcut, seen in more than one rounding tutorial, is int(avg + 0.5) instead of math.floor(avg + 0.5). It looks like the same trick with fewer characters, and on positive scores it is: int(2.5 + 0.5) gives 3. It stops being the same trick the moment a negative number shows up, because int() truncates toward zero while floor() always rounds down. int(-2.7 + 0.5) gives -2; the mathematically nearer integer is -3, and math.floor(-2.7 + 0.5) gets there. A leaderboard of points never goes negative, but a temperature delta, an account balance, or a scored review does, and that is where the truncating version starts silently handing back the wrong integer.

Where round() flips a ranking in your codebase

There is no dedicated lint rule for this, because round() is correct almost everywhere it appears; the trap is specific to values that can land precisely on a half. Grep for every call and read the ones whose input is an average, a midpoint, or a split total:

grep -rnE '\bround\(' --include=*.py .

For each hit, ask whether the value being rounded is a division result that can equal n.5 for an integer n. Averages of small integer counts are the most common source, and sum(x) / len(x) on any list of length 2 hits it on roughly half its inputs. numpy.round() inherits the same rule; its own docs state that "for values exactly halfway between rounded decimal values, NumPy rounds to the nearest even value," so switching arrays does not switch behavior. Python 2's round() rounded halves away from zero; Python 3 changed the built-in itself to round-half-to-even, so code ported from Python 2 without a rounding review is worth a second look wherever it ranks, bills, or grades something.

Related bugs

The same "the interpreter followed its own rule to the letter, and it still surprised you" shape covers why is silently disagrees with == on integers above 256 and the mutable default argument that remembers every caller's edits. More Python traps live on the Python bug hub, the full set at bug write-ups.

Next time a printed scoreboard and a pocket calculator disagree, do not recheck the addition. Check what happens right on the halfway point.