Namsang Labs
Deep Dive · #settlement #rounding #python #kotlin

Rounding Is Policy

· Sangkyoon Nam

A toy train carrying a 0.5 block stands before a switch where the track splits toward 0 and 1, and the switch lever is tied to a contract with a pen on it

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 methodDefault 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.

PolicyBilled 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

Share this post