Skip to content
NOSTREL
Writing

Why your money column should never hold a float

0.1 plus 0.2 is not 0.3, and in a payments ledger that is not a curiosity. What goes wrong, and the one rule that removes the whole class of bug.

6 minute readEngineeringNOSTREL engineering

Open a terminal and type this into any language you like.

0.1 + 0.2
0.30000000000000004

Everybody has seen it. Most people file it under "floating point is weird" and move on, which is fine right up until the number is somebody's money.

Why it happens

A binary floating-point number represents a value as a fraction times a power of two. That system can represent 0.5 exactly, and 0.25, and 0.75. It cannot represent 0.1 exactly, for the same reason base ten cannot represent one third exactly: the expansion does not terminate. So 0.1 is stored as the nearest representable value, which is very slightly wrong, and the error travels with it.

One such error is invisible. The problem is that they accumulate, and that they accumulate differently depending on the order of operations, which means two correct programs computing the same total can disagree.

What that looks like in a payments system

A fee is 1.5% of 1,850 shillings. In floats you get 27.749999999999996, which rounds to 27.75, which is right. Now do it for a quarter of a million transactions and sum the fees, and whether your total matches the sum of the individual roundings depends on the order you added them in.

That is the first symptom: your statement does not reconcile with your transaction list, by a few cents, for no reason anybody can find. Nobody can find it because the individual rows are all correct. It is the summation that drifts, and a few cents is small enough that the first instinct is to assume somebody made a data entry error.

The second symptom is worse and rarer. A balance check reads available >= amount, the two values are equal in the real world, and the comparison returns false because one of them is 1999.9999999999998. A payout fails for a merchant who demonstrably has the money. You cannot reproduce it, because reproducing it requires the exact sequence of prior transactions that produced that exact error.

The third is a genuine security problem. If rounding happens at the point of display rather than the point of storage, you can have a balance that shows as Ksh 0.00 and is actually 0.004, and a system that sums a lot of those is a system with money in it that no screen will ever show.

The rule

Store money as an integer number of the smallest unit, everywhere, and never convert until you are formatting a string for a human being.

In Kenya that is cents. 1,850 shillings is 185000. A fee of 1.5% plus 2 shillings, with a floor of 5 shillings, is:

percent_part = amount * bps / 10000     // integer division, truncating
fee          = percent_part + fixed
fee          = max(floor, min(ceiling, fee))

All integers. The division truncates, which is a decision rather than an accident: it means the rounding always goes the same way, and which way it goes is a commercial choice you make once and write down, rather than an artefact of the IEEE 754 spec.

The parts people miss

The database column. numeric is exact and is a perfectly good answer. real and double precision are not. The trap is that a column typed numeric can still be read into a float by an ORM that has helpfully chosen a native type for you, at which point you have exactness in storage and drift in memory, which is the worst combination because it looks fine in the database.

JSON. JSON.parse produces a double for every number. If your API sends 18.50, the client reads a float whether you intended that or not. Sending 1850 and documenting that amounts are in cents removes the ambiguity entirely, and has the side effect of making it obvious when somebody has sent shillings by mistake, because the number is a hundred times too small rather than slightly off.

The input field. A person types 1,850.50 into a box. Somewhere that becomes a number, and the most natural way to write that conversion is parseFloat. Parse the string yourself: strip the separators, split on the decimal point, validate that there are at most two digits after it, and assemble the integer. More code, and the code is boring and testable, which is what you want standing between a person and a money column.

Very large integers. In JavaScript a Number holds integers exactly up to about 9 quadrillion, which in cents is about 90 trillion shillings. Fine for any real balance, and not fine if you are summing every transaction in the platform's history into one accumulator, which is exactly the sort of thing a reporting query does. Use BigInt, or do the sum in the database.

What it buys you

Not elegance. The code is slightly more verbose and you spend a little time on conversion at the edges.

What you get is that an entire category of bug stops existing. Not "becomes rare": stops existing. Your statement reconciles with your transaction list exactly, every time, because they are summing the same integers. A balance comparison does what it says. A fee is a number you can reproduce by hand on paper and get the same answer.

In a system whose single job is to be right about money, removing a class of error entirely is worth some verbosity at the edges. It is one of the few decisions in this business that is genuinely free of trade-offs, which is why we would rather spend six minutes of your attention on it than on something more interesting.


If you are integrating, the practical version is: amounts in and out of our API are integers in cents, and the field is documented that way. The glossary entry on minor units is the short version, and the API overview has the rest.

Disagree with any of this?

Genuinely. If something here is wrong, or right for the wrong reason, we would rather be told than keep it published. Corrections get made and credited.