lifetimes.dev
On this page

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:

InputTwo nearest whole numbersResultWhy
2.42 and 322 is closer
2.52 and 32Exactly halfway; 2 is even
3.53 and 44Exactly halfway; 4 is even
3.63 and 444 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:

InputRounded to four placesReason
0.000010.0000Nearest value
0.000050.0000Tie; retained digit 0 is even
0.000060.0001Nearest value
0.000150.0002Tie; 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 depositsExact available balanceMaximum withdrawable amountBalance remaining afterward
One deposit of 0.000060.000060.00000.00006
Two deposits of 0.000060.000120.00010.00002
Ten deposits of 0.000060.000600.00060.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.