On this page
- Why 0.1 + 0.2 can be surprising
- What Decimal changes
- Rounding each value changes the total
- Banker’s rounding: how ties are decided
- Rounding after each operation keeps the discrepancy
- Solution 1: reject precision the input contract does not support
- Solution 2: preserve valid fractions and account for the remainder
- Integer units can enforce an exact arithmetic range
- Where I would put the rounding boundary
Decimals in Rust: Choosing a Type Is Only Half the Problem
Decimal representation, repeated rounding, and the policies needed to keep totals consistent in Rust.
Consider a Rust application that rounds incoming decimal values to a fixed number of decimal places:
let clamped = raw.round_dp(SCALE);
With SCALE set to four, this rounds an amount to four decimal places.
The name clamped hides a decision: an incoming value can become a different
value before the application uses it.
That raised a question for me. If I already use rust_decimal::Decimal, what
does rounding still change?
Choosing a number representation and choosing a rounding policy are separate decisions. To understand why, I want to start with the values a type can store, then follow what happens when we round those values repeatedly.
Why 0.1 + 0.2 can be surprising
Rust’s f64 uses the IEEE 754 binary64 format: 64 bits split into one sign
bit, 11 exponent bits, and 52 fraction bits. Think of it as scientific
notation in base two: the sign determines positive or negative, the exponent
sets the power of two, and the fraction supplies the significant binary digits
(with an implicit leading 1 for normal values).
In binary, the places after the point represent halves, quarters, eighths,
and so on. Decimal 0.5 is binary 0.1, while decimal 0.1 repeats as binary
0.0001100110011.... Only a finite number of bits fit in an f64, so Rust
stores a rounded approximation of that repeating value.
The same limitation applies to 0.2: both inputs in the addition below are
already approximations. See the
Rust f64 documentation.
let sum = 0.1_f64 + 0.2_f64;
println!("{sum}"); // 0.30000000000000004
println!("{}", sum == 0.3); // false
The important detail is that 0.3_f64 is also an approximation. Writing 0.3
directly and adding 0.1 to 0.2 produce two slightly different stored
numbers. Printing 17 decimal places makes the difference visible:
0.3_f64: 0.29999999999999999
0.1_f64 + 0.2_f64: 0.30000000000000004
These are neighboring values that f64 can store. The == comparison checks
whether the stored numbers are equal, so it returns false. Repeating this
addition gives the same result; the difference is not random.
We can make the sum look like 0.3 by printing only one decimal place,
but formatting only changes the output text:
println!("{sum:.1}"); // 0.3
println!("{sum}"); // 0.30000000000000004
println!("{}", sum == 0.3); // false
The value in sum stays the same, and later calculations still use it.
f64 is useful when small approximation errors are acceptable. In that case,
the calculation’s accuracy requirements determine how close two results must
be to count as equal.
What Decimal changes
rust_decimal::Decimal stores a sign, a 96-bit integer coefficient, and a
decimal scale from zero through 28. For a positive value, think of it as:
value = coefficient / 10^scale
0.1 = 1 / 10^1
1.2345 = 12345 / 10^4
That lets it represent these values exactly. Its scale can vary; choosing
Decimal does not restrict every value to four decimal places. See the
Decimal type documentation.
The Rust fragments below use rust_decimal and omit the surrounding program.
For fixed example values, Decimal::new(coefficient, scale) makes the decimal
representation visible:
use rust_decimal::Decimal;
let a = Decimal::new(1, 1); // 0.1
let b = Decimal::new(2, 1); // 0.2
println!("{}", a + b); // 0.3
For external input, parse decimal text directly into Decimal, so it never
passes through an approximate f64. The exact parser also rejects values it
cannot represent without losing precision. See
from_str_exact.
Decimal precision is still finite. 1 / 3 has no finite decimal expansion,
and sufficiently large calculations can overflow. A decimal type cannot make
every mathematical result exact.
Rounding each value changes the total
Suppose we receive 0.00006 ten thousand times and round each input to four
decimal places before adding it to a total.
At that scale, the nearest choices are 0.0000 and 0.0001. The input is
closer to 0.0001, so rounding adds 0.00004 each time.
Round each input: 0.00006 → 0.0001
Then add them: 10,000 × 0.0001 = 1.0000
Add the inputs first: 10,000 × 0.00006 = 0.60000
Then round the total: 0.6000
Difference: 1.0000 − 0.6000 = 0.4000
Here is the same comparison using Decimal:
use rust_decimal::Decimal;
let input = Decimal::new(6, 5); // 0.00006
let mut rounded_total = Decimal::ZERO;
let mut unrounded_total = Decimal::ZERO;
for _ in 0..10_000 {
rounded_total += input.round_dp(4);
unrounded_total += input;
}
let rounded_once = unrounded_total.round_dp(4);
println!("{rounded_total:.4}"); // 1.0000
println!("{rounded_once:.4}"); // 0.6000
println!("{:.4}", rounded_total - rounded_once); // 0.4000
All the intermediate sums in this example fit exactly. The difference comes from changing the inputs before adding them. In general:
sum(round(x)) ≠ round(sum(x))
Which total is appropriate depends on what the application promises. If each item must be independently rounded, the first total may be required. If only the aggregate becomes payable or reportable, the second may be appropriate.
Banker’s rounding: how ties are decided
round_dp
uses midpoint-to-even rounding, also called banker’s rounding. There are
two steps: choose the nearest value at the requested precision, and, if two
values are equally close, choose the one whose last retained digit is even.
Start with rounding to whole numbers:
| Input | Two nearest whole numbers | Result | Why |
|---|---|---|---|
2.4 | 2 and 3 | 2 | 2 is closer |
2.5 | 2 and 3 | 2 | Exactly halfway; 2 is even |
3.5 | 3 and 4 | 4 | Exactly halfway; 4 is even |
3.6 | 3 and 4 | 4 | 4 is closer |
The even-digit rule applies only to an exact tie. It does not make every
result even: 3.1 rounds to 3, because that is the nearest whole number.
With decimal places, look at the last digit that will remain. At two places,
1.245 is halfway between 1.24 and 1.25, so it becomes 1.24. Likewise,
1.255 becomes 1.26. The retained digits 4 and 6 are even.
Why use this rule? Consider the positive ties 2.5 and 3.5. Always rounding
ties upward gives 3 + 4 = 7, while the original total is 6. Midpoint-to-even
gives 2 + 4 = 6: one adjustment goes down and the other goes up. When ties
are balanced between these cases, their adjustments cancel.
But the function does not remember previous calls or alternate directions.
Ten inputs of 2.5 all round to 2, giving 20 instead of the original 25.
The result depends on which values occur.
Now return to our four-place examples:
| Input | Rounded to four places | Reason |
|---|---|---|
0.00001 | 0.0000 | Nearest value |
0.00005 | 0.0000 | Tie; retained digit 0 is even |
0.00006 | 0.0001 | Nearest value |
0.00015 | 0.0002 | Tie; retained digit 2 is even |
For 0.00006, the distance to 0.0000 is 0.00006, while the distance to
0.0001 is only 0.00004. There is no tie, so the even-digit rule never
comes into play. Every call returns 0.0001, adding 0.00004 each time.
Banker’s rounding defines how one value is rounded. We still have to choose whether to round every item or the total. Changing the tie rule cannot recover fractions already removed from individual items.
Rounding after each operation keeps the discrepancy
Suppose we preserve the input but round the balance after each addition:
Operation Balance after rounding to four places
0.0000 + 0.00006 = 0.00006 0.0001
0.0001 + 0.00006 = 0.00016 0.0002
0.0002 + 0.00006 = 0.00026 0.0003
Each operation starts from a balance that already includes earlier rounding
adjustments. Moving the rounding call here still adds 0.00004 per addition.
If the input was rounded first, another rounding call cannot restore it either.
Solution 1: reject precision the input contract does not support
If the smallest accepted transaction unit is 0.0001, require inputs to be
exact multiples of that unit. Reject 0.00006 before changing any balance.
This works only if rejecting finer amounts is allowed. A requirement to print
four decimal places does not, by itself, impose that restriction on input.
Validate precision before accepting an amount. Normalize trailing zeros first:
1.00000
has five written decimal places, but its value fits exactly at four places.
This standalone helper illustrates the precision check:
use rust_decimal::Decimal;
#[derive(Debug)]
enum PrecisionError {
InvalidDecimal(rust_decimal::Error),
UnsupportedPrecision,
}
fn parse_four_place_value(input: &str) -> Result<Decimal, PrecisionError> {
let value = Decimal::from_str_exact(input)
.map_err(PrecisionError::InvalidDecimal)?
.normalize();
if value.scale() > 4 {
return Err(PrecisionError::UnsupportedPrecision);
}
Ok(value)
}
// parse_four_place_value("1.00000") → Ok(1)
// parse_four_place_value("1.2345") → Ok(1.2345)
// parse_four_place_value("0.00006") → Err(UnsupportedPrecision)
The ? and Ok here belong to the validation function’s error handling.
Transaction positivity and range checks would remain separate requirements,
with the precision check enforced for direct construction as well as parsing.
Ordinary decimal parsing already preserves 0.00006 exactly. The change that
addresses this case is replacing input rounding with validation.
Using from_str_exact is a separate precaution for inputs beyond Decimal’s
precision, so parsing cannot silently remove significant digits before the
scale check.
Once accepted amounts are multiples of 0.0001, addition and subtraction
preserve that unit while their results fit exactly:
1.2345 + 0.0001 = 1.2346
1.2346 - 0.0001 = 1.2345
Reversing an operation should use the original accepted amount. No explicit rounding is needed for those balance movements. Decimal still has finite capacity: define supported amount and balance limits, use checked arithmetic, and verify that the smallest supported increment remains exact at the largest supported balance.
Solution 2: preserve valid fractions and account for the remainder
If a provider sends valid 0.00006 deposits that we must accept, rejecting
five-place amounts does not meet our requirements. Preserve those amounts in
transaction history and balances, and decide separately which units can leave
the account.
Assume deposits are confirmed at their exact values and withdrawals must be
multiples of 0.0001. One policy for positive available balances is to allow
withdrawal of only complete units and retain the fraction for the customer:
| Confirmed deposits | Exact available balance | Maximum withdrawable amount | Balance remaining afterward |
|---|---|---|---|
One deposit of 0.00006 | 0.00006 | 0.0000 | 0.00006 |
Two deposits of 0.00006 | 0.00012 | 0.0001 | 0.00002 |
Ten deposits of 0.00006 | 0.00060 | 0.0006 | 0.00000 |
Each row starts without earlier withdrawals. “Afterward” means after taking out the maximum permitted amount; in the first row, no withdrawal is possible. The maximum is derived by truncating a positive available balance to four places. That derived value never replaces the exact balance.
After two deposits, withdrawing 0.0001 leaves 0.00002. A later deposit of
0.00008 brings the remaining balance to 0.00010, enough for another unit.
For this example, assuming only deposits and withdrawals:
confirmed deposits = completed withdrawals + remaining balance
0.00012 = 0.0001 + 0.00002
This policy can be implemented on our side. It requires enough internal precision and range for the provider’s amounts and accumulated balances, plus withdrawal validation against both the permitted unit and exact available funds. Formatting a balance must not change the amount available for withdrawal.
Calculated usage can produce the same fractions
Even inputs with four places can produce a finer result. Suppose a service
costs 0.0002 credits per second and a request takes 0.3 seconds:
Cost per request: 0.0002 × 0.3 = 0.00006 credits
Cost of 100 requests: 100 × 0.00006 = 0.00600 credits
If the service settles a customer’s accumulated usage at the end of a billing
period, it can retain the five-place costs until then. Rounding each request
first would charge 0.0100 instead of 0.0060 credits.
When the period total does not fit the settlement unit, the same carry-forward policy can retain the unbilled fraction for the next period. Keep it with the same customer, and define what happens to it when the account closes. Combining unrelated customers’ fractions would change who owns those amounts.
Allocation must assign the leftover unit
Sometimes a total must be distributed now. Using cents as the unit, splitting
1.00 equally among three recipients gives 0.33 each and leaves 0.01.
One allocation that preserves the total is:
Recipient A: 0.34
Recipient B: 0.33
Recipient C: 0.33
Total: 1.00
Calculate 100 cents divided by three: 33 cents each, with one cent left to assign. A stable ordering makes the assignment deterministic; rotating the recipient across repeated allocations may better fit a fairness requirement. Independent rounding of each share does not decide where that cent goes.
Integer units can enforce an exact arithmetic range
Either policy can use integer storage. With a unit of 0.0001, store 1.2345
as 12_345 units. Checked integer addition and subtraction either return the
exact result or report overflow; they cannot silently discard a small fraction
to fit a larger value.
For the first solution, this gives a direct implementation: validate and convert accepted input into units, keep balances in those units, and reject arithmetic that overflows before changing state. Choose a signed balance type if the domain permits negative balances.
For the second solution, the internal unit must also represent the provider’s
finer amounts. If its supported precision is five places, units of 0.00001
represent 0.00006 as six units. One withdrawal unit of 0.0001 is ten internal
units. Two deposits give 12 units; withdrawing ten leaves two.
Choosing a four-place integer unit would lose the very fraction this solution needs to preserve. Integer storage therefore still requires a precision contract, a supported range, and rules for division and remainders.
Where I would put the rounding boundary
I would avoid silently rounding incoming values. If the input contract allows rejection, construction should validate precision. If finer amounts must be accepted, construction should preserve them within the supported range. Stored transaction values and balance movements should stay exact.
For the provider example, the conversion to whole units belongs at withdrawal authorization, with the remainder kept in the balance. For usage, it belongs at the agreed billing boundary. For allocation, it belongs where shares are assigned and the leftover units are distributed. These boundaries are part of the application’s rules; the end of every arithmetic operation is not one universal rounding boundary.
I would verify the chosen policy with the two-deposit case above, withdrawals that leave a fraction, reversals using the original amount, and arithmetic at the supported limits. Printing four decimal places should change none of those stored results.