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 Anup Maurya and Chaitanya Vartak ·
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 eight fixture datasets, each an answer key covering one area: balances, period statistics, all four branches of the currency-conversion path, budget scoping, and period boundary arithmetic including month-end clamping.
Those fixtures are not assertions someone wrote after looking at the output. Each one is built to match the reference behavior of Ivy Wallet, the open-source Android app that inspired Chillar's, cited back to the exact logic it verifies, so the expected numbers hold up against real-world money math, not just our own assumptions. Both the SQL layer and the browser layer are asserted against those same numbers to a tolerance of one cent, run by hand before every merge and every deploy, since Chillar's has no CI pipeline to run them automatically. Exact decimals are what let us prove the two layers agree with each other, and with numbers that are already known to be correct.
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 authors
Anup Maurya · Chaitanya Vartak
Anup Maurya and Chaitanya Vartak build and run Chillar's. Everything published here is checked against the app's own behaviour before it goes out.
Start tracking in about two minutes
Free to use, and everything you enter is exportable. No card and no trial.