Guide contents

Lesson 61 of 71

Money, BigDecimal, and Rounding

Money code must produce the exact amount a regulator, an auditor, or a customer expects, to the last minor unit (a cent, a yen, a fils). This lesson shows why binary floating point cannot do that, how java.math.BigDecimal represents decimal values exactly, and how to round, compare, and split amounts. It ends with a Money record and a long-based fixed-point form used on low-latency paths.

Idea What it gives
BigDecimal Exact decimal value: an unscaled integer and a scale
equals vs compareTo Value-and-scale equality vs numeric equality
RoundingMode, setScale Explicit rounding to a number of decimal places
MathContext Rounding to a number of significant digits
Currency.getDefaultFractionDigits Minor units per ISO 4217 currency
long minor units Allocation-free fixed-point money
Allocation Splitting a total into parts that add up exactly

Example 1: why double fails for money

A double is a binary fraction (see Types, Variables, and Expressions). Most decimal fractions, such as 0.1, have no exact binary form, so each stored value is slightly off and the errors add up.

Java
public class DoubleMoney {
    public static void main(String[] args) {
        double total = 0;
        for (int i = 0; i < 10; i++) total += 0.10;
        System.out.println("ten dimes = " + total);
        System.out.println("equals 1.0: " + (total == 1.0));
        System.out.println("1.03 - 0.42 = " + (1.03 - 0.42));
        System.out.println("new BigDecimal(0.1) = " + new java.math.BigDecimal(0.1));
    }
}
Output
ten dimes = 0.9999999999999999
equals 1.0: false
1.03 - 0.42 = 0.6100000000000001
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625

The last line shows the exact binary value that the literal 0.1 stores. The constructor new BigDecimal(double) copies that inexact value. BigDecimal.valueOf(double) and new BigDecimal(String) use the decimal text instead, so money code builds amounts from strings or integers.

Example 2: scale, precision, and unscaled value

A BigDecimal is an arbitrary-precision integer, the unscaled value, together with a 32-bit scale. Its value is unscaledValue × 10^-scale. Precision is the number of digits in the unscaled value.

Java
import java.math.BigDecimal;

public class ScaleAndPrecision {
    static void show(String text) {
        BigDecimal d = new BigDecimal(text);
        System.out.println(text + " -> unscaled=" + d.unscaledValue()
                + " scale=" + d.scale() + " precision=" + d.precision());
    }

    public static void main(String[] args) {
        show("12.30");
        show("12.3");
        show("0.005");
        show("1E+3");
        System.out.println("valueOf(0.1) = " + BigDecimal.valueOf(0.1));
        System.out.println("0.10 + 0.20 = " + new BigDecimal("0.10").add(new BigDecimal("0.20")));
        System.out.println("1.5 * 1.25 = " + new BigDecimal("1.5").multiply(new BigDecimal("1.25")));
    }
}
Output
12.30 -> unscaled=1230 scale=2 precision=4
12.3 -> unscaled=123 scale=1 precision=3
0.005 -> unscaled=5 scale=3 precision=1
1E+3 -> unscaled=1 scale=-3 precision=1
valueOf(0.1) = 0.1
0.10 + 0.20 = 0.30
1.5 * 1.25 = 1.875

12.30 and 12.3 are the same number with different scales. Addition keeps the larger scale of the two operands, and multiplication adds the scales (1 + 2 = 3). A negative scale, as in 1E+3, means trailing zeros to the left of the decimal point.

Example 3: equals vs compareTo

BigDecimal.equals compares the unscaled value and the scale. compareTo compares only the numeric value. This matters for hash-based collections, which use equals and hashCode, and for sorted ones, which use compareTo (see Object Contracts).

Java
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;

public class EqualsVsCompareTo {
    public static void main(String[] args) {
        BigDecimal a = new BigDecimal("2.0");
        BigDecimal b = new BigDecimal("2.00");
        System.out.println("equals: " + a.equals(b));
        System.out.println("compareTo == 0: " + (a.compareTo(b) == 0));

        Set<BigDecimal> hash = new HashSet<>(Set.of(a, b));
        Set<BigDecimal> tree = new TreeSet<>(Set.of(a, b));
        System.out.println("HashSet size: " + hash.size());
        System.out.println("TreeSet size: " + tree.size());

        BigDecimal s = new BigDecimal("2.00").stripTrailingZeros();
        System.out.println("stripTrailingZeros(2.00) = " + s + " scale=" + s.scale());
        BigDecimal h = new BigDecimal("100").stripTrailingZeros();
        System.out.println("stripTrailingZeros(100) = " + h + " plain=" + h.toPlainString());
        System.out.println("after strip, equals: " + a.stripTrailingZeros().equals(b.stripTrailingZeros()));
    }
}
Output
equals: false
compareTo == 0: true
HashSet size: 2
TreeSet size: 1
stripTrailingZeros(2.00) = 2 scale=0
stripTrailingZeros(100) = 1E+2 plain=100
after strip, equals: true

The rule for money: compare amounts with compareTo, or normalize every amount to the currency's scale before it is stored as a key. stripTrailingZeros also normalizes, but it can produce a negative scale (1E+2), so toPlainString is used for display.

Example 4: RoundingMode and setScale

setScale(n, mode) changes the scale to n and rounds with the given RoundingMode. Without a mode, setScale throws ArithmeticException if rounding would be needed. HALF_EVEN, called banker's rounding, rounds a tie to the even neighbour, so ties round up and down equally often and the sum of many rounded values has no upward bias.

Java
import java.math.BigDecimal;
import java.math.RoundingMode;

public class Rounding {
    public static void main(String[] args) {
        String[] inputs = {"2.345", "2.355", "-2.345", "2.341"};
        RoundingMode[] modes = {RoundingMode.HALF_UP, RoundingMode.HALF_EVEN,
                RoundingMode.HALF_DOWN, RoundingMode.UP, RoundingMode.DOWN,
                RoundingMode.CEILING, RoundingMode.FLOOR};
        for (RoundingMode m : modes) {
            StringBuilder line = new StringBuilder(String.format("%-9s", m));
            for (String s : inputs) {
                line.append(String.format("%8s", new BigDecimal(s).setScale(2, m)));
            }
            System.out.println(line);
        }
        try {
            new BigDecimal("2.345").setScale(2);
        } catch (ArithmeticException e) {
            System.out.println("setScale(2) without mode: " + e.getMessage());
        }
        System.out.println("setScale(4) without mode: " + new BigDecimal("2.345").setScale(4));
    }
}
Output
HALF_UP      2.35    2.36   -2.35    2.34
HALF_EVEN    2.34    2.36   -2.34    2.34
HALF_DOWN    2.34    2.35   -2.34    2.34
UP           2.35    2.36   -2.35    2.35
DOWN         2.34    2.35   -2.34    2.34
CEILING      2.35    2.36   -2.34    2.35
FLOOR        2.34    2.35   -2.35    2.34
setScale(2) without mode: Rounding necessary
setScale(4) without mode: 2.3450

UP and DOWN move away from or toward zero. CEILING and FLOOR move toward positive or negative infinity, so they differ from UP and DOWN only for negative numbers. Increasing the scale never needs rounding, so setScale(4) works without a mode.

Example 5: division and MathContext

divide(BigDecimal) with no scale or context must return the exact quotient. When the decimal expansion does not end, as for 1/3, it throws ArithmeticException. Two ways out: give a scale and rounding mode, or give a MathContext, which is a precision (number of significant digits) plus a rounding mode.

Java
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;

public class Division {
    public static void main(String[] args) {
        BigDecimal ten = new BigDecimal("10.00");
        BigDecimal three = new BigDecimal("3");
        try {
            ten.divide(three);
        } catch (ArithmeticException e) {
            System.out.println("divide: " + e.getMessage());
        }
        System.out.println("exact 10.00/4 = " + ten.divide(new BigDecimal("4")));
        System.out.println("scale 2 HALF_EVEN = " + ten.divide(three, 2, RoundingMode.HALF_EVEN));
        System.out.println("DECIMAL64 = " + ten.divide(three, MathContext.DECIMAL64));
        System.out.println("DECIMAL64 precision = " + MathContext.DECIMAL64.getPrecision()
                + " mode = " + MathContext.DECIMAL64.getRoundingMode());
        MathContext mc = new MathContext(4, RoundingMode.HALF_EVEN);
        System.out.println("12345.678 round(4 digits) = " + new BigDecimal("12345.678").round(mc));
    }
}
Output
divide: Non-terminating decimal expansion; no exact representable decimal result.
exact 10.00/4 = 2.50
scale 2 HALF_EVEN = 3.33
DECIMAL64 = 3.333333333333333
DECIMAL64 precision = 16 mode = HALF_EVEN
12345.678 round(4 digits) = 1.235E+4

A MathContext limits significant digits, not decimal places, so 12345.678 rounded to four digits becomes 1.235E+4 (the fifth digit, 5 followed by 678, is above the half). Money results are usually rounded with setScale to the currency's minor units; MathContext fits intermediate values such as interest rates and FX rates.

Example 6: currencies and minor units

java.util.Currency holds ISO 4217 data. getDefaultFractionDigits returns the number of minor-unit digits: the yen has none, the US dollar has two, the Bahraini dinar has three.

Java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;

public class MinorUnits {
    public static void main(String[] args) {
        BigDecimal price = new BigDecimal("1234.5678");
        for (String code : new String[] {"JPY", "USD", "EUR", "BHD"}) {
            Currency c = Currency.getInstance(code);
            int digits = c.getDefaultFractionDigits();
            BigDecimal rounded = price.setScale(digits, RoundingMode.HALF_EVEN);
            long minor = rounded.movePointRight(digits).longValueExact();
            System.out.println(code + " digits=" + digits + " amount=" + rounded + " minor units=" + minor);
        }
    }
}
Output
JPY digits=0 amount=1235 minor units=1235
USD digits=2 amount=1234.57 minor units=123457
EUR digits=2 amount=1234.57 minor units=123457
BHD digits=3 amount=1234.568 minor units=1234568

movePointRight(digits) turns the amount into an integer count of minor units, and longValueExact throws if any fraction is left or the value does not fit in a long. Code that hard-codes two decimals breaks for JPY and BHD.

Example 7: a Money record and long-based fixed point

A record (see Records and Sealed Types) gives a value type with equals and hashCode. The compact constructor normalizes the scale to the currency's minor units, so two equal amounts are always equals. For hot paths, the same amount is stored as a long count of minor units: no object per operation and plain integer arithmetic, with Math.addExact to detect overflow.

Java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;

public class MoneyRecord {
    record Money(BigDecimal amount, Currency currency) {
        Money {
            amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
        }
        static Money of(String amount, String code) {
            return new Money(new BigDecimal(amount), Currency.getInstance(code));
        }
        Money plus(Money other) {
            if (!currency.equals(other.currency)) {
                throw new IllegalArgumentException(currency + " vs " + other.currency);
            }
            return new Money(amount.add(other.amount), currency);
        }
        Money times(BigDecimal factor, RoundingMode mode) {
            BigDecimal raw = amount.multiply(factor);
            return new Money(raw.setScale(currency.getDefaultFractionDigits(), mode), currency);
        }
        long minorUnits() {
            return amount.movePointRight(amount.scale()).longValueExact();
        }
    }

    public static void main(String[] args) {
        Money a = Money.of("10.5", "USD");
        Money b = Money.of("10.50", "USD");
        System.out.println(a + " equals " + b + ": " + a.equals(b));
        System.out.println("sum = " + a.plus(Money.of("0.25", "USD")).amount());
        System.out.println("tax 8.875% = " + a.times(new BigDecimal("0.08875"), RoundingMode.HALF_EVEN).amount());
        try {
            Money.of("1.005", "USD");
        } catch (ArithmeticException e) {
            System.out.println("1.005 USD rejected: " + e.getMessage());
        }
        try {
            a.plus(Money.of("100", "JPY"));
        } catch (IllegalArgumentException e) {
            System.out.println("mixed currency rejected: " + e.getMessage());
        }

        long cents = a.minorUnits();
        long total = Math.addExact(cents, 25);
        System.out.println("long cents = " + cents + ", plus 25 = " + total
                + ", display = " + BigDecimal.valueOf(total, 2));
        try {
            Math.addExact(Long.MAX_VALUE, 1L);
        } catch (ArithmeticException e) {
            System.out.println("overflow detected: " + e.getMessage());
        }
    }
}
Output
Money[amount=10.50, currency=USD] equals Money[amount=10.50, currency=USD]: true
sum = 10.75
tax 8.875% = 0.93
1.005 USD rejected: Rounding necessary
mixed currency rejected: USD vs JPY
long cents = 1050, plus 25 = 1075, display = 10.75
overflow detected: long overflow

RoundingMode.UNNECESSARY makes the constructor reject an amount with more digits than the currency allows, instead of rounding it silently. BigDecimal.valueOf(long unscaled, int scale) converts minor units back for display. Trading systems often use a long with a fixed scale for prices too (for example 10^-8), and agree on the scale per instrument.

Example 8: allocating a total without losing cents

Splitting 100.00 three ways gives 33.33 each, which sums to 99.99. Allocation fixes this: compute each share in minor units with integer division, then give the leftover units one by one to the first parts. The same method handles weighted splits (ratios), with the remainder going to the parts with the largest fractional remainders or, as in this code, to the first parts in a fixed order.

Java
import java.math.BigDecimal;
import java.util.Arrays;

public class Allocation {
    static long[] allocate(long totalMinor, long[] weights) {
        long weightSum = Arrays.stream(weights).sum();
        long[] parts = new long[weights.length];
        long assigned = 0;
        for (int i = 0; i < weights.length; i++) {
            parts[i] = Math.multiplyExact(totalMinor, weights[i]) / weightSum;
            assigned += parts[i];
        }
        long remainder = totalMinor - assigned;
        for (int i = 0; remainder > 0; i = (i + 1) % parts.length, remainder--) {
            parts[i]++;
        }
        return parts;
    }

    static String show(long[] parts) {
        StringBuilder sb = new StringBuilder();
        long sum = 0;
        for (long p : parts) {
            sb.append(BigDecimal.valueOf(p, 2)).append(' ');
            sum += p;
        }
        return sb + "sum=" + BigDecimal.valueOf(sum, 2);
    }

    public static void main(String[] args) {
        BigDecimal naive = new BigDecimal("100.00").divide(new BigDecimal("3"), 2, java.math.RoundingMode.HALF_EVEN);
        System.out.println("naive third = " + naive + ", times 3 = " + naive.multiply(new BigDecimal("3")));
        System.out.println("equal 3 ways: " + show(allocate(10_000, new long[] {1, 1, 1})));
        System.out.println("ratio 70/20/10 of 0.05: " + show(allocate(5, new long[] {70, 20, 10})));
        System.out.println("ratio 1/1 of 0.01: " + show(allocate(1, new long[] {1, 1})));
    }
}
Output
naive third = 33.33, times 3 = 99.99
equal 3 ways: 33.34 33.33 33.33 sum=100.00
ratio 70/20/10 of 0.05: 0.04 0.01 0.00 sum=0.05
ratio 1/1 of 0.01: 0.01 0.00 sum=0.01

Every split sums to the input. The order that receives the leftover units is a business rule (first party, largest share, or a fixed account), and it must be deterministic so that reruns give the same result.

Interview points

  • double cannot represent most decimal fractions; use BigDecimal or long minor units for money.
  • Build BigDecimal from String or BigDecimal.valueOf, never new BigDecimal(double).
  • equals compares value and scale (2.0 is not 2.00); compareTo compares value. HashSet and TreeSet disagree on size for the same inputs.
  • divide without scale or MathContext throws ArithmeticException for non-terminating results.
  • HALF_EVEN avoids systematic bias when many values are rounded; regulations or contracts may require a specific mode.
  • Minor units come from Currency.getDefaultFractionDigits (JPY 0, USD 2, BHD 3).
  • long minor units avoid allocation; guard arithmetic with Math.addExact and Math.multiplyExact.
  • Allocation distributes the remainder explicitly so parts sum to the total.

Summary

  • BigDecimal value = unscaled value × 10^-scale; precision is the digit count of the unscaled value.
  • add keeps the larger scale; multiply adds scales.
  • setScale without a rounding mode throws if digits would be lost.
  • MathContext sets significant digits and a rounding mode; DECIMAL64 is 16 digits, HALF_EVEN.
  • stripTrailingZeros can produce a negative scale; toPlainString prints without exponent.
  • A Money record normalizes scale in its compact constructor and rejects mixed currencies.
  • Splitting a total uses integer division plus explicit distribution of the remainder.