BigDecimal is a life saver when it comes to financial applications. If you are doing lots of decimal math where correctness is non negotiable, things like taxes, interest, and FX conversions, BigDecimal gives you clear guarantees around decimal behavior, correctness, and explicit rounding rules, something floating point cannot offer.
However, BigDecimal is also an object-heavy type, and it is immutable. Immutability is great for safety, but it also means every add, subtract, multiply, divide, and scale adjustment produces a new object.
In normal business applications, this is usually fine. The performance cost may never show up in a meaningful way. But in high performance systems, processing large volumes of transactions per second, those extra allocations and the GC pressure have an obvious cost.
So BigDecimal is not a fit for everything in a money system. There are cases where you really do not need it, where correctness can be achieved without paying the performance cost.
In this post, we will make it clear when and when not to use BigDecimal, what the real performance cost looks like, and we will propose a simple storage model based on use case so you can stay fast without losing correctness.
Money in Code: Final Amounts vs Calculation Inputs
In real systems, values that look like money usually fall into two categories. The first is final amounts. These are the numbers your system posts, stores, sums, compares, audits, and moves around as money. Balances, ledger entries, charges, refunds, and invoice totals all belong here.
The second is calculation inputs, that is rates and intermediate values. These are inputs like FX rates, interest rates, and unit prices. They are not the final outcome of a transaction. Because they are mathematical inputs used to compute that final amount, most often they require much higher precision.
This distinction matters because the two categories have different needs. Final amounts need to be stable, cheap to process, and unambiguous. Rates and intermediate values need decimal precision. A lot of money models become messy because they treat both categories as if they were the same thing.
Why BigDecimal Gets Expensive in High Throughput code
BigDecimal is built for correctness. It is immutable, and it represents a decimal number as an unscaled integer plus a scale. That is what gives it reliable decimal behavior and explicit rounding control, but it also makes it costly in parts of your code that run repeatedly, sometimes millions of times.
The first cost is object churn. Because BigDecimal is immutable, an add() does not change the existing value. It creates a new BigDecimal and returns it. Same for subtract, multiply, divide, and scale changes. That is a lot of object allocation, and it gets worse when you are doing it at volume. Imagine processing 1,000,000 transactions and updating balances or totals in a loop. Even simple calculations create a stream of short-lived BigDecimal objects. The JVM then has to keep allocating and collecting them, and the result is GC pressure.
The second cost is the work per operation. A primitive long add is a single cheap CPU operation on fixed-width numbers. BigDecimal cannot do that, because before it adds two values it may need to line up their decimal scales, apply precision rules, and route through more general decimal arithmetic logic than a primitive ever needs.
So you are paying in two ways: extra work per operation and extra objects per operation.
What High Performance Systems Do Instead
In case you are building a system that handles large transaction volume, you want to stay as close to primitives as possible. Balance updates and ledger style arithmetic are mostly add, subtract, and compare, so many high performance systems represent final amounts as integers in minor units. Minor units simply means storing the smallest currency unit as a whole number. So instead of storing “10.99 USD” as a decimal, you store 1099 with the currency code. This enforces a single stable representation for final amounts and avoids paying the cost of BigDecimal math
BigDecimal still stays useful when you are dealing with rates and intermediate calculations, things like FX rates, interest rates, and unit prices. But once you compute the result, you round once and store the final amount back as minor units.
How to Model Money Without BigDecimal Everywhere
Make final money a primitive-friendly value type
For final amounts, simply use the currency plus minor units as a long. This is what you want for balances and ledger entries, for summing large batches of transactions, and for moving money values safely through APIs, messages, storage, and caches.
public record Money(String currency, long minor) {
public static Money ofMinor(String currency, long minor) {
return new Money(currency, minor);
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(currency, Math.addExact(minor, other.minor));
}
public Money minus(Money other) {
requireSameCurrency(other);
return new Money(currency, Math.subtractExact(minor, other.minor));
}
private void requireSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currency mismatch: " + currency + " vs " + other.currency
);
}
}
}
2. Keep rates as BigDecimal
Rates are not final money. They are math inputs, and this is exactly where BigDecimal belongs.
public record FxRate(String from, String to, BigDecimal rate) {
public FxRate {
if (rate == null || rate.signum() <= 0) {
throw new IllegalArgumentException("rate must be > 0");
}
}
}
3. Round once, at settlement
Simply convert to BigDecimal only to do the rate math, then round once and return to minor units.
public final class MoneyFx {
public static Money convert(Money amount, FxRate rate, RoundingMode rounding) {
if (!amount.currency().equals(rate.from())) {
throw new IllegalArgumentException("Amount currency does not match rate.from()");
}
int sourceExponent = exponent(amount.currency());
int targetExponent = exponent(rate.to());
BigDecimal major = BigDecimal.valueOf(amount.minor())
.movePointLeft(sourceExponent);
BigDecimal convertedMajor = major.multiply(rate.rate());
BigDecimal roundedMajor = convertedMajor.setScale(targetExponent, rounding);
long targetMinor = roundedMajor
.movePointRight(targetExponent)
.longValueExact();
return Money.ofMinor(rate.to(), targetMinor);
}
private static int exponent(String currencyCode) {
return Currency.getInstance(currencyCode).getDefaultFractionDigits();
}
}
Conclusion
In designing any system, it is all about tradeoffs. This is not a post to say BigDecimal is wrong or right. It is about being mindful of the performance cost and using the right tool in the right place.
If you are not handling large volumes of transactions, and you prioritize simplicity and straightforward reporting over raw throughput, then using BigDecimal for stored amounts can be a perfectly reasonable choice.
But in large systems, you must be mindful of the cost. It is also important to note that most payment gateways and payment style APIs represent amounts as whole numbers in minor units with a currency code. They enforce one stable representation for final amounts, and they keep decimal precision where it actually matters.
So the rule is simple: keep final amounts primitive and stable, and use BigDecimal where the math truly needs it. This gives you correctness without making your most frequently executed code slow.



![Stop Storing Flat Data Like Objects: Why int[] Beats Integer[] in Java primitive vs object arrays](https://heappulse.com/wp-content/uploads/2026/03/primitive_vs_object_arrays-768x337.png)
