Blog
Why an expense tracker must not store money as a float
Binary floating-point numbers cannot represent most decimal fractions exactly, so 0.1 + 0.2 evaluates to 0.30000000000000004. In a money app the error accumulates across thousands of rows and surfaces as totals that disagree with the transactions they are made of.
By The Chillar's maintainers ·
The one-line demonstration
Open any JavaScript console and type:
0.1 + 0.2
You get 0.30000000000000004.
That is how binary floating-point arithmetic works, and Python, Java, Go, C and every other language using IEEE 754 doubles behave the same way. The number 0.1 has no exact representation in binary, in the same way 1/3 has no exact representation in decimal. You store the nearest representable value, and the error compounds.
Money is the wrong place for it
A physics simulation absorbs an error of one part in 10^16 without anyone noticing. A ledger does not, for three reasons.
You compare money for equality. "Does this month's income minus expenses equal the change in balance?" has a yes-or-no answer, and floating-point arithmetic gets it wrong often enough to matter. A reconciliation off by 0.00000000000004 is still off.
Errors accumulate along the length of a ledger. A single addition is fine. Ten thousand additions, which is an ordinary year for someone who tracks UPI spending, is another matter, and the drift lands in the place users check first: a total that disagrees with the transactions above it.
Division is everywhere. Currency conversion, splitting a bill, apportioning a shared expense, computing a percentage of a budget. Division produces the largest floating-point error, and it is where "the error is tiny" stops holding.
Where it surfaces first
The first symptom you see is rarely a wrong balance. You see:
- A category total one paisa or one cent off the sum of its rows, on screen, next to those rows
- An export whose column total disagrees with the same total in the app
- A budget showing 0.01 remaining when it should show 0, so it never reads as fully spent
- A cross-currency conversion that round-trips to a different number than it started at
Each of these is small, and each one costs you the app's credibility, because a money manager offers one thing: arithmetic you can trust.
What Chillar's does instead
Money is never a JavaScript number anywhere in the arithmetic path.
In the database, amounts are NUMERIC(19,4), an exact decimal type. Postgres does the arithmetic in decimal, so aggregates computed in SQL come out exact by construction.
In the browser, an amount is a decimal string and arithmetic goes through big.js, an arbitrary-precision decimal library. Money is a string plus a currency code, and that type is what the whole codebase passes around.
At the boundaries, the string survives. The RPC functions that compute balances, budget progress and report aggregates cast their results to text before returning them, because every client library parses a JSON number back into a double. That would put the problem back at the last possible moment, after we had handled everything else.
At the display step only, we convert the decimal to a number and hand it to Intl.NumberFormat. That value never goes back into arithmetic.
A lint rule fails the build on raw arithmetic applied to a variable named like money: amount, balance, total, spent. It is a blunt instrument, and it catches the slip that happens in practice, which is someone reaching for + on a tired afternoon.
How we check it
We check the money functions against fixture datasets paired with known correct figures: balances, period statistics, all four branches of the currency-conversion path, budget scoping, and period boundary arithmetic including month-end clamping.
Those fixtures are answer keys rather than assertions someone wrote after looking at the output, and both the SQL layer and the browser layer answer to the same numbers. Exact decimals are what let us prove the two layers agree.
Frequently asked questions
- How does Chillar's store amounts?
- As NUMERIC(19,4) in Postgres and as decimal strings handled by big.js in the browser. No amount is ever a JavaScript number in the arithmetic path; conversion to a number happens only at the final formatting step for display.
Related reading
- How to track expenses (and still be doing it in three months)A five-step method for tracking expenses that survives the second week: pick a capture habit, categorise coarsely, reconcile weekly, review monthly.Read
- How to switch expense trackers without losing your historyMove years of history to a new expense tracker without losing it: export first, check the CSV columns, map them once, then verify three balances by hand.Read
About the author
The Chillar's maintainers
The team that builds and runs Chillar's, a free money manager for the web. We check everything published here against the app's own behaviour.
Start tracking in about two minutes
Free to use, and everything you enter is exportable. No card and no trial.