Floating point is the right tool for measurement and the wrong one for money. Here is the actual reason 0.1 + 0.2 fails, what it costs across a single job, and where the rounding is allowed to happen.
Floating point usually enters a developer's life as a party trick. Type 0.1 + 0.2 into a REPL, get 0.30000000000000004 back, laugh, move on. Treated as a curiosity it is harmless. Left in a financial system it produces invoices whose lines do not add up to their totals, margin figures that change depending on which query produced them, and journal entries that do not balance.
Contractor OS carries a zero among its metrics that is more load-bearing than it looks: no floating-point money values anywhere in the system. Not in a column, not in a JavaScript value, not in a single intermediate calculation. That is not fastidiousness. It is the precondition for the thing the platform exists to do, which is have every financial document post a balanced journal entry to a real general ledger. A ledger that is out by an amount nobody can account for is not a ledger.
What the literal 0.1 actually is
A double-precision float is sixty-four bits: one for the sign, eleven for the exponent, and fifty-two stored bits of significand, fifty-three counting the implicit leading one. The value it encodes is an integer significand multiplied by a power of two.
That last clause is the whole problem. A fraction is exactly representable in binary only when its denominator, in lowest terms, is a power of two. One half, one quarter, three eighths: exact. One tenth is not, because ten factors into two and five, and the format has nowhere to put the five. In binary, one tenth is 0.0001100110011... repeating forever, in precisely the way one third is 0.333... repeating forever in decimal.
So the literal 0.1 in your source file is not 0.1. It is the nearest double to 0.1, which is exactly:
0.1000000000000000055511151231257827021181583404541015625
The error arrives at parse time, before any arithmetic has run. 0.2 is likewise a fraction over. Add the two nearest doubles, round the result to the nearest double again, and you land somewhere other than the double nearest to 0.3, which is why the comparison fails and why the printed answer carries a stray digit in the seventeenth place.
None of this is a defect. IEEE-754 is doing exactly what it documents. It was designed for quantities that arrive from measurement, where relative error is what you care about and the last bit is noise. Money does not arrive from measurement. A price is a count of the smallest indivisible unit, fixed by convention and enforced by statute, and in that setting the last bit is a cent, which is not noise. It is an amount somebody is owed.
The failure that hurts is the quiet one
0.30000000000000004 announces itself. The cases that reach production do not.
| Expression | Result | What happened |
|---|---|---|
0.1 + 0.2 === 0.3 | false | neither literal was ever the value you typed |
0.1 added ten times | 0.9999999999999999 | representation error accumulates over a sum |
(2.675).toFixed(2) | "2.67" | the stored double sits just below 2.675 |
(1.005).toFixed(2) | "1.00" | same cause, and this one is a tax line |
1.15 * 100 | 114.99999999999999 | the naive conversion to cents, off by a hair |
The toFixed rows are worth sitting with. The rounding rule is correct — half-up rounds 2.675 to 2.68. The input was already wrong before the rule saw it, and the function has no way to know that. Wrapping the last row in Math.round returns 115 and appears to fix the problem, which is exactly why the bug survives: the naive repair works often enough to look like a solution, and fails on the values nobody thought to test.
How one cent becomes a discrepancy
Rounding to the nearest cent can be off by up to half a cent in either direction. That is fine once. A job is not once.
Take the shape of the domain in Contractor OS: an estimate becomes a job, the job accumulates purchase orders, bills, expenses and crew timesheets, change orders alter the scope, and invoices are raised against the whole thing. Each line carries a quantity and a unit price, and the percentage multipliers that sit on top of them. That is several arithmetic operations per line, a long list of lines to a job, and a rounding decision available at every one of them.
The errors do not reliably cancel. Their direction is determined by the bit pattern of the operands rather than by chance, so a catalogue of similarly priced items tends to lean the same way. Three failures then compound:
- The sum of the rounded lines is not the rounded sum. Round each line for display, total the raw values for the header, and the invoice disagrees with itself. Both numbers are defensible. The customer adding up the column is not interested in that.
- The same figure is computed by two paths. A job cost assembled from timesheets and bills, and the same job cost read off a posted invoice, take different routes through the same arithmetic and arrive a cent apart.
- Margin magnifies it. Margin is the difference between two large numbers, so a small absolute error in either becomes a visibly larger relative error in the answer. The figure the owner actually makes decisions on is the one most exposed.
In a double-entry system this stops being cosmetic. A journal entry is only valid when its debits equal its credits. If the debit side is derived from the line items and the credit side from the header total, and the two paths round differently, the entry does not balance. You are then choosing between rejecting a legitimate posting and accepting a ledger that no longer proves anything. Neither is a choice worth having.
The tell
If your financial assertions need a tolerance —
toBeCloseTo, an epsilon comparison, a rounding-difference account that quietly absorbs the remainder — you are not testing a ledger. You are testing floats and calling the leftovers policy.
Integer cents, and why BigInt
The fix is old and boring: store money as an integer count of the smallest unit, and never let it become a fraction.
In Contractor OS that means three decisions that have to agree with each other.
- In the database,
numeric(14,2). Postgresnumericis exact decimal with a declared scale.real,double precisionandfloat8are IEEE-754 by definition and must never hold an amount, however convenient the driver makes them. - In JavaScript, a decimal string. JSON has one number type, and every JavaScript parser turns it into a double, so an amount that travels as a JSON number has had the database's work undone before your code sees it. A string carries the exact digits across.
- In every calculation, integer cents in
BigInt.
The last one gets the most pushback, because JavaScript's Number holds integers exactly up to Number.MAX_SAFE_INTEGER, and that ceiling sits far above any total a contractor is going to invoice. The ceiling is not the problem. Intermediate products are. Multiply a cents value by a scaled percentage multiplier and by a scaled quantity before dividing back down, and the product crosses the safe-integer boundary long before either input looks large — at which point Number starts quietly returning even values, and nothing anywhere raises a hand.
BigInt has no ceiling and no silent widening. Its real advantage is the inconvenience everybody complains about: it refuses to mix with Number in an expression. A stray float that finds its way into the money path throws a TypeError instead of contaminating a total. In this one place, the type error is the smoke detector.
Round once, at the end
Rounding is lossy, so it happens exactly once per amount, at the moment the amount becomes real: posted to the ledger, printed on a document, or charged to a card. Everything upstream is carried at full precision.
That is why quantities and percentages are not stored the way money is. A tax rate, a markup, a waste factor, a labour hour — these are ratios and measures, not counts of cents, and truncating them to two places at rest destroys information the money calculation still needs. In Contractor OS the quantity and percentage multipliers are carried at six decimals in memory, so a line's extended amount stays in scaled integers through the whole calculation and only comes down to cents at the end. One rounding step per amount instead of one per operation.
Three rules keep that honest.
- Pick a rounding mode and write it down. Half-up, half-even, half-away-from-zero — each is defensible, and using two of them in one system is not. The reporting layer has to use the same one as the ledger, or reconciliation starts failing for reasons that have nothing to do with the business.
- Allocate remainders deliberately. When a total is split across lines — a discount over an invoice, a payment across bills, tax over a bundle — the parts must sum to the whole. Take the floor of each part, then hand out the remaining cents one at a time by largest remainder. Divide and hope, and you are a cent short at the bottom of the page.
- Keep markup and margin apart. Markup is against cost, margin is against price. Oryx holds them as separate concepts in the pricing model rather than one field with a comment on it, because conflating the two is a pricing bug that perfect arithmetic will never catch.
What to check in your own system today
None of this needs a rewrite to diagnose. It is a reading exercise, and the whole list can be worked through in one sitting.
- Grep the schema for
float,realanddouble precision. Any money column that matches is the bug, and it is the first one to fix, because everything downstream inherits it. - Read what your driver and ORM do with
numeric. Some return a string, some return a JavaScript number. If yours returns a number, the exact column you were careful about has already been converted to a float before your code sees a single row. - Look at the API boundary. Is an amount a JSON number or a decimal string? Check the responses you send and the payloads you accept, separately.
- Grep the application for
parseFloat,toFixedand bareNumber(on anything that ends up on a document. Each one is a place where a rounding decision got made without a rounding policy. - Count the rounding steps in one invoice's code path, from unit price to header total. If the answer is more than one per amount, you have found where the cent went.
- Check the reporting layer on its own. Casting to float for a chart axis is fine. Casting to float and then reconciling the result against the ledger is not.
- Write the test that catches the rest: generate a document with many lines at awkward prices, then assert that the line amounts sum exactly to the header total, and that the resulting journal entry's debits equal its credits. Exactly. No epsilon. If the assertion cannot be written without a tolerance, you already have your answer.
- Make sure seeded and demo data runs the same code path as production. In Contractor OS the demo dataset is deterministic on purpose, so the same figures come out of the embedded WebAssembly Postgres used locally and the managed Postgres in production. A fixture that takes a shortcut is a fixture that has stopped testing the thing you care about.
The part that is not really about arithmetic
Keeping money out of floating point is not a performance decision or a matter of taste, and it will never show up in a benchmark. It pays for itself the first time somebody asks why the job margin on the dashboard differs from the job margin in the accountant's export, and the answer is that it does not — because there is one number, and one place where it was rounded.
That is the whole return. Not precision for its own sake, but the ability to give a straight answer about your own money.
If the totals in your system have started disagreeing with each other, the brief is the quickest way to describe what you are running and what it is doing. We will tell you whether it is a rounding problem, or something else wearing rounding as a costume.