DEV Community

speed engineer
speed engineer

Posted on

The Ledger Was Off By One Cent. By Month-End It Was $340.

The problem

A nightly job reconciled every transaction against the daily balance and flagged anything that didn't tie out to the cent. For months it was silent. Then one Tuesday it flagged a $0.01 discrepancy on an account with about 40,000 line items. Nobody paged over a penny — the ticket sat in the backlog for a week.

By the following Monday, the same account was off by $2.14. Two weeks after that, $340.07. The discrepancy wasn't random noise that would cancel out — it only ever grew in one direction, and it grew faster the more transactions the account processed that month. That's the detail that turned "round to the nearest cent and move on" into an actual incident.

The balance was computed by summing a Python list of float transaction amounts — sum(amount for amount in transactions) — then rounding the total to two decimal places for display. Every individual amount, printed on its own, looked exactly right: 19.99, 4.50, 102.33. The bug wasn't in any single number. It was in adding them together, tens of thousands of times, in whatever order the query happened to return them.

Why it happens

float in Python (and in virtually every language) is IEEE-754 double precision: a fixed 53 bits of mantissa. Most decimal amounts — 0.10, 19.99 — have no exact binary representation, the same way 1/3 has no exact finite decimal representation. 0.10 in a double is actually stored as 0.1000000000000000055511151231257827021181583404541015625. That error is roughly 5.5e-18 per value — utterly invisible when you print one number, because printing rounds it back to something that looks clean.

The part most people miss: floating-point addition is not associative. (a + b) + c does not always equal a + (b + c) once rounding is involved, because each intermediate addition rounds to the nearest representable double, and which values you round first changes the final result. Summing 40,000 of these tiny errors doesn't average out to zero the way independent random noise would — the rounding direction correlates with the sign and magnitude of the running total, so on this workload the errors compounded in the same direction almost every time. Change the table scan order, add an index, reorder the query — any of it could push the running total onto a different, still-wrong, path.

This is exactly why the discrepancy only grew: it wasn't measurement noise, it was systematic error from a data type that was never designed to represent money exactly in the first place.

What to do about it

Never use float/double for currency amounts you intend to sum, compare, or reconcile. Two solid options:

Integer minor units. Store and sum everything in cents (or the smallest currency unit) as an integer — 1999 instead of 19.99. Integer addition has no rounding error, period. Convert to a display string only at the edge, right before rendering.

# Instead of this:
total = sum([19.99, 4.50, 102.33])  # accumulates binary rounding error

# Do this:
total_cents = sum([1999, 450, 10233])  # exact integer arithmetic
display = f"${total_cents / 100:.2f}"  # convert only for display
Enter fullscreen mode Exit fullscreen mode

decimal.Decimal, if you need fractional-cent precision (tax calculations, currency conversion) or can't restructure storage to integers:

from decimal import Decimal
total = sum((Decimal("19.99"), Decimal("4.50"), Decimal("102.33")))
Enter fullscreen mode Exit fullscreen mode

Decimal is exact for base-10 values because it stores digits, not binary fractions — but it's slower, and it's easy to accidentally reintroduce the bug by constructing a Decimal from a float (Decimal(19.99) inherits the float's existing error; Decimal("19.99") built from the string does not).

If you're stuck with floats for now — legacy schema, a third-party API you don't control — at minimum switch your summation to Kahan summation, which tracks and compensates for the running rounding error at each step. It doesn't fix the root representation problem, but it caps the error growth instead of letting it compound.

Whatever you pick, add a reconciliation test that sums a large, realistic transaction set and asserts the total matches a value computed an independent, exact way (integer cents, say) — not just a handful of clean round numbers in a unit test, which is exactly the kind of input that hides this bug.

Key takeaways

  • float/double cannot represent most decimal fractions exactly — the error per value is tiny, but real.
  • Floating-point addition is not associative; summing many floats compounds rounding error in ways that don't cancel out, especially under a consistent processing order.
  • Never sum currency as float. Use integer minor units (cents) or decimal.Decimal, and construct Decimal from strings, not from floats.
  • A bug invisible at the single-value level can still be a systemic, growing problem in aggregate — test reconciliation logic against large, realistic datasets, not clean round numbers.

Top comments (0)