Handling Money in Code: Never Use Floats

Every engineer who has spent time near a payments ledger eventually learns the same lesson, usually at the worst possible moment: a reconciliation report is...

Originally published onanselmfowel.com

Every engineer who has spent time near a payments ledger eventually learns the same lesson, usually at the worst possible moment: a reconciliation report is off by a penny, then by a few pennies, then by an amount that nobody can explain. The code looks correct. The tests pass. The logic, read aloud, is obviously right. And yet the books do not balance, and an auditor is now asking why.

Handling Money in Code: Never Use Floats
Handling Money in Code: Never Use Floats

In nearly every case I have investigated, the root cause is the same humble mistake. Somewhere, somebody stored or computed money in a floating-point number. I want to walk through why this is so dangerous, why it is so easy to get wrong, and what we do instead at the systems I am responsible for. This is not a stylistic preference. In regulated finance, it is closer to a rule.

Why Floating-Point Math Betrays You

Floating-point numbers, the IEEE 754 doubles and floats that back the primitive types in most languages, are designed to represent an enormous range of values with a fixed number of bits. They do this brilliantly for physics, graphics, and machine learning. The trade-off is that they store values in base two, and most decimal fractions simply do not have an exact representation in base two. The value 0.1 is not really 0.1 in a double; it is the nearest binary approximation, and that approximation carries a tiny error.

For a single value this error is invisible. The trouble begins when you do arithmetic, especially repeated addition over thousands or millions of transactions. The small errors accumulate, and because they do not cancel cleanly, you drift. The classic demonstration is that 0.1 plus 0.2 does not equal 0.3 in floating-point arithmetic; it produces 0.30000000000000004. That extra fragment is not a bug in your language. It is the format behaving exactly as specified.

The first time a junior engineer shows me a money bug, I do not ask what their code does. I ask what type they used. Nine times out of ten, the answer is the bug.

The Penny Problem at Scale

Consider a payout run that distributes a settlement across ten thousand merchants. Each share is computed as a percentage of a pool, rounded to the cent. If those intermediate shares are held in doubles, each one carries a sub-cent error. Individually, none of them matters. But when you sum them back up to confirm that the parts equal the whole, the total no longer matches the pool you started with. You are now short, or over, by an amount that grew out of nothing but rounding noise.

This is not a theoretical concern. A ledger that does not foot to the penny is a ledger you cannot certify. Finance teams reconcile to zero, not to "close enough." When the difference is small, the temptation is to plug it with an adjustment entry, but that is how you teach an organization to tolerate unexplained variance. The right answer is to make the arithmetic exact so that the difference never appears in the first place.

Use a Decimal Type, Not a Binary One

The fix is to stop using binary floating-point for monetary values and use a base-ten decimal type instead. In .NET this is the decimal type, a 128-bit value that stores numbers in base ten with up to 28 to 29 significant digits and an explicit scale. It represents 0.1 as exactly one tenth, and 0.1 plus 0.2 as exactly 0.3. Java has BigDecimal, Python has the decimal module, and most modern languages offer something equivalent.

The difference is stark when you see it side by side. The double accumulates error; the decimal does not.

using System;

decimal a = 0.1m;
decimal b = 0.2m;
Console.WriteLine(a + b);        // prints 0.3 exactly

double x = 0.1;
double y = 0.2;
Console.WriteLine(x + y);        // prints 0.30000000000000004

// A payout split that must foot back to the pool
decimal pool = 100.00m;
int shares = 3;
decimal each = Math.Round(pool / shares, 2, MidpointRounding.ToEven);
decimal distributed = each * shares;          // 99.99
decimal remainder = pool - distributed;       // 0.01 -- allocate it explicitly
Console.WriteLine($"{each} x {shares} = {distributed}, remainder {remainder}");

Notice that even with a decimal type, three does not divide one hundred evenly. The decimal type does not make rounding disappear; it makes rounding explicit and deterministic. That last cent has to go somewhere, and a good system decides where on purpose rather than letting the floating-point unit decide by accident.

The Case for Integer Minor Units

There is a second school of thought that I respect and use in certain systems: represent money as an integer count of the smallest unit of the currency. Instead of 12.34 dollars you store 1234 cents. Integers are exact by definition, they serialize cleanly, they compare without surprises, and they are immune to any decimal-scale confusion. Many high-throughput ledgers and most card networks model amounts this way.

The catch is that you must always carry the currency alongside the integer, because the number of minor units per major unit varies. Most currencies use two decimal places, but the Japanese yen uses none, and a few currencies such as the Tunisian dinar use three. An amount of "1000" is meaningless without knowing whether it is yen, cents, or thousandths of a dinar. Whichever representation you choose, the rule is the same:

  • Pick one canonical representation for stored money and never mix it with another.
  • Always pair an amount with an explicit currency code; a bare number is not money.
  • Decide your rounding mode deliberately and apply it consistently across the system.
  • Make illegal states unrepresentable by wrapping amount and currency in a single type.
  • Never let a floating-point value enter the system, not even at the edges where data arrives.

Rounding Is a Business Decision, Not a Default

Once you accept that division will produce remainders, rounding stops being a technical afterthought and becomes a policy you must define. The two questions that matter are which direction you round and how you handle the exact halfway case. Banker's rounding, which rounds halves to the nearest even digit, is the default in .NET's decimal and is preferred in many financial contexts because it does not introduce a systematic upward bias over large numbers of operations.

But the right rule depends on the jurisdiction and the contract. Tax calculations often have legally mandated rounding behavior that differs from what your language does by default. Interest accrual may need to round per period rather than at the end. Currency conversion may need to round at a specific step in the pipeline. I have seen disputes resolved purely by demonstrating which rounding mode was applied and when, so this should be documented, tested, and owned, not left implicit.

The discipline I insist on is that rounding happens at well-defined boundaries and never silently in the middle of a calculation. Carry full precision through intermediate steps, round once at the point where a real-world amount is produced, and record that you did so.

Storing Money in the Database

The same rule extends to your schema. A money column should never be a FLOAT or a DOUBLE PRECISION. In SQL the correct type is NUMERIC or DECIMAL with an explicit precision and scale, which stores the value exactly as written rather than as a binary approximation. If you store amounts as integer minor units, use a BIGINT and keep the currency in an adjacent column.

CREATE TABLE ledger_entry (
    id           BIGSERIAL PRIMARY KEY,
    account_id   BIGINT      NOT NULL,
    -- exact, never FLOAT: up to 19 digits, 4 after the point
    amount       NUMERIC(19,4) NOT NULL,
    currency     CHAR(3)     NOT NULL,    -- ISO 4217 code
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (currency = upper(currency))
);

-- This sum is exact and will foot to the penny
SELECT account_id, currency, SUM(amount) AS balance
FROM   ledger_entry
GROUP  BY account_id, currency;

I choose a scale of four rather than two so there is room for sub-cent precision in intermediate values such as interest or unit prices, while still rounding to two when a customer-facing amount is produced. The key point is that the database, the application layer, and any serialization format in between must all agree on an exact representation. A double sneaking in through a JSON parser or an ORM mapping will undo all your careful work upstream.

Guard the Edges and the Wire Format

The places money most often turns into a float are the boundaries: a JSON API, a CSV import, a message on a queue, a spreadsheet export. JSON in particular has no money type, and many libraries deserialize a numeric literal straight into a double. The safest practice I have adopted is to transmit money as a string, parse it explicitly into a decimal, and reject anything that does not match the expected format.

This feels pedantic until you trace a production incident back to a single API client that sent an amount as a JSON number, watched it round-trip through a double somewhere in the middle, and arrived a fraction of a cent off. Treating money as a string on the wire removes that entire class of failure. It also forces every integration to be explicit about currency and scale, which is exactly the conversation you want to have at integration time rather than at audit time.

Testing and Auditing Money Code

Money code deserves tests that ordinary business logic does not. Beyond the usual examples, I want property-based tests that assert invariants: that a sum of allocations equals the original total, that converting and converting back lands on the same value within the defined tolerance, that no operation ever produces more precision than the currency allows. These tests catch the drift that a single hand-picked example will miss.

Equally important is observability. Every monetary calculation that involves rounding should leave a trace of what it did, so that when finance asks why a payout was 99.99 rather than 100.00, the answer is in the logs rather than in a reconstruction from memory. The remainder cent did not vanish; it was allocated to a specific entry, and that decision is recorded. An auditable system is one where every penny can be accounted for, and that is far easier to build when the arithmetic was exact from the start.

Conclusion

The instruction to never use floats for money sounds dogmatic, and I deliver it that way on purpose, because the failure mode is subtle, cumulative, and expensive. Floating-point types are a superb tool for the problems they were designed to solve, and money is not one of them. Use a decimal type or integer minor units, pair every amount with its currency, decide your rounding rules explicitly, store exact values in the database, treat money as a string on the wire, and test the invariants that keep your ledger balanced. Do that, and the penny that used to haunt your reconciliation reports simply never appears. In a regulated business, that quiet absence of mystery is worth more than any clever optimization.

Chat with us