Core Java

Money in Java: Why double Ruins Ledgers, and BigDecimal Against Minor Units

Every developer meets the same surprise sooner or later: 0.1 + 0.2 does not equal 0.3. In a toy program, that is a curiosity. In a ledger, it becomes a reconciliation report that never balances.

In this article, we see why double cannot hold money, how BigDecimal fixes it, and why many payment systems prefer plain integers in minor units. We also measure the damage and the cost, so the advice rests on numbers.

Why double Cannot Hold Money

A double stores numbers in binary. Many simple decimal values, such as 0.10, have no exact binary form. Consequently, Java quietly stores the nearest value instead, and small errors follow. For example, adding 0.10 ten times gives 0.9999999999999999, and adding it a million times is off by about 0.0000013.

The error is tiny, so why does it matter?

Here is the honest part. The size of the error is microscopic. In my tests, the worst difference was about five millionths of a cent. However, accounting systems do not ask “is it close?” They ask “is it equal?”, and on that question double fails badly.

I generated random ledgers of payments between $0.01 and $1,000.00. For each ledger, I added the amounts forward and backward, and compared both totals with the exact total in cents.

Payments per ledgerTotal changes with orderTotal differs from exactlong or BigDecimal
1050.6%39.9%0%
10084.3%79.7%0%
1,00095.8%94.2%0%
10,00098.1%97.4%0%
Figure 1. Share of ledgers where a double total is wrong or order-dependent. Data note: my own run on JDK 25, 20,000 ledgers per size (4,000 for the largest), random amounts, fixed seed.

In other words, with only 100 payments, the same data produced two different totals in most ledgers. Rounding is worse still. For half-cent amounts such as 1.005 dollars, Math.round(x * 100) was wrong in 4.6% of 100,000 cases, because 1.005 * 100 evaluates to 100.49999999999999 and rounds down.

BigDecimal: Exact, but Easy to Misuse

The Java package documentation describes BigDecimal as suitable for currency calculations and notes that it gives full control over rounding, with eight rounding modes. That makes it the natural fix. Still, three traps catch newcomers.

First, never build one from a double. The BigDecimal documentation says the BigDecimal(double) constructor gives the exact decimal value of the double’s binary number. For 0.1, that is a long string of digits. Use the String constructor or BigDecimal.valueOf instead.

Second, equals compares scale as well as value, so 2.0 and 2.00 are not equal, while compareTo says they are. Third, always set the scale and rounding mode yourself, instead of relying on defaults.

Minor Units: Store Integers

Many payment systems sidestep the problem by storing whole numbers of the smallest currency unit. Stripe’s currency documentation says all API amounts are in the currency’s minor unit, so a zero-decimal currency such as JPY sends 500 for 500 yen. Integers add exactly, in any order, and they fit in a long.

Minor units are not always cents, though. Java’s Currency class reports 2 fraction digits for USD, 0 for JPY and 3 for KWD, which I confirmed by running it. Even Stripe notes a few quirks, such as ISK and UGX being zero-decimal but represented with two decimals for backward compatibility. Therefore, always look up the digits per currency.

A Snippet You Can Run

This program shows the main points in one place. It needs only Java 17 or newer.

cat > Money.java <<'JAVA'
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;

public class Money {
    public static void main(String[] args) {
        System.out.println(0.1 + 0.2);
        System.out.println(new BigDecimal(0.1));
        System.out.println(new BigDecimal("0.10")
                .add(new BigDecimal("0.20")));
        System.out.println(new BigDecimal("2.0")
                .equals(new BigDecimal("2.00")));
        System.out.println(new BigDecimal("2.0")
                .compareTo(new BigDecimal("2.00")));

        long cents = 1999;
        int digits = Currency.getInstance("USD")
                .getDefaultFractionDigits();
        System.out.println(BigDecimal.valueOf(cents, digits));

        BigDecimal fee = new BigDecimal("19.99")
                .multiply(new BigDecimal("0.029"))
                .setScale(2, RoundingMode.HALF_EVEN);
        System.out.println(fee);
    }
}
JAVA
java Money.java

I ran it on JDK 17, 21 and 25, and every one printed these seven lines:

0.30000000000000004
0.1000000000000000055511151231257827021181583404541015625
0.30
false
0
19.99
0.58

What the Numbers Say

Exactness has a price, so I measured it. Each test ran over one million random amounts, using the median of nine passes. The fee test takes 2.9% of each amount and rounds to the cent.

Operation (ns per amount)longdoubleBigDecimal
Add to a running total0.500.718.86
Fee of 2.9%, rounded1.240.7231.5
Figure 2. Time per operation in nanoseconds. Data note: my own run on JDK 25 in a one-CPU sandbox, no JMH harness; treat the ratios as indicative.

BigDecimal was about 18 times slower than long for sums and about 25 times slower for fees. Even so, 31 nanoseconds per fee means a million fees cost about 31 milliseconds, which rarely matters. Interestingly, the double fee matched the exact fee on all one million random amounts, because random amounts almost never land on a half cent. The danger hides in the boundary cases, not in the average.

Which One Should You Use?

ApproachExactSpeedBest for
doubleNoFastestNever for money
BigDecimalYesSlowerRates, tax, conversions, rounding
long in minor unitsYesFastStoring and adding amounts

A sensible combination follows from this. Store and add amounts as long minor units. When a calculation needs fractions, such as a tax rate, use BigDecimal, then round once to the currency’s minor unit with an explicit rounding mode.

Quick checklist

  • Never use double or float for money.
  • Create BigDecimal from a String or valueOf, not from a double.
  • Compare with compareTo, not equals.
  • Look up the minor-unit digits for each currency.
  • Round once, at the end, with an explicit rounding mode.

What We Have Learned

A double cannot represent most decimal amounts exactly, so its errors are tiny but its comparisons break. In our test, double totals changed with the order of addition in 50.6% of small ledgers and 98.1% of large ones, and half-cent rounding failed in 4.6% of cases. BigDecimal is exact and offers full control over rounding, though it is about 18 to 25 times slower than long in our measurements, and it hides traps with constructors and equals. Integer minor units are exact and fast, but the number of digits depends on the currency. In short, store money as long minor units, use BigDecimal for fractional calculations, and never let a double touch a balance.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button