Exact Prices in Java: Decimal Strings and BigDecimal Traps
JavaJava Core

Exact Prices in Java: Decimal Strings and BigDecimal Traps

Prices look like the simplest data there is: a number with a few decimals. Then a crypto price arrives with fifteen decimal places, a token trades at 0.000012341873905, and the numbers on screen stop adding up. This article walks through what goes wrong between the JSON and the screen, and how to keep every digit with BigDecimal and Jackson. For the wider trade-offs between the number types, see double, BigDecimal, or Fixed-Point? on foojay; this article follows one price from the JSON to the screen.

Why double is the wrong type for a price

A double stores a binary fraction, and most decimal fractions have no exact binary form. You can see it with three lines:

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        System.out.println(0.1 + 0.2);
        System.out.println(63520.780763913 * 0.75);
        System.out.println(new BigDecimal("63520.780763913").multiply(new BigDecimal("0.75")));
    }
}
0.30000000000000004
47640.585572934746
47640.58557293475

The second line is the value of 0.75 BTC at a price of 63,520.780763913 USD, and the double already gets the last digits wrong. The errors also add up: adding one satoshi (0.00000001) a hundred million times gives 1.0000000022898672 as a double, and exactly 1.00000000 as a BigDecimal.

Rounding for display hides the error, which is why it survives code review. As soon as you sum, compare or store the values, it becomes a bug report.

Why price APIs send strings

JSON has a single number type, and most parsers turn a number with a fraction into a double. Jackson’s tree model (readTree) and untyped targets such as Map<String, Object> do exactly that (the examples use Jackson 2, the 2.17 line the SDK below depends on):

ObjectMapper mapper = new ObjectMapper();
JsonNode p = mapper.readTree("{\"p\": 63520.780763913123456}").get("p");

p.getClass();       // DoubleNode
p.decimalValue();   // 63520.78076391312

The digits are gone before your code sees them, and decimalValue() only wraps the rounded double. Binding straight into a BigDecimal field is exact; the loss happens in the tree and in untyped maps. If you control the parser, DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS keeps the full value there too, but the tree then strips trailing zeros (1200.00 comes back as 1.2E+3), so print those values with toPlainString().

You often don’t control every client, though, so many market-data APIs send prices as strings instead. The Bitculator Data API, which this article uses for its example, returns each coin as a row like this one (abridged; the Bitculator Data API documentation lists every field):

{
  "slug": "bitcoin",
  "symbol": "BTC",
  "price": "63520.780763913",
  "marketcap": 1251359381049.09,
  "change_24h": 1.35
}

The price is a decimal string with trailing zeros trimmed and never in E-notation. Values where a rounded double is acceptable, such as the market cap and the percentage change, stay JSON numbers.

Reading a decimal string with Jackson

A string price arrives as a TextNode, and that is where the first trap is:

JsonNode price = mapper.readTree("{\"price\": \"63520.780763913\"}").get("price");

price.decimalValue();             // 0
price.asDouble();                 // 63520.780763913, but now a double
new BigDecimal(price.asText());   // 63520.780763913

decimalValue() is defined on JsonNode and returns BigDecimal.ZERO for anything that isn’t a number node. There is no exception and no warning, just a price of zero. asDouble() parses the text, which brings back the double problem. new BigDecimal(node.asText()) is the conversion you want. Don’t use node.toString() here: it returns the quoted JSON form, and the BigDecimal constructor rejects the quotes.

If you map responses to records, Jackson converts the string for you, because it coerces a JSON string into a BigDecimal field by default:

@JsonIgnoreProperties(ignoreUnknown = true)
record Quote(String symbol, BigDecimal price) {}

Quote btc = mapper.readValue(
        "{\"symbol\":\"BTC\",\"price\":\"63520.780763913\",\"rank\":1}", Quote.class);
// Quote[symbol=BTC, price=63520.780763913]

The annotation matters with real API payloads, which carry more fields than your record. Jackson 2 fails on unknown properties by default.

Jackson 3 (package tools.jackson) changes some of this. The string node is a StringNode, decimalValue() throws a JsonNodeException instead of returning zero, and the new asDecimal() parses the text into an exact BigDecimal. Jackson 3 also no longer fails on unknown properties by default.

Five BigDecimal traps

Once the value is a BigDecimal, the class still has a few surprises.

1. The double constructor

new BigDecimal(0.1);       // 0.1000000000000000055511151231257827021181583404541015625
BigDecimal.valueOf(0.1);   // 0.1
new BigDecimal("0.1");     // 0.1

new BigDecimal(double) faithfully copies the binary error. BigDecimal.valueOf(double) looks better because it goes through Double.toString, but it can’t bring back digits the double never had: BigDecimal.valueOf(63520.780763913123456) is 63520.78076391312. Build prices from strings.

2. equals is not compareTo

BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("2.00");

a.equals(b);       // false, the scales differ (1 and 2)
a.compareTo(b);    // 0

equals and hashCode include the scale, so a HashSet keeps both values and HashMap.get(b) misses a key stored as a. TreeSet and TreeMap use compareTo and see one value. Compare prices with compareTo, test for zero with signum() == 0, and normalise map keys with stripTrailingZeros() if you have to use them.

3. divide needs a rule

BigDecimal usd = new BigDecimal("100");
BigDecimal btcPrice = new BigDecimal("63520.780763913");

usd.divide(btcPrice);                          // ArithmeticException
usd.divide(btcPrice, 8, RoundingMode.DOWN);    // 0.00157428
usd.divide(btcPrice, MathContext.DECIMAL64);   // 0.001574287954231371

Without a scale or a MathContext, divide only succeeds when the result is exact; otherwise it throws Non-terminating decimal expansion; no exact representable decimal result. Decide what the result means: eight decimals rounded down gives whole satoshis and never more BTC than 100 USD can buy. A MathContext counts significant digits, not decimal places. Also watch out for divide(divisor, RoundingMode.HALF_UP) without a scale: it keeps the dividend’s scale, so BigDecimal.ONE.divide(new BigDecimal("3"), RoundingMode.HALF_UP) returns 0.

4. toString switches to scientific notation

BigDecimal tiny = new BigDecimal("0.0000000123");

String s = "price: " + tiny;   // price: 1.23E-8
tiny.toPlainString();           // 0.0000000123

new BigDecimal("1200").stripTrailingZeros();                   // 1.2E+3
new BigDecimal("1200").stripTrailingZeros().toPlainString();   // 1200

toString() uses E-notation once the value is below 0.000001 (one satoshi prints as 1E-8) or when stripping zeros leaves a negative scale. String concatenation, String.valueOf and "%s" all call toString(). Use toPlainString() for anything a person reads or another system parses. Jackson also writes BigDecimal with toString() unless you enable StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN, for example with JsonMapper.builder().enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN).build().

5. Rounding modes differ between tools

new BigDecimal("2.345").setScale(2, RoundingMode.HALF_UP);     // 2.35
new BigDecimal("2.345").setScale(2, RoundingMode.HALF_EVEN);   // 2.34

NumberFormat.getCurrencyInstance(Locale.US).format(new BigDecimal("2.345"));   // $2.34
String.format(Locale.ROOT, "%.2f", new BigDecimal("2.345"));                  // 2.35

HALF_EVEN sends a tie to the even neighbour, which keeps sums of many rounded values unbiased. NumberFormat uses it by default, while String.format rounds half up, so the same value can show two different ways on one page. Pick a mode on purpose, and round once, at the end.

Putting it together: valuing a small portfolio

Here is the whole path in one program: fetch prices for three coins, value the holdings and show each coin’s share. It uses the Bitculator Java SDK (source on GitHub), which needs Java 17 and returns rows as Jackson 2 JsonNodes:

<dependency>
  <groupId>com.bitculator</groupId>
  <artifactId>bitculator</artifactId>
  <version>1.1.0</version>
</dependency>

Set BITCULATOR_API_KEY to a Data API key before running it; a free-tier key is enough.

import com.bitculator.Bitculator;
import com.bitculator.Params;
import com.fasterxml.jackson.databind.JsonNode;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

public class PortfolioValue {

    public static void main(String[] args) {
        Map<String, BigDecimal> holdings = Map.of(
                "bitcoin", new BigDecimal("0.75"),
                "ethereum", new BigDecimal("2.5"),
                "shiba-inu", new BigDecimal("2500000"));

        Bitculator client = new Bitculator(System.getenv("BITCULATOR_API_KEY"));
        JsonNode rows = client.prices().list(Params.of().set("slugs", holdings.keySet()));

        Map<String, BigDecimal> values = new LinkedHashMap<>();
        for (JsonNode row : rows) {
            JsonNode price = row.get("price");
            if (price == null || price.isNull()) {
                continue; // no current price for this coin
            }
            BigDecimal amount = holdings.get(row.get("slug").asText());
            values.put(row.get("symbol").asText(), new BigDecimal(price.asText()).multiply(amount));
        }
        if (values.size() < holdings.size()) {
            System.err.println("No price for " + (holdings.size() - values.size()) + " holding(s); the total leaves them out");
        }

        BigDecimal total = values.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
        NumberFormat usd = NumberFormat.getCurrencyInstance(Locale.US);

        values.forEach((symbol, value) -> {
            BigDecimal share = value.multiply(BigDecimal.valueOf(100))
                    .divide(total, 2, RoundingMode.HALF_EVEN);
            System.out.printf("%-5s %14s %7s%%%n", symbol, usd.format(value), share.toPlainString());
        });
        System.out.printf("%-5s %14s%n", "Total", usd.format(total));
        System.out.println("Exact total: " + total.stripTrailingZeros().toPlainString());
    }
}

With these example prices (63,520.780763913 for BTC, 2,431.5527310904 for ETH and 0.000012341873905 for SHIB) it prints:

BTC       $47,640.59   88.63%
ETH        $6,078.88   11.31%
SHIB          $30.85    0.06%
Total     $53,750.32
Exact total: 53750.32208542325

The program keeps full precision through every multiplication and the sum, and rounds only where a person reads the result: in NumberFormat and in the one division. SHIB’s price sits just above the point where toString() switches to E-notation, so it would print plain either way; a cheaper token at 0.00000065231842 prints as 6.5231842E-7. The program doesn’t rely on that, because every number a person sees goes through NumberFormat or toPlainString(). The stripTrailingZeros() on the last line is there because the scale of a product is the sum of the two scales and add() keeps the larger scale: the SHIB value has 15 decimals, so the exact sum is 53750.322085423250000 until the zeros are trimmed. The check after the loop matters too: the API leaves out slugs it doesn’t know, and a silently missing holding is the same kind of bug as a silently wrong digit.

A short checklist

  • Never put a price in a double, not even “just for display”.
  • Read decimal strings with new BigDecimal(node.asText()), never with decimalValue() or asDouble() (on Jackson 3: node.asDecimal()).
  • If prices arrive as JSON numbers and you read them with readTree or into a Map, enable USE_BIG_DECIMAL_FOR_FLOATS before parsing.
  • Build BigDecimals from strings, not from doubles.
  • Compare with compareTo, test zero with signum().
  • Give every divide a scale and a rounding mode, or a MathContext.
  • Use toPlainString() for output, and WRITE_BIGDECIMAL_AS_PLAIN when writing JSON.
  • Choose the rounding mode on purpose, and round once, at the end.

Disclosure: the author builds the Bitculator Data API and Java SDK used in the example.

Share this page

Found a mistake, or something to add? Edit this page on GitHub

Written by

Christian Lauridsen

Christian Lauridsen builds Bitculator, a crypto market data platform with a public REST API, open-source SDKs for eight languages and an MCP server.

Related posts

Join the discussion