Open a Python REPL and type three lines:
>>> 0.1 + 0.2 0.30000000000000004 >>> round(2.675, 2) 2.67 >>> round(2.5), round(3.5) (2, 4) All three results look wrong. None of them is a bug in Python. Each one follows a rule that is working exactly as designed, and the rules are not the ones most people assume their computer uses. Money is where those assumptions get expensive.
0.1 has no exact binary form
Computers store fractional numbers in base 2, the same way we write integers in base 10. A value like 0.125 is easy in binary: it is 1/8, one bit after the point. A value like 0.1 is not. Its binary expansion repeats forever:
0.0001100110011001100110011001100110011001100110011... Stop at any finite number of bits and you have an approximation. A Python float keeps 53 bits of precision, so the stored value of 0.1 is the fraction 3602879701896397 / 2**55, which is close to 1/10 but not equal to it. The Python tutorial prints the exact stored value:
>>> from decimal import Decimal >>> Decimal.from_float(0.1) Decimal('0.1000000000000000055511151231257827021181583404541015625') Add two of those approximations and the error surfaces at the 17th digit, which is why the result prints as 0.30000000000000004. JavaScript uses the same IEEE 754 binary64 format and returns the same answer.
This is also why 2.675 rounds down. The literal 2.675 is stored as 2.67499999999999982236431605997495353221893310546875, a number strictly below 2.675. Any rounding rule applied to it will produce 2.67, because that is the correct rounding of the value actually in memory.
Rounding is a policy, and languages disagree
Representation explains the first problem. The second one is that "round" is not a single operation. Python 3 rounds halfway cases to the nearest even integer, a rule called banker's rounding. That is why round(2.5) is 2 and round(3.5) is 4. The language reference states it directly: when two multiples are equally close, the result goes to the even choice.
JavaScript rounds halfway cases toward positive infinity. The MDN documentation notes that this differs from languages that round half away from zero, and the asymmetry shows up on negative numbers:
Math.round(0.5) // 1 Math.round(-0.5) // -0 1 / Math.round(-0.5) // -Infinity Then toFixed adds a third behavior, because it rounds the decimal expansion of the stored binary value. That produces results that look like a different bug entirely:
(2.675).toFixed(2) // '2.67' (1.005).toFixed(2) // '1.00' None of these engines is wrong. They implement different rounding policies, and each policy is reasonable until the number is a price, a tax line, or an invoice total.
Three failures that reached production
The Vancouver Stock Exchange index, 1982 to 1983
The exchange started an index at 1000 in January 1982 and updated it after every trade. Each update was truncated to three decimal places, and the truncated value was used to compute the next one. With roughly 3,000 updates a day, the truncation compounded into a loss of about 25 points per month. By late November 1983 the published index read 524.811. Correcting the arithmetic over the weekend of November 25 to 28 raised it to 1098.892. The index had been understating the market by more than half for nearly two years, not because of a crash, but because a rounding error was fed back into its own input. Wikipedia has the figures.
The lesson is not "use more decimals". The lesson is that a rounded value must never become the input to the next calculation in a chain.
The Patriot missile battery, 1991
A Patriot battery in Dhahran tracked time in tenths of a second. The value 0.1 was stored in a 24-bit register, chopping the repeating binary expansion and introducing an error of about 0.000000095 seconds per tenth. That error was multiplied by the number of tenths since boot. After roughly 100 hours of uptime the clock was off by about 0.34 seconds, which was enough for the system to miscalculate a Scud's position. The missile was not intercepted, and 28 soldiers died. The GAO report is public, and the numerical walkthrough is widely taught.
The failure mode here is a small bias in one direction, accumulated over a long time. Financial systems have the same shape: a per-transaction rounding bias of one cent is invisible in a test and material after ten million transactions.
Excel 2007 and the number 65,535
In September 2007, users found that =77.1*850 displayed 100000 instead of 65535. Microsoft's own post-mortem confirmed the pattern: many calculations that should have produced 65,535 displayed 100000, while others such as =65535*1 worked. The bug sat at the boundary where the calculated value was converted to the text shown on screen, and it was patched.
This one is about the display layer rather than the calculation. The stored value was correct; the text rendered from it was not, and for a spreadsheet user the number on screen is the number that matters.
What actually holds up
Store money as integers in the smallest unit
Stripe's API makes the rule explicit: the amount field is "a positive integer representing how much to charge in the smallest currency unit" (see the Charge object). 100 means $1.00, and a zero-decimal currency like JPY is already in its smallest unit. Integer arithmetic inside a safe range is exact, which removes representation error from every addition, subtraction, and comparison. JavaScript's safe integer limit is 9,007,199,254,740,991, or about $90 trillion in cents, which is enough for any ordinary ledger.
Use a decimal type when the language has one
Python's decimal module and Java's BigDecimal store decimal digits rather than binary fractions, so 0.1 is exactly 0.1. The catch is that the rounding mode is an argument, not a default you can ignore:
from decimal import Decimal, ROUND_HALF_UP def money(value: str) -> Decimal: return Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) money("2.675") # Decimal('2.68') Pass a string, not a float. Decimal(2.675) imports the representation error and gives 2.67499999999999982236431605997495353221893310546875, which rounds back to 2.67. Decimal("2.675") is the decimal you meant.
Pick the rounding mode the domain requires. Tax authorities usually specify half-up or a floor at a particular step. Banks often use half-even. The important part is that the choice is written down and tested, not inherited from whichever language happened to run the code.
Round once, at the boundary
Compute in exact units, and round only when you produce a value a human will see or a system will store as a final amount. Interest accrual, allocation, and tax calculations should run on unrounded values; the invoice line rounds once. If you round at every step, the errors compound in the same way as the Vancouver index.
Allocate the remainder instead of losing it
Splitting 100.00 three ways does not divide evenly. Rounding each share independently gives 33.33 three times, and one cent vanishes. The largest remainder method assigns the leftover units to the first shares:
def split(total_cents: int, parts: int) -> list[int]: base, remainder = divmod(total_cents, parts) return [base + (1 if i < remainder else 0) for i in range(parts)] split(10_000, 3) # [3334, 3333, 3333] The sum always equals the total, which is the property an accounting system actually checks.
Test the invariants, not just the examples
Two assertions catch most money bugs before they ship:
assert sum(split(10_000, 3)) == 10_000 assert money("2.675") == Decimal("2.68") The first one is a property: every split sums back to its input. The second one pins the rounding policy in a place where a language upgrade or a refactor will break the build if it changes. Add a case for negative amounts, because refunds and credits are where half-up and half-even diverge most visibly.
The short version
- Binary floats cannot represent most decimal fractions exactly. That is arithmetic, not a language defect.
- Rounding modes differ between languages and between industries. Choose one, name it, and test it.
- Never feed a rounded value into the next calculation. Round at the boundary.
- Integers in the smallest unit, or a decimal type, for anything that has to balance.
- Split totals with a remainder method so the parts still sum to the whole.
A single cent looks like noise. The same cent, applied a few million times without a rule, is a line item nobody can reconcile.
