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.
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));
}
}ten dimes = 0.9999999999999999
equals 1.0: false
1.03 - 0.42 = 0.6100000000000001
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625The 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.
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")));
}
}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.87512.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).
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()));
}
}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: trueThe 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.
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));
}
}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.3450UP 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.
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));
}
}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+4A 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.
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);
}
}
}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=1234568movePointRight(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.
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());
}
}
}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 overflowRoundingMode.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.
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})));
}
}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.01Every 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
doublecannot represent most decimal fractions; useBigDecimalorlongminor units for money.- Build
BigDecimalfromStringorBigDecimal.valueOf, nevernew BigDecimal(double). equalscompares value and scale (2.0is not2.00);compareTocompares value.HashSetandTreeSetdisagree on size for the same inputs.dividewithout scale orMathContextthrowsArithmeticExceptionfor non-terminating results.HALF_EVENavoids 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). longminor units avoid allocation; guard arithmetic withMath.addExactandMath.multiplyExact.- Allocation distributes the remainder explicitly so parts sum to the total.
Summary
BigDecimalvalue = unscaled value × 10^-scale; precision is the digit count of the unscaled value.addkeeps the larger scale;multiplyadds scales.setScalewithout a rounding mode throws if digits would be lost.MathContextsets significant digits and a rounding mode;DECIMAL64is 16 digits,HALF_EVEN.stripTrailingZeroscan produce a negative scale;toPlainStringprints without exponent.- A
Moneyrecord normalizes scale in its compact constructor and rejects mixed currencies. - Splitting a total uses integer division plus explicit distribution of the remainder.
