All entries
Chapter VIII
Journal · 03 Nov 2026 · 13 min read

rust_decimal vs bigdecimal: Money Types in Rust

rust_decimal vs bigdecimal for payment code, tested: silent from_str rounding, the 79bn-token limit at 18 decimals, serde traps and Postgres NUMERIC.

Decimal::from_str("100000000000.000000000000000001") returns Ok. The value inside is 100000000000.00000000000000000: rust_decimal dropped the last wei to fit its 96-bit mantissa and raised no error. Call Decimal::from_str_exact on the same string and you get Err(Underflow).

The pages ranking for "rust_decimal vs bigdecimal" mostly stop at "fixed-size and fast versus arbitrary precision", which is true and not enough to choose with. So I built a scratch crate against rust_decimal 1.43.0 and bigdecimal 0.4.11 on Rust 1.98 and ran the cases payment code hits: token amounts, rounding, JSON, Postgres and overflow. Unless I say otherwise, every output below is pasted from that run.

How Should You Represent Money in Rust?

There are five realistic options, and one of them is wrong.

TypeExact decimals?SizeHeap?RangeUse it for
f64No8 bytesNo15 to 17 significant digitsNothing involving amounts
i64 minor unitsYes8 bytesNo±9.22 × 10¹⁸ minor unitsFiat balances and postings
i128 / u128 minor unitsYes16 bytesNoup to 3.4 × 10³⁸Token balances, TigerBeetle-style ledgers
rust_decimal::DecimalYes, to 28 digits16 bytesNo±79,228,162,514,264,337,593,543,950,335Rates, fees, tax, FX, API amounts
bigdecimal::BigDecimalYes, unbounded40 bytes + digitsYesMemoryBoundaries where inputs can exceed 28 digits
f64 fails the first test anyone runs. Adding 0.1 ten times gives 0.9999999999999999, and 0.1 + 0.2 == 0.3 is false. Both decimal crates return true.

Integer minor units are what Stripe and Adyen put on the wire, and what I used for the Postgres schema in the TigerBeetle vs Postgres ledger guide. An i64 holds 92,233,720,368,547,758.07 when the unit is pence. No fiat ledger gets close. The same i64 holds 9.223372036854775807 ETH when the unit is wei. That gap between fiat and 18-decimal tokens shapes most of what follows.

rust_decimal vs bigdecimal vs fastnum in 2026: Which Crate?

rust_decimal 1.43.0bigdecimal 0.4.11fastnum 0.7.5
Representation96-bit integer, scale 0 to 28, sign bitBigInt plus i64 scaleFixed N-bit coefficient plus 64-bit control block
Significant digits28 (29 in some cases)Unbounded19 (D64), 38 (D128), 77 (D256), 154 (D512)
Copy, no heapYesNo (Clone, heap digits)Yes
Default roundingHalf-evenHalf-even for round, truncation for with_scaleHalf-up
NaN and InfinityNoNoYes
serde defaultString out; string or number inString out; string or number inString out; string only in
Postgres supportsqlx, Diesel, tokio-postgressqlx, Dieselsqlx, Diesel, tokio-postgres
Total downloads (3 Nov 2026)169.1M139.5M10.6M
Latest release2 Sep 202628 Sep 202611 Jun 2026, pre-1.0

fastnum is the one most comparisons skip. It is fixed-size like rust_decimal, but a D256 holds 77 significant digits, which covers any realistic 18-decimal token balance. It is still pre-1.0 and needs Rust 1.94. Its default rounding is half-up, so swapping it in for rust_decimal changes every midpoint result without a compile error.

Two others turn up in searches. rusty-money 0.5.1 wraps rust_decimal with 150-plus ISO 4217 currencies and 14 crypto assets, and its fast feature adds an i64 FastMoney that truncates; it went five years between releases (January 2021 to January 2026). The fixed crate, with 16.3M downloads, is binary fixed-point, so 0.1 has no exact representation in it.

Copy matters more than it looks. A Decimal behaves like an integer: pass it by value, derive Copy on the struct that holds it, use it in a HashMap key. BigDecimal pushes you into &a + &b and .clone() everywhere, and every intermediate result allocates.

bigdecimal's defaults are also fixed at compile time. Its build.rs reads RUST_BIGDECIMAL_DEFAULT_PRECISION (default 100) and RUST_BIGDECIMAL_DEFAULT_ROUNDING_MODE (default HalfEven), so an environment variable set in CI changes the result of a / b for every crate in the build. That is a strange place to keep a business rule. If you use bigdecimal for money, pass precision and rounding mode explicitly at each call.

What Is the Maximum Value of rust_decimal?

Decimal::MAX is 79,228,162,514,264,337,593,543,950,335, which is 2⁹⁶ − 1. The scale moves the decimal point inside that integer. It does not add digits. At 18 decimal places the ceiling is:
Decimal::MAX / 1e18 = 79228162514.264337593543950335

That is roughly 79.2 billion whole tokens. For USDC, with 6 decimals, the limit never matters. For 18-decimal ERC-20s it is a real ceiling: an individual DAI balance fits easily, but a pool reserve or a total supply in the hundreds of billions does not, and intermediate products such as price * amount hit it long before the inputs do.

What happens above the ceiling depends on which constructor you call:

Input (18 dp)from_str / parse()from_str_exact
79,228,162,514.264337593543950335exactexact
100,000,000,000 tokens + 1 wei100000000000.00000000000000000Err(Underflow)
1,000,000,000,000.1234567890123456781000000000000.1234567890123457Err(Underflow)
589,000,000,000,000.123456789012345678589000000000000.12345678901235Err(Underflow)
FromStr, which "...".parse::() calls, rounds away fractional digits that do not fit; the last row loses four of them. Building the same 100-billion-token value from raw wei with Decimal::try_from_i128_with_scale(wei, 18) returns Err(ExceedsMaximumPossibleValue), which is the behaviour you want. In a ledger, silent rounding shows up later as a reconciliation break. In a bridge or custody system it is a loss.

If amounts arrive as strings from a chain indexer, a PSP or a bank file, parse them with from_str_exact and treat the error as a rejected message. For on-chain values I would not use either decimal crate as the source of truth. Keep the raw integer as U256 from alloy-primitives, or u128 if you have checked the token's supply, and convert to Decimal only for display or pricing.

Does rust_decimal Panic on Overflow?

Yes. The operators panic, and the checked methods return None:

Decimal::MAX + dec!(1)                → panic: "Addition overflowed"
dec!(1) / dec!(0)                     → panic: "Division by zero"
Decimal::MAX.checked_add(dec!(1))     → None
dec!(1).checked_div(dec!(0))          → None
Decimal::MAX.saturating_add(dec!(1))  → 79228162514264337593543950335

A panic is at least loud. Multiplication that needs more than 28 significant digits rounds instead of failing:

1.2345678901234567890123456789 * 1.2345678901234567890123456789
                                       = 1.5241578753238836750495351563
0.0000000000000000000000000001 * 0.5   = 0.0000000000000000000000000000
(dec!(1) / dec!(3)) * dec!(3)          = 0.9999999999999999999999999999
checked_mul on two 1e-28 values returns Some(0). It is not an overflow, so the checked API does not catch it. This only bites when you multiply very small numbers together, such as per-second interest rates or per-request pricing in fractions of a penny. Scale those up to a larger unit, compute, and round once at the end.

For the overflow case, let Clippy enforce checked arithmetic instead of relying on review. Setting arithmetic_side_effects = "warn" under [lints.clippy] in Cargo.toml flags + and * on Decimal and on BigDecimal, not only on primitive integers; it fired on all four functions in my test crate. Types you have audited can be exempted with arithmetic-side-effects-allowed in clippy.toml.

Which Rounding Mode Does rust_decimal Use?

Both round() and round_dp() use banker's rounding, where a midpoint goes to the nearest even digit:

ExpressionResult
dec!(2.5).round()2
dec!(3.5).round()4
dec!(0.125).round_dp(2)0.12
dec!(0.135).round_dp(2)0.14
dec!(0.125).round_dp_with_strategy(2, MidpointAwayFromZero)0.13
dec!(-0.125).round_dp_with_strategy(2, ToNegativeInfinity)-0.13
bigdecimal 0.125.round(2)0.12
bigdecimal 2.679.round(2)2.68
bigdecimal 2.679.with_scale(2)2.67

The last row is the bigdecimal trap. with_scale(2) reads like "set to two decimal places", and it truncates. Use round(2) or with_scale_round(2, RoundingMode::HalfUp) and name the mode you mean.

Banker's rounding is a reasonable default for statistics. For customer-facing money it is a choice you should make on purpose. Postgres round(numeric) rounds half away from zero, so round(0.125, 2) in SQL gives 0.13 while round_dp(2) in Rust gives 0.12. If both layers round, they will disagree on exactly the values a customer notices. I covered the same split between Postgres and Python in the ISO 4217 minor units guide.

UK VAT Rounding Rules in Rust (HMRC Notice 700)

For UK VAT invoices you don't get to pick the rounding mode. HMRC VAT Notice 700 §17.5 lets invoice traders round the invoice's total VAT down to a whole penny. If you work VAT out per line (§17.5.1), each line rounds either down to the nearest 0.1p or to the nearest 1p or 0.5p, and "whatever you decide, you must be consistent". Retailers who calculate at line or invoice level must not round down at all (§17.6).

Mapped to rust_decimal, with a line VAT of £0.8676:

HMRC optionrust_decimalResult
Line, down to 0.1pround_dp_with_strategy(3, ToZero)0.867
Line, nearest 1pround_dp_with_strategy(2, MidpointAwayFromZero)0.87
Line, nearest 0.5p(v * dec!(200)).round_dp_with_strategy(0, MidpointAwayFromZero) / dec!(200)0.87
Unit VAT £0.0024, nearest 0.5psame formula0, which HMRC forbids

Two things follow. The 0.1p option keeps line VAT in tenths of a penny, so an integer-pence type cannot hold it until the invoice total is rounded. And no built-in strategy implements the per-unit rule in §17.5.2: round to the nearest 0.5p, but never round standard- or reduced-rate VAT to nil. HMRC's own example turns £0.0024 into £0.005. That is a branch you write and test yourself. Credit notes need a decision as well: for −£0.8676, ToZero gives −0.867 and ToNegativeInfinity gives −0.868, and "round down" doesn't say which one it means.

How Do You Serialise rust_decimal to JSON Without Losing Precision?

Both crates serialise to a JSON string by default, {"v":"1.10"}, with trailing zeros kept because the scale is part of the value. Leave that default alone. The damage happens on the way in, and through feature flags:

JSON inputrust_decimal (default)bigdecimal (default)
"12345678901234567890.12"12345678901234567890.1212345678901234567890.12
12345678901234567890.121234567890123456700012345678901234567168
0.10.10.1000000000000000055511151231257827021181583404541015625
1.101.11.100000000000000088817841970012523233890533447265625

A JSON number goes through f64 unless serde_json's arbitrary_precision feature is on. rust_decimal then takes the shortest round-trip form of the float, which hides the damage for short values. bigdecimal keeps the float's exact binary expansion, which makes it obvious. Neither survives a 22-digit number. Turning on arbitrary_precision fixed every row for both crates in my test. It is a Cargo feature, though, so it unifies across the dependency graph and changes serde_json::Number for every crate in the binary.

rust_decimal's serde-with-float option writes 1.10 as 1.1 and 12345678901234567890.12 as 1.2345678901234567e+19. Do not use it for amounts.

bigdecimal has one more surprise. Its Display switches to exponent notation after five leading zeros: 0.000001 prints as 0.000001, but 0.0000001 prints as 1E-7, and serde sends the string "1E-7". A bank or PSP API expecting a plain decimal will reject that. to_plain_string() returns 0.0000001. The threshold is another build-time variable, RUST_BIGDECIMAL_FMT_EXPONENTIAL_LOWER_THRESHOLD.

Equality has a related catch. In rust_decimal dec!(1.10) == dec!(1.1) is true and both hash the same, but to_string() gives "1.10" and "1.1". If you build an idempotency fingerprint or an HMAC over the string form, two equal amounts produce two different keys. Normalise or format to the currency's exponent first; the idempotency keys guide covers why fingerprints need a canonical body.

Postgres NUMERIC in Rust: sqlx, Diesel or SeaORM?

The mapping is settled. What happens at the edges isn't.

LibraryRust type for NUMERICFeature flag
sqlx 0.9rust_decimal::Decimal or bigdecimal::BigDecimalrust_decimal or bigdecimal
Diesel 2.3bigdecimal::BigDecimalnumeric; rust_decimal ships db-diesel-postgres itself
SeaORM 2.0rust_decimal::Decimalwith-rust_decimal (default), with-bigdecimal opt-in
tokio-postgresrust_decimal::Decimalrust_decimal's db-tokio-postgres

Postgres NUMERIC allows 131,072 digits before the decimal point and 16,383 after. rust_decimal holds 28 in total, so writes never fail and reads can:

  • NaN is rejected by every Decimal and BigDecimal decoder.
  • An integer part above 7.9 × 10²⁸ fails. sqlx returns "value not representable as rust_decimal::Decimal". This is the one that catches crypto schemas: NUMERIC(78,0) is the usual column for a uint256, and any balance above 79.2 billion tokens in wei will not decode into a Decimal.
  • More than 28 decimal places is where the drivers disagree. rust_decimal's own tokio-postgres code rescales and rounds. sqlx 0.9 multiplies each base-10,000 digit group by 10,000 to the power of its weight, and 10,000⁻⁸ is not representable, so a non-zero digit past the 28th decimal place fails the whole row. I ran sqlx's decode loop against rust_decimal 1.43 to check. Same type, same column: one driver rounds and the other errors.
  • An oversized BigDecimal on write is encoded by sqlx as a deliberately invalid value, and the query fails with 22P03 invalid_binary_representation.
sqlx's own docs describe 2⁹⁶ − 1 as "a number with 67 decimal digits". It has 29. If a Decimal sits behind a column, declare the column as NUMERIC(28, s) or narrower, so Postgres enforces the range Rust can actually read.

Is rust_decimal Fast Enough? Benchmark vs i64

I ran a quick release-mode loop on an Apple M5 Pro, with std::hint::black_box around inputs and outputs. Treat the numbers as orders of magnitude, not a benchmark suite:

Operationi64i128DecimalBigDecimal
10M additions6.4 ms21.7 ms62.4 ms141.5 ms
1M multiply by 0.2, round to 2 dp0.8 msn/a6.1 ms82.3 ms

rust_decimal costs about 10x an integer, and bigdecimal about 13x rust_decimal once a multiply allocates. Six nanoseconds per multiply-and-round will never be the slow part of a payment request; one Postgres round trip costs thousands of times more. It only shows up in batch work, such as nightly interest accrual over millions of accounts.

i64 Minor Units vs Decimal: Which Should a Payment Ledger Use?

My position, and it is an opinion: store balances and postings as integers in minor units, compute with Decimal, and keep BigDecimal at the edges.

The ledger stores i64 (or u128 for tokens) plus a per-asset exponent. TigerBeetle forces that model on you, since its amounts are u128 integers, and Stripe exposes it in its API. An integer cannot carry a scale mismatch: 1.10 and 1.1 cannot both exist in the table. Rates, fee percentages, VAT, FX conversion and pro-rata splits are computed in Decimal, because that is where fractional minor units appear. The conversion from Decimal to integer minor units happens in one function, with an explicit rounding strategy, and the remainder is posted somewhere visible rather than dropped. BigDecimal is for inputs that can legitimately exceed 28 digits, mainly crypto amounts arriving as strings, and those get validated and converted before they reach the ledger.

The alternative, Decimal all the way down with NUMERIC(19,4) columns, works. Plenty of teams run it. My objection is that it lets fractional pennies into balances that nobody can pay out, and the reconciliation report is where you find out.

What This Means for Rust Payment Teams

1. Wrap amounts in a type. struct Money { minor: i64, currency: Currency }, with no f64 constructor and the exponent taken from the currency. 2. Parse external decimal strings with from_str_exact and reject on error. Never parse() an amount from a third party. 3. Turn on clippy::arithmetic_side_effects in the crates that touch balances, and use checked_add, checked_mul and checked_div. 4. Keep serde's string format. If you must accept JSON numbers, enable arbitrary_precision and add a test with a 20-digit value. 5. Never use bigdecimal's with_scale to round. Use round or with_scale_round with a named mode, and use to_plain_string() for anything sent to another system. 6. Round in one layer. Either Rust or SQL, with the mode written down next to the function. 7. Check token decimals against the 79.2 billion ceiling before you put an 18-decimal asset in a Decimal. Keep raw on-chain values as U256 or u128. 8. Property-test the round trip with rust_decimal's proptest feature: string to Decimal to Postgres and back should be the identity.

Key Takeaways

  • Decimal::from_str silently rounds digits beyond 28; from_str_exact returns Err(Underflow). At 18 decimals the ceiling is 79,228,162,514 whole tokens.
  • Operators panic on overflow, but a multiplication needing more than 28 digits rounds, and checked_mul still returns Some.
  • rust_decimal rounds half to even, Postgres round() half away from zero, and bigdecimal's with_scale truncates.
  • Without serde_json's arbitrary_precision, a JSON number 0.1 becomes a 55-digit BigDecimal, and bigdecimal writes 0.0000001 as "1E-7".
  • sqlx errors on NUMERIC values past 28 decimal places that rust_decimal's own driver rounds.
I'm Tom Wang, and I write these guides from test output rather than READMEs. Most money bugs come from a rounding mode nobody chose on purpose, so choose yours.