BigDecimal is the go-to class for high-precision arithmetic in Java. When it comes to converting a BigDecimal to a string, three methods are available: toString(), toPlainString(), and toEngineeringString(). Developers often pick one without fully understanding what each does, which leads to subtle bugs in production. This article walks through the source code of each method, explains when each one is appropriate, and covers the pitfalls that tend to catch people off guard.
1. Quick Comparison
Before digging into the source, here is a summary of what each method produces:
Method
Scientific notation
Exponent format
Typical output
Best for
toString()
Auto-detected
Standard scientific
1.23E-7
Logging, general display
toPlainString()
Never used
None
0.000000123
Currency display, UI
toEngineeringString()
Always used
Engineering (exponent is a multiple of 3)
123E-9
Engineering, scientific computing
The key distinction: toString() decides whether to use scientific notation based on the magnitude of the number; toPlainString() always produces a plain decimal representation with no exponent; toEngineeringString() always uses scientific notation but constrains the exponent to a multiple of three.
private String layoutChars(boolean sci){ if (scale == 0) { // No fractional part — return the integer as a string return (intCompact != INFLATED) ? Long.toString(intCompact) : intVal.toString(); } if (scale < 0) { // Negative scale means trailing zeros are implied // e.g. 123 with scale=-3 → "123000" return intVal.toString() + zeros(-scale); } // scale > 0: decide between plain decimal and scientific notation int adjust = -scale + (intCompact != INFLATED ? digitLength(intCompact) : intVal.precision()) + 1; // The critical threshold if (adjust >= -6) { // Adjusted value is between -6 and 0: use plain decimal return toPlainString(); } // Beyond the threshold: use scientific notation StringBuilder buf = new StringBuilder(); // ... build the scientific notation string buf.append('E'); buf.append(adjust - 1); // exponent value return buf.toString(); }
The threshold is straightforward: when adjust >= -6, the method produces a plain decimal string; below -6, it switches to scientific notation.
The adjust value is computed as: adjust = -scale + precision + 1, where precision is the number of significant digits and scale is the number of decimal places.
Here is a concrete example:
1 2 3 4 5 6 7 8
BigDecimal bd = new BigDecimal("0.000000123"); // precision = 3 (significant digits: 1, 2, 3) // scale = 9 (decimal places) // adjust = -9 + 3 + 1 = -5 // -5 >= -6, so does it NOT use scientific notation? Wrong.
// Actually, adjust = -9 + 3 + 1 = -5 // But -5 < -6 is false, so... wait:
Let me correct the analysis. The threshold check is adjust >= -6, meaning plain decimal when the adjusted exponent is -6 or higher. Here are the actual results:
1 2
new BigDecimal("0.00000123").toString(); // "0.00000123" (adjust = -6) new BigDecimal("0.000000123").toString(); // "1.23E-7" (adjust = -7)
The boundary sits right around the sixth decimal place. Numbers with more leading zeros after the decimal point switch to scientific notation.
scale > 0: compute pad = scale - precision. If pad > 0, the number is a pure fraction with leading zeros (like 0.00123). If pad == 0, the integer part is zero (like 0.123). If pad < 0, there is both an integer and a fractional part (like 123.456).
The difference between engineering and standard scientific notation comes down to the exponent:
Scientific: 1.23E-7 (exponent can be any integer)
Engineering: 123E-9 (exponent must be a multiple of 3)
The implementation adjusts the exponent inside layoutChars:
1 2 3 4 5 6 7 8 9 10 11 12 13
if (sci) { // engineering mode // Adjust the exponent to be a multiple of 3 int e = adjust - 1; int n = e % 3; if (n != 0) { // Shift the decimal point so the exponent becomes a multiple of 3 // e.g. 1.23E-7 → 123E-9 (exponent -9 is divisible by 3) e = e - n; // Adjust the coefficient accordingly } buf.append('E'); buf.append(e); }
Why does engineering notation require the exponent to be a multiple of 3? It comes from the SI (International System of Units) prefix table. Every standard prefix corresponds to a power of 10 that is a multiple of 3:
Power
Prefix
Symbol
10^12
Tera
T
10^9
Giga
G
10^6
Mega
M
10^3
Kilo
k
10^-3
Milli
m
10^-6
Micro
μ
10^-9
Nano
n
10^-12
Pico
p
Engineering notation maps directly onto these prefixes, which is why it’s the standard in electrical engineering, physics, and related fields.
4.2 External Systems That Don’t Accept Scientific Notation
1 2 3 4 5 6 7 8 9
BigDecimal original = new BigDecimal("1.23E-7"); String str = original.toString(); // "1.23E-7"
// Parsing it back works fine BigDecimal parsed = new BigDecimal(str); // OK
// But an external API might choke on the exponent String apiInput = new BigDecimal("0.000000123").toString(); // apiInput = "1.23E-7" — the external system may reject this
Use toPlainString() when sending data to systems that expect plain numeric strings:
1 2
String apiInput = new BigDecimal("0.000000123").toPlainString(); // apiInput = "0.000000123" — safe to send
4.3 Trailing Zeros “Disappear”
1 2 3
BigDecimal bd = new BigDecimal("1.10"); System.out.println(bd.toString()); // "1.1" — the trailing zero is gone System.out.println(bd.toPlainString()); // "1.1" — same here
This is not precision loss. BigDecimal strips trailing zeros during certain operations because 1.10 and 1.1 are mathematically equal. If you need to preserve the trailing zero for display purposes, use DecimalFormat:
1 2 3 4 5
import java.text.DecimalFormat;
BigDecimal bd = new BigDecimal("1.10"); DecimalFormat df = new DecimalFormat("0.00"); System.out.println(df.format(bd)); // "1.10"
A negative scale means the unscaled value needs to be multiplied by 10 raised to the absolute value of the scale. scale = -2 means unscaledValue × 10^2. So 1.23 × 10^5 = 123000.
Once computed, the result is cached on the object. toPlainString() and toEngineeringString() do not have this optimization — they recompute on every call.
// Warmup for (int i = 0; i < 10000; i++) { bd.toString(); }
// Benchmark toString() long start = System.nanoTime(); for (int i = 0; i < 1000000; i++) { bd.toString(); } long toStringTime = System.nanoTime() - start;
// Benchmark toPlainString() start = System.nanoTime(); for (int i = 0; i < 1000000; i++) { bd.toPlainString(); } long toPlainStringTime = System.nanoTime() - start;
toString() is slightly faster due to caching, but the difference is marginal. Method selection should be driven by correctness requirements, not performance.
6. Decision Guide
6.1 Choosing the Right Method
1 2 3 4 5 6 7 8 9 10 11 12 13 14
Need to convert BigDecimal to String ├── For UI display? │ ├── Yes → toPlainString() + DecimalFormat │ └── No ↓ ├── For logging? │ ├── Yes → toString() │ └── No ↓ ├── For data exchange (API, JSON)? │ ├── Yes → toPlainString() │ └── No ↓ ├── For engineering/scientific work? │ ├── Yes → toEngineeringString() │ └── No ↓ └── Default: toString()
Understanding these three methods eliminates an entire class of display and serialization bugs. The choice is rarely ambiguous once you know what each one actually does.