Ten identical lines, each 1,006 won, with 10% VAT. Two correct-looking implementations, two different invoices:
| where the VAT is rounded | arithmetic | VAT on the invoice |
|---|---|---|
| per line, then summed |
round(100.6) = 101, ร 10 lines |
1,010 |
| once, on the invoice total | round(10,060 ร 0.10) = 1,006 |
1,006 |
Four won. Nobody notices four won, and that is the problem: the customer's copy, your copy, and the tax filing each add up on a different boundary, and no error ever fires.
I write small commerce pages โ a menu, a price list, an order form, a pay-what-the-page-says button โ and I have shipped each of these mistakes once. So this time I sat down and measured the whole family of them instead of trusting folklore: Python 3.12.16, V8 (Chrome 155), SQLite 3.46.1 in process, and Cloudflare D1 read-only, over a few hundred thousand generated carts and a few million prices. Every number below came out of this run.
The folklore was wrong about the interesting part.
The float is not the thief
Every tutorial blames binary floating point. Here is what binary floating point actually does at the scale a shop operates at.
| measurement | result |
|---|---|
1,000,000 line amounts of 199.9 added in a Python float
|
199900000.00360635 โ error 0.0036 won, 1.8 ร 10โปโน % of the total |
20,000 carts ร 10 lines: float sum vs exact Decimal sum |
max error 1 ร 10โปยนยน won |
10,000,000 additions of 0.1
|
999999.9998389754, error โ0.00016 won
|
| 20,000 carts ร 10 lines: float per-line VAT vs exact integer per-line VAT (V8) | 0 / 20,000 differences |
| same carts: invoice-level VAT, float vs exact integer (V8) | 0 / 20,000 differences |
The error grows, but at the tenth decimal place. To lose one won to accumulation you need on the order of ten million additions of a value that is not representable โ at which point you have other problems.
That does not make floats safe. It relocates the danger: floating point does not lose money by drifting; it loses money at the boundary โ where the fraction is cut, and by which rule. Those are one-line decisions, and they are wrong in ways the drift never is.
Thief one: truncation looks like rounding
int(1.15 * 100) # 114
Not 115. 1.15 * 100 is 114.99999999999999, so truncation returns one below the amount. The same expression in JavaScript is Math.trunc(1.15 * 100) === 114; in SQL it is CAST(1.15 * 100 AS INTEGER) = 114.
This is not about 1.15. Over every amount from 0.01 to 10,000.00, 65,624 of 1,000,000 (6.56%) have a binary representation just below their own last cent:
| cents | amount * 100 |
truncated |
|---|---|---|
| 29 | 28.999999999999996 |
28 |
| 57 | 56.99999999999999 |
56 |
| 113 | 112.99999999999999 |
112 |
| 115 | 114.99999999999999 |
114 |
Apply the same instinct to a tax rate, which is how it usually gets written:
tax = int(price * 0.10) # "10%, drop the fraction"
For integer prices from 1 to 1,000,000 won this differs from a correct half-up rounding on 500,000 prices โ exactly half โ and under-collects 500,000 won in total. Restricted to realistic menu prices (50โ10,000 won) it is still 4,975 of 9,951 (49.99%), 4,975 won short. In V8, Math.trunc(p * 0.1) over the same million prices differs on 500,000.
SQL engines add a second twist, and this one bites refunds:
| expression | SQLite 3.46.1 | Cloudflare D1 |
|---|---|---|
CAST(2.7 AS INTEGER) |
2 | 2 |
CAST(-2.7 AS INTEGER) |
โ2 | โ2 |
-5 / 2 |
โ2 | โ2 |
5 / 2 |
2 | 2 |
10 / 3 |
3 | 3 |
typeof(10/3) |
integer |
integer |
CAST truncates toward zero, so a โ2.7 won credit becomes โ2: the customer is shorted, systematically, in the direction that favours you. Integer division truncates the same way, which means SUM(price * qty) / 100 as a "cents to won" conversion is not a rounding at all โ it is a floor on every row.
Thief two: "round" means four different things
The word covers at least four rules, and three of them are the default in something you will use today.
| value | Python round()
|
Math.round() (V8) |
SQLite / D1 ROUND()
|
Decimal HALF_UP |
|---|---|---|---|---|
| 0.5 | 0 | 1 | 1 | 1 |
| 1.5 | 2 | 2 | 2 | 2 |
| 2.5 | 2 | 3 | 3 | 3 |
| โ2.5 | โ2 | โ2 | โ3 | โ3 |
| 1.005 โ 2dp | 1.0 |
(1.005).toFixed(2) = "1.00"
|
1.0 | 1.01 |
| 2.675 โ 2dp | 2.67 |
(2.675).toFixed(2) = "2.67"
|
2.67 | 2.68 |
Python's round() is round-half-to-even. V8's Math.round() is half-up toward +โ, but toFixed follows the exact binary value, so (2.675).toFixed(2) is "2.67" while Math.round(2.675 * 100) / 100 is 2.68. SQLite's ROUND() is half away from zero โ the only one of the three that sends โ2.5 to โ3.
How often the choice matters is exactly as often as there are ties, and the tie rate is a property of the rate you charge:
| rate | supplies (1โ200,000) landing exactly on .5 | share |
|---|---|---|
| 10% | 20,000 | 10.00% |
| 7.5% | 5,000 | 2.50% |
| 25% | 50,000 | 25.00% |
| 19% | 2,000 | 1.00% |
| 8.1% | 200 | 0.10% |
| 8%, 20% | 0 | 0.00% |
A tenth of a percent rate quantised to whole won ties one time in ten; a quarter ties one time in four; a rate with an even denominator in hundredths never ties. So "our rounding rule is fine, we checked" is a statement about a rate, not about a system โ and a rule that is fine at 20% breaks on a quarter of your lines at 25%.
Over prices 1 to 1,000,000 at 10% VAT, half-even and half-up disagree on 50,000 of them (5.00%), and every one of those is a won somebody has to explain. Applied per line to 20,000 ten-line carts, Python's round() disagreed with an exact integer half-up total on 8,055 carts (40.27%) โ while V8's Math.round() on the identical carts disagreed on 0. Same arithmetic, same data, two languages, and only one of them is the one the accountant assumes.
Thief three: the boundary you chose
This is the opening table, and it is the expensive one because it is a decision rather than a bug. Round every line and sum, or sum and round once? Over 20,000 generated 10-line carts (amounts 100โ50,000 won, 10%):
| cart convention | distribution of (per-line total โ invoice total) |
|---|---|
| VAT-exclusive, rounded per line | {โ3: 7, โ2: 290, โ1: 2,707, 0: 7,397, +1: 7,095, +2: 2,291, +3: 208, +4: 5} |
| VAT-inclusive (รท11), rounded per line | {โ3: 56, โ2: 965, โ1: 4,869, 0: 8,383, +1: 4,789, +2: 898, +3: 40} |
Non-zero in 63.02% of exclusive carts and 58.09% of inclusive ones, with a maximum of 4 won on a 10-line cart. The same experiment in V8 โ a different random sequence, Math.round instead of Decimal โ came out 63.99% non-zero with a maximum gap of 3 won: the same shape, different numbers. The language is not the variable here; the boundary is.
It scales with the number of lines, which is what makes it real rather than cute. Same price, more lines, VAT-inclusive at 10%:
| lines ร price | per-line VAT | invoice VAT | gap |
|---|---|---|---|
| 2 ร 1,004 | 182 | 183 | 1 |
| 5 ร 1,005 | 455 | 457 | 2 |
| 10 ร 1,006 | 910 | 915 | 5 |
| 20 ร 1,006 | 1,820 | 1,829 | 9 |
| 50 ร 1,006 | 4,550 | 4,573 | 23 |
| 100 ร 1,006 | 9,100 | 9,145 | 45 |
Now the same thing on the tax-exclusive side, which is how a supply invoice is written: one hundred invoices each with a supply of 1,006 won carry 101 won of VAT each, 10,100 in total, while the VAT on the month's combined supply of 100,600 is 10,060. Forty won, on a month. Across supplies of 1,000โ10,000 won the worst monthly gap measured was 50 won.
It also lives inside the database, in a query you would write without thinking twice:
-- 10,000 cart lines, price_cents ร qty
SELECT SUM((price_cents*qty*10 + 50) / 100) AS per_line_tax, -- 5,091,289,812
(SUM(price_cents*qty)*10 + 50) / 100 AS invoice_tax -- 5,091,289,507
FROM cart;
305 cents apart. Both are integer SQL. Both are defensible. One is right for the line the customer reads and the other for the invoice you file, and if one column feeds both screens, one screen is lying by a rounded amount.
Thief four: splitting a total
A split is a rounding problem in a different hat: the parts must sum to the whole, and rounding each part independently does not do that.
| total รท n | naive round(total / n) ร n |
off by | largest-remainder parts |
|---|---|---|---|
| 10,000 รท 3 | 3,333 ร 3 = 9,999 | โ1 | 3,334 + 3,333 + 3,333 |
| 10,000 รท 6 | 1,667 ร 6 = 10,002 | +2 | 1,667 ร 4 + 1,666 ร 2 |
| 10,000 รท 7 | 1,429 ร 7 = 10,003 | +3 | 1,429 ร 4 + 1,428 ร 3 |
| 10,000 รท 9 | 1,111 ร 9 = 9,999 | โ1 | 1,112 + 1,111 ร 8 |
| 199,000 รท 12 | 16,583 ร 12 = 198,996 | โ4 | 16,584 ร 4 + 16,583 ร 8 |
The offset is neither rare nor small: over totals 1โ100,000, a naive 3-way split is wrong on 66,667 of 100,000 totals, and a 9-way split on 88,889 of 100,000, spread almost uniformly across every value from โ4 to +4. "We split the annual price into 12 invoices of 16,583" is a 4-won hole in the year, twelve times a year.
The same failure appears whenever a fee, discount or tax is pushed down onto lines proportionally:
| lines | total | fee (10%) | naive per-line | proportional |
|---|---|---|---|---|
| 3,333 / 3,333 / 3,334 | 10,000 | 1,000 | 333 + 333 + 333 = 999 | 333 + 333 + 334 = 1,000 |
And it compounds when two percentages are applied in sequence instead of together: over 20,000 random baskets, rounding a 10% discount and then a 5% coupon differed from the exact combined calculation in 23.45% of baskets (โ1 won in 1,635, +1 won in 3,056). Loyalty points at 1% per purchase, summed over ten purchases, differed from 1% of the ten-purchase total in 59.20% of sets, range โ4 to +4. No single line is wrong; two boundaries disagree.
The Korean specifics, measured
Three places where this pattern has a local shape.
Won truncation (์ ๋จ์ ์ ์ฌ). Truncating each receipt is not the same as truncating the day's total. Over 10,000 receipts of 1,000โ500,000 won:
| unit | sum of truncated receipts | truncated sum of receipts | gap |
|---|---|---|---|
| 1 won | 2,523,774,378 | 2,523,774,378 | 0 |
| 10 won | 2,523,729,530 | 2,523,774,370 | 44,840 won |
| 100 won | 2,523,279,400 | 2,523,774,300 | 494,900 won |
The till and the settlement disagree by tens of thousands of won a month, and both are arithmetically defensible.
3.3% withholding. A freelancer's 3.3% is two taxes: 3% income tax, then local income tax at 10% of that income tax. Computed in one step (round(a ร 0.033)) versus two (round(a ร 0.03), then round(that ร 0.1)), the results differ on 275,000 of 1,000,000 amounts (27.50%) โ by exactly 1 won each, with the two-step version lower by 55,000 won in total. If a payment system and an accounting system pick different steps, every fourth payment is a reconciliation.
Card fees. A 2.5% fee plus 10% VAT on the fee, charged per transaction, is not the fee on the total: 10,000 transactions of 9,900 won cost 2,730,000 won per-transaction and 2,722,500 won on the aggregate โ 7,500 won apart in a month. For 1,000 transactions of 1,000 won the gap is 500 won, which is 1.8% of the fee, because rounding a 25-won fee up to 28 won is a 12% increase on every small transaction.
And the identity that holds. Define the tax-inclusive price as supply = total โ tax. Then supply + tax == total on all 1,000,000 totals tested, and a supply โ total โ supply round trip (1,000,000 supplies at 10%) recovered the original supply 1,000,000 times out of 1,000,000. Computing both parts independently instead of subtracting is luckier than it looks: at 10% it also survived a million totals. At 20% it failed on 166,667 of 1,000,000 โ 3 won of supply carries a 1-won tax, and 3 + 1 is 4, not 3. Subtraction cannot fail at any rate, because it is an identity rather than a calculation.
Currency, where the error is not the problem
Converting 1,000โ200,000 won to USD at 1,385.50 and back loses between โ7 and +7 won, mean +0.03. That is noise. The 0.5% spread on a single 19,900-won order is 100 won โ fourteen times the largest rounding error in this article.
Which is the useful framing: rounding errors are the ones you can eliminate, and they are almost never the ones that cost real money. What costs real money is a minor unit that does not exist. KRW and JPY have no subunit; KWD and BHD have three. $19.99 at 149.83 is 2,995.10 yen โ a price you cannot charge, a tax you cannot invoice, and a stored value that whichever system writes it first will quietly round. 1 KRW is 0.000722 USD; store that as a float and you have invented a subunit you then have to keep.
Storage and display, three traps
typeof in SQL. ROUND(2.5) returns 3.0, and typeof(ROUND(2.5)) is 'real' in both SQLite 3.46.1 and D1. An integer column written from a ROUND() becomes a real, and a later SUM() over a mixed column is a real sum. CAST back is a truncation, so ROUND โ CAST is a truncation with extra steps.
JSON. JSON.parse("1.0") is 1, and JSON.parse("9007199254740993") is 9007199254740992. Integer minor units survive JSON; "1.00" does not exist as a number.
Display. (8.575).toFixed(2) is "8.57", (1.45).toFixed(1) is "1.4", and Intl.NumberFormat('ko-KR', {style:'currency', currency:'KRW'}).format(19900) is โฉ19,900 โ no decimals, because the currency has none. Formatting is the one place a fraction is supposed to disappear, which is why it should be the only place it does.
For scale: Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991, which in cents is 90,071,992,547,409 won โ ninety trillion won before a double loses a one-won step. Integer minor units are not a compromise at shop scale; they are exact for every number a shop will ever see.
The model that survives
Six rules, each bought with a measurement above.
- One money type, integer minor units. KRW as integer won, USD as integer cents, KWD as integer fils. No float holds an amount; floats hold rates only.
-
One rounding function, with the mode in its name.
round_half_up_to_won()andround_half_even_to_won()are two functions, not one function and a comment. The choice is a written business decision, and it is the same function in the invoice, the receipt, the API and the database view. - Round at exactly two boundaries: the tax boundary and the display. Not at every step. A discount is subtracted from an integer; a tax is computed from an integer total; the display rounds nothing that has not already been rounded.
-
Compute the tax once, then allocate it.
tax = round_half_up(total ร rate), then distributetaxacross lines by largest remainder. Parts equal the whole by construction, so the invoice and the lines cannot disagree. -
Splits use largest remainder.
base = total // n, then hand outtotal โ base ร none unit at a time, in a documented order. Neverround(total / n) ร n. -
Test the identity, not the function. Generate 10,000 random carts and assert
sum(lines) + tax == totalandsum(allocated) == taxfor every one. A unit test onround()passes while a system loses 7,500 won a month; the identity test fails the moment a boundary moves.
The rounding rule I would keep boring: half-up on positive amounts, applied to integer minor units, with the mode named at each call. That rule is not what these numbers indict. What they indict is which value gets rounded, where, and by whom โ and that is a design question, not a library question.
The whole model, integer only
Here is the model in about forty lines, with no float in sight:
def round_half_up(den: int, num: int) -> int:
"""round_half_up(num / den) for non-negative integers, no float anywhere"""
return (num * 2 + den) // (den * 2)
def allocate(total: int, weights: list[int]) -> list[int]:
"""largest remainder: sum(out) == total, deterministic order"""
s = sum(weights)
out = [total * w // s for w in weights]
rem = total - sum(out)
order = sorted(range(len(weights)), key=lambda i: (-(total * weights[i] % s), i))
for i in order[:rem]:
out[i] += 1
return out
@dataclass(frozen=True)
class Invoice:
lines: list[int] # integer won, tax-exclusive
rate_den: int = 10 # 10% VAT => 1/10
def subtotal(self) -> int: return sum(self.lines)
def tax(self) -> int: return round_half_up(self.rate_den, self.subtotal())
def total(self) -> int: return self.subtotal() + self.tax()
def allocate_tax(self) -> list[int]: return allocate(self.tax(), self.lines)
def property_test(n=10000):
for _ in range(n):
lines = [random.randint(1, 200000) for _ in range(random.randint(1, 40))]
inv = Invoice(lines)
assert inv.subtotal() + inv.tax() == inv.total()
assert sum(inv.allocate_tax()) == inv.tax()
Run against 10,000 random carts it passed 10,000 of 10,000, and the same carts show why the identity is worth more than any single assertion: ten lines of 1,006 won produce a tax of 1,006 while the naive per-line sum was 1,010, and the allocated lines come out as six of 101 and four of 100 โ receipts that look like a human rounded them, totals that reconcile, and an invoice total of 11,066 for 10,060 of supply.
Two notes on the code. round_half_up is integer division on 2 ร num + den, which is exact for every amount a shop will ever hold and is the same function in Python, SQL and anything else with integers. And allocate sorts ties by index, not by value, so the same cart always produces the same receipt โ determinism is part of the contract, because a receipt reprinted twice has to match itself.
What this does not answer
Whether your rounding rule is the one your tax authority expects. I measured arithmetic libraries, not tax law; VAT invoicing has conventions about which boundary is authoritative, and that is a question for an accountant or the tax service. What the measurements give you is the ability to ask it precisely: put the per-line figure and the invoice figure side by side and show the eleven won between them.
Nor whether your database behaves like mine. D1 and local SQLite answered every expression above identically, but D1 refused SELECT sqlite_version() outright โ error 7500, "not authorized to use function: sqlite_version" โ so I can tell you it agreed with 3.46.1 on every rounding function I could reach, not which build served it. Probe your own engine for ROUND(-2.5) and typeof(ROUND(2.5)) before you write a rule that leans on them.
And not what your customers will tolerate. Four won is nothing; four won on every invoice, on a document they reconcile against a bank statement, is a support ticket with a screenshot.
The money is not lost in the arithmetic. It is lost at the boundaries โ and boundaries are the part you choose, which is the good news, because a choice can be named, tested and written down.
I build and maintain the small commerce and menu pages where these numbers live, and their price tables are why I stopped trusting "we round to the nearest won". If you want to see one of those pages in the shape it ships โ static, no account, a table of facts and a code you can copy โ the English walkthrough is at https://pickle-invite-kr-71cc5a.deed.page/en.html, and the invite page it describes is here: https://pickle.com/i/RDKXK77G9ZRT.
Disclosure: I maintain the Korean-language invite page linked above and receive usage credits when someone joins through that code. Nothing in this post is sponsored, and no number above depends on it.
All figures measured on 2026-11-15: Python 3.12.16, SQLite 3.46.1, Chrome 155 (V8), Cloudflare D1 (read-only, SELECT only). Scripts: four Python/SQLite probes, one browser_evaluate probe for V8, one D1 SQL API probe.
Top comments (1)
The 4-won gap being a rounding-boundary decision rather than a float artifact matches the measurement: at shop scale binary floating point contributes a sub-cent error, while per-line rounding summed versus one rounding on the invoice total produces a real, silently unreconciled difference. Keeping money in integer minor units and doing the arithmetic there removes the float question outright; the remaining choice โ round each line or round once on the total โ has to follow the jurisdiction's tax rule and be applied identically across the invoice, the customer copy, and the filing. Any leftover line-vs-total remainder should be settled on the total rather than left to drift, and the client display format must never re-derive a different number. When runtimes disagree, the server-side integer calculation should be the authoritative source rather than whatever the browser or SQLite localizes to.