
A Slack notification went off the moment I got to the office. It was K from finance.
“Company A’s settlement for this month is one won off from the tax invoice. Did anything change on your end?”
Nothing had. Not a single thing. And yet one won was different.
I spent the whole afternoon on it. The cause was anticlimactic. Integer division was silently dropping the decimals. The fix was one line. Looking a little further into the data, the dropped decimals mattered less than where 0.5 was being sent.
#round(0.5) is not always 1
The rounding we learned in school is round half up. 5 and above goes up, 4 and below goes down. Under that rule 0.5 becomes 1. Through Python 2, it did.
# Python 2
round(0.5) # → 1.0
round(1.5) # → 2.0
round(2.5) # → 3.0
Run it in Python 3 and the result changes.
# Python 3
round(0.5) # → 0
round(1.5) # → 2
round(2.5) # → 2
0.5 becomes 0, and 2.5 becomes 2. What on earth happened in Python 3?
#Banker’s rounding
This behavior has a name. Banker’s rounding, also called round half to even. At an exact midpoint like 0.5, instead of always rounding up, it rounds to the nearest even number.
- 0.5 → 0 (nearest even is 0)
- 1.5 → 2 (nearest even is 2)
- 2.5 → 2 (nearest even is 2)
- 3.5 → 4 (nearest even is 4)
Round half up is fine on any single transaction. It only turns 0.5 into 1. But apply it across millions of transactions and the story changes. Always turning 0.5 into 1 builds up an upward bias, a little at a time. The more transactions and the larger the amounts, the harder that bias is to ignore.
Banker’s rounding is the rule that answers this. If the midpoints are evenly distributed, over the long run the ups and downs at 0.5 come out half and half, so the bias cancels out. When the IEEE 754 floating-point standard was established in 1985, this became the default rounding mode for floating-point arithmetic, and most hardware has followed the standard since. The default of a language’s round() function is a separate matter.
The origin of the name banker’s rounding is unclear, and I couldn’t find evidence it was ever an official standard in banking. The name says banker, but the source may not be finance.
In Korean it goes by the Sino-Korean name osaoip (五捨五入). Where sasaoip (四捨五入), the Sino-Korean name for round half up, means drop 4 and carry 5, osaoip means the same 5 is sometimes dropped and sometimes carried, dropped when the digit before it is even and carried when it’s odd.
Python 3 switched for the same reason. Python 2 rounded away from zero, which is round half up, and Python 3 changed to the nearest even number because it’s statistically fairer and matches IEEE 754. My guess is that, with Python used heavily for scientific computing and data analysis, it chose the side that limits bias in bulk computation.
#Defaults differ by language
In Java and Kotlin, Math.round() gives the same result as round half up for positive numbers but not for negative ones, and that story comes later. Kotlin has one more trap: kotlin.math.round() sends midpoints to the even side and roundToInt() sends them toward positive infinity, so within the same language one function name changes the policy. The difference shows up first in BigDecimal, which is used for precise decimal arithmetic. Its setScale() refuses to run unless you state the rounding mode wherever rounding is needed. BigDecimal("2.0").setScale(0) has nothing to drop, so it passes, and 2.5 throws.
BigDecimal("2.5").setScale(0)
// → ArithmeticException: Rounding necessary
// (it can't tell whether to carry or drop the 0.5)
That’s not a bug. It’s by design. setScale() forces the developer to state the rounding mode at the moment a value gets cut, so you pass a RoundingMode with it.
import java.math.BigDecimal
import java.math.RoundingMode
BigDecimal("2.5").setScale(0, RoundingMode.HALF_UP) // 3, round half up
BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN) // 2, banker's rounding
BigDecimal("2.5").setScale(0, RoundingMode.DOWN) // 2, always drop
Same 2.5, different result depending on which RoundingMode you pick. Where Python settled on a default, setScale() forces the choice wherever a value gets cut. Whether there’s a default depends on the API more than the language: in the same BigDecimal, MathContext defaults to HALF_UP, and Kotlin’s / operator on BigDecimal uses HALF_EVEN without asking.
Each language gives a different answer, and the difference comes from each one’s design.
| Language and method | Default behavior |
|---|---|
Python 3 round() | banker’s rounding, HALF_EVEN |
Java and Kotlin Math.round() | nearest integer, ties toward positive infinity |
Java and Kotlin BigDecimal.setScale() | must be stated when rounding is needed, exception if omitted |
JavaScript Math.round() | nearest integer, ties toward positive infinity |
Math.round() in JavaScript, Java, and Kotlin isn’t strictly round half up: it matches for positive numbers and differs for negative ones. In all three, Math.round(-2.5) returns -2, not -3, because they round to the nearest integer and, at an exact midpoint, pick the side toward positive infinity. Run the same value through BigDecimal("-2.5").setScale(0, RoundingMode.HALF_UP) and you get -3. In a settlement system that handles negative amounts, like refunds and adjustments, this difference becomes a problem.
#Where the half cent goes
Take a dollar-based scenario, where decimals are common. Company N pays its ad agency a 15% commission on monthly ad spend. This month’s spend came to $18,336.30.
Commission = $18,336.30 × 0.15 = $2,750.445
That amount only comes out when the math is done in decimal. Do the same math with Python’s binary float and you don’t get 2,750.445. Neither 18,336.30 nor 0.15 has an exact binary representation, so the product is 2,750.4449999…, which never reaches the half-cent boundary. round(fee, 2) returns 2,750.44, and applying either round half up or banker’s rounding to that value also gives 2,750.44. The round(2.5) examples earlier behaved as described only because 0.5 and 2.5 are exact in binary.
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN
fee = 18336.30 * 0.15
print(f"{fee:.20f}") # 2750.44499999999970896170
print(round(fee, 2)) # 2750.44
fee = Decimal("18336.30") * Decimal("0.15")
print(fee) # 2750.4450
print(fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) # 2750.45
print(fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)) # 2750.44
So before you pick a rounding policy, pick the type you compute money in. Use a type that handles decimals exactly, Decimal in Python or BigDecimal in Java and Kotlin, and choose the rounding policy on top of it. Build the values from strings, as in Decimal("18336.30"), without passing through a float. Decimal(18336.30) carries the float’s error along with it.
The exact amount, $2,750.445, has three decimal places. Dollars carry two. Somebody has to decide where that half cent goes.
| Policy | Billed amount |
|---|---|
round half up, HALF_UP | $2,750.45 |
banker’s rounding, HALF_EVEN | $2,750.44 |
drop, DOWN | $2,750.44 |
Round half up carries it. Banker’s rounding drops it because the preceding 4 is even. Drop always drops. Same amount, different result depending on the policy.
$0.01. On its own it doesn’t feel like much. With dozens of clients and the months stacking up, the annual number is hard to ignore. Which one is right is a question the code doesn’t answer. There’s nothing wrong with the logic. The difference accumulates until the finance team finds it as a mismatch between the settlement system and the ledger.
What if the payment system and the settlement system each have their own rounding policy? What if the frontend and the backend use different policies, so the amount the user console and the admin show differs from the amount the API computes? Look at any single piece of code and its unit tests and there’s no error. That’s the gap where an invisible bug gets amplified.
#Policy is set outside the code
As I write this in 2026, rounding has become a hot issue outside the code too. In November 2025 the United States struck its last circulating penny, the one-cent coin. As the supply of pennies shrinks, rounding policy for cash transactions is turning into a business and legal matter. Square added a beta feature to its POS that rounds cash transactions to the nearest 5 cents, and McDonald’s introduced cash rounding policies by store. Some chains round down in the customer’s favor and some round up.
States are responding differently too. Indiana passed a rounding bill, Connecticut recommends always rounding down, and on March 23 Washington signed a bill that rounds the last digit of a cash transaction to the nearest 5 cents, so last digits of 1, 2, 6, and 7 round down and 3, 4, 8, and 9 round up. Electronic payments are still settled to the exact cent, but there’s no unified federal standard for cash yet.
In a settlement system, rounding isn’t a language default. It’s a policy to be discussed. Which method to use is set by the terms in the contract, the tax invoice rules, and industry practice. That’s a business decision, not a technical spec, and the decision has to land in the code as policy. Leave a comment with it.
// 2024-03-15 agreed with finance. Per contract clause 4.2.
val ROUNDING_MODE = RoundingMode.HALF_UP
Leave it unstated and you may get another Slack message. Next time it may not be one won.
#References
- Shopify Engineering, Bound to Round: 8 Tips for Dealing with Hanging Pennies: a practical guide that covers rounding bias, stakeholder communication, and code consistency.
- Founding Minds, Rounding Numbers in the Financial Domain: how a billing project settled on a rounding strategy, with IFRS and GAAP context.
- Alipay+, Banker’s Rounding: why Alipay+ adopted banker’s rounding, with the deviation from standard rounding in numbers.
- Gmarket Tech Blog, BigDecimal A to Z: a reference for using
BigDecimalandRoundingModein Java and Kotlin.