| Value as written | Remove |
|---|---|
| Value | Half away from zero | Half to even | toFixed on a double | multiply, round, divide | Apart by |
|---|---|---|---|---|---|
| 2.155 | 2.16 | 2.16 | 2.15 | 2.15 | 1 unit |
| 1.005 | 1.01 | 1.00 | 1.00 | 1.00 | 1 unit |
| 8.475 | 8.48 | 8.48 | 8.47 | 8.48 | 1 unit |
| 0.125 | 0.13 | 0.12 | 0.13 | 0.13 | 1 unit |
| 2.675 | 2.68 | 2.68 | 2.67 | 2.68 | 1 unit |
| 1.115 | 1.12 | 1.12 | 1.11 | 1.12 | 1 unit |
Rounding 2.15 to one decimal place
One rule, four implementations, two answers. Every one of these is a correct implementation of something, which is why the divergence survives review.
| Implementation | Result | Why |
|---|---|---|
| Half away from zero | 2.2 | What a spreadsheet does. A true half always moves away from zero. |
| Half to even | 2.2 | Ties go to the even digit, which removes the upward bias across many values. |
| toFixed on a double | 2.1 | Rounds the stored value, 2.1499999999999999, where there is no tie to break. |
| Multiply, round, divide | 2.2 | The multiply lands exactly on 21.5, so the tie returns and goes up. |
The float rows go through a double, where the value you wrote is frequently not the value stored.
Why this happens
Rounding to two decimal places sounds like one operation. It is at least four, and they disagree on ordinary money rather than on edge cases somebody constructed.
The first split is the rule itself. Half away from zero sends a true half away from zero, which is what a spreadsheet does and what most people mean by rounding up. Half to even sends it to the nearest even digit, removing the upward bias you accumulate by always rounding halves up.
The second split is not about the rule at all. A value written as 2.15 is not stored as 2.15 in a binary floating-point number. It is stored slightly below it.
2.15 as a double is 2.14999999999999991118215802999
So a correct implementation of round half up, applied to the stored value, finds no half and goes down. The rule was right. The input was not the one you wrote.
Then the two float idioms disagree with each other, which catches people who already knew about the first two. toFixed rounds the stored value and goes down. Multiplying by ten first produces exactly 21.5, because that multiply rounds too, and the tie comes back.
The trap in the test
The natural way to prove your engine agrees with the business is to export the historic figures and compare. That test cannot find this class of defect.
A spreadsheet writes CSV as the displayed value, not the stored one, so the export has already had the spreadsheet’s rounding applied to it. The harness compares your engine against a number rounded by the mechanism under test, and agrees with itself whatever your engine does at the boundary.
Read stored values at full precision, pin the rounding mode explicitly rather than inheriting a language default, and compare to the unit against figures the business actually issued. The shape of that harness is in the piece on automating a gatekeeper away, and the related case of a value that is schema-valid and still wrong is in units are semantics, not syntax.
Common questions
Two separate reasons that happen to point the same way. A spreadsheet rounds half away from zero, so a true half goes up. Your code is probably working on a double, where 2.15 is stored as 2.1499999999999999, which is below the half, so a correct implementation of round half up correctly rounds it down. Neither is a bug in isolation. The divergence between them is the bug.
Because they round at different points. Calling toFixed rounds the stored double, which sits just under the half, so it goes down. Multiplying by a power of ten first can land exactly on the half, because the multiply itself rounds, and then the tie reappears and goes up. Same language, same input, two answers, and neither path is incorrect by its own rules.
Because a spreadsheet writes CSV as the displayed value rather than the stored one, so the export has already had the spreadsheet's rounding applied. The harness then compares your engine against a number that was rounded by the exact mechanism under test. It agrees with itself and passes whatever your engine does at the boundary.
Whichever one the business already uses, which is usually half away from zero, because that is what the workbook does. The important part is that the mode is stated explicitly in the engine rather than inherited from whatever the language or the database defaults to. An unstated rounding mode is a decision nobody made.
It is when somebody is bound by the number. A cent on an indicative price is cosmetic. A cent on an executable quote, or on a figure a professional certifies, is an exposure on every transaction, and it is exactly the size of error that survives review because it reads as a rounding artefact rather than as a fault.
Fewer than you fear and more than none, and the rate depends on how the values are produced upstream. Figures derived from a percentage or a division land on boundaries far more often than typed ones. Run your real values through rather than estimating, which is what the volume field is for.
This compares four implementations on the values you paste. It does not know your database column type, your language runtime, or where in your pipeline the rounding happens, and a real system usually rounds more than once. A value that agrees here can still diverge in your stack if it is rounded twice or passes through a float column on the way. The useful output is a list of boundary values to run against your own engine, not a verdict on it.