Semantic Types for Money in Rust, with Better Precision and Fixed-point Decimal Arithmetic
, 2789 words, 15 minutes read
Table of contents
- Overview
- Fixed-point and Floating-point
- Fixed-size and Arbitrary-precision
- Choosing Crates & Benchmarks
- Quick overview of the fastnum crate
- Semantic typing
- Example: Parsing values from CSV
- Github
- Further reading
Overview
đź’ˇ This section has been combined with an excellent overview of the subject provided by WuBingzheng from his article Comparison and Benchmarking of Rust Decimal Crates.
His intro material is targeted at new audiences, by his own admission, and has been added to improve context in my article. I do not see the need to “reinvent” the wheel, when he has already provided excellent coverage and depth on the topic.
I also share his sentiment to avoid ambiguity and use Fixed-size instead of Fixed-precision, where applicable.
Since the binary number system, base-2 is the building block of logic, this falls apart when looking at numbers such as 1/3 which evaluates to 0.33333333333333… and so on until infinity. Binary fractions cannot represent decimal fractions exactly. Consider for example, f64 as this is a good example of this classic arithmetic error: 0.1 + 0.2 ≠0.3. Here’s a quick quote from the author of fastnum:
The key point is that working with decimal numbers follows intuitive rules familiar to everyone from school. For example, we all understand that 1/3 = 0.333333…(3) and that rounding is eventually inevitable. However, the fact that 0.1, when written down in a notebook, might turn into something like 0.10000000000001 in calculations – puzzles many people, because in the real world, we neither interact with the binary number system nor write numbers in it.
That’s right much like 1/10 in binary, 1/3 in decimal also does not have a finite representation and any attempt to store it in a computer using a decimal number will result in a loss of precision. Decimal can display more fractions precisely than binary can, but not all of them.
In financial applications we require exact representation of decimal fractions. This is why decimal crates are needed. These use integers to represent the mantissa, along with a scale representing the number of decimal places. For example, the value 1.23 can be represented using integer 123 with scale = 2.
There are many decimal crates in the Rust ecosystem, each with different designs and trade-offs. Their differences mainly fall into two dimensions:
-
Whether the scale is fixed or variable. This corresponds to Fixed-point vs Floating-point.
-
Whether the count of integers are fixed or arbitrary. This corresponds to Fixed-precision vs Arbitrary-precision.
The first two sections (Fixed-point and Floating-point, Fixed-size and Arbitrary-precision) introduce the characteristics of these categories. There is nothing particularly new here, so experienced readers may skip them.
Fixed-point and Floating-point
Fixed-point vs Floating-point.
In fixed-point arithmetic, the scale is fixed and bound to the type. In floating-point arithmetic, the scale is variable and stored in each instance.
Let’s illustrate this with code. A typical fixed-point type definition might look like this:
struct FixedPoint<const SCALE: i32>(i128); // scale is bound to type
A typical floating-point decimal type might look like this:
struct FloatingPoint {
mantissa: i128,
scale: i32, // scale is stored in each instance
}
This clearly shows that fixed-point numbers have fixed decimal precision, while floating-point decimals have variable precision. For example, FixedPoint<2> always has 2 decimal places, while the precision of FloatingPoint depends
on each instance’s scale.
Because of this distinction, fixed-point and floating-point types exhibit the following differences:
-
Fixed-point numbers have a smaller representable range, while floating-point numbers can represent a much larger range. This is because floating-point numbers sacrifice decimal precision as values become larger.
-
Fixed-point arithmetic is simpler and faster, while floating-point arithmetic is more complex and slower. For example, addition for fixed-point numbers only requires integer addition on the mantissa. Floating-point addition must first check whether the scales are equal (this check itself can already be slower than the addition), and if not, align the scales through multiplication. Refer to the detailed discussed in the benchmark section.
-
Fixed-point arithmetic is somewhat more cumbersome to use, while floating-point arithmetic is more convenient. For example, with the
FixedPointtype above, the scale must be determined at compile time for each type, such as how many decimal placesBalanceorPriceshould have. Floating-point decimals do not require this consideration.
The difference between the two is somewhat analogous to the difference between statically typed and dynamically typed languages.
Most applications use decimal crates simply to represent decimal fractions exactly, without particularly high requirements for performance or strict decimal precision. In such cases, floating-point decimals are usually preferred for convenience. However, for more serious services, especially many financial systems that require strict decimal precision or high performance, fixed-point decimals are recommended. For example, USD assets should have exactly 2 decimal places, neither more nor less.
NOTE: Since built-in floating-point types in programming languages (such as C’s
float and double, or Rust’s f32 and f64) are commonly referred to as
“floating-point”, and these types cannot represent decimal fractions exactly,
many people mistakenly think that “floating-point” inherently cannot represent
decimal fractions exactly. This is WRONG! More precisely, these are “binary
floating-point” numbers. The inability to represent decimal fractions exactly
comes from the “binary” part, not the “floating-point” part. Because people
often omit the word “binary”, floating-point arithmetic unfairly gets blamed.
In fact, even binary fixed-point types, such as the
fixed crate, also cannot represent
decimal fractions exactly. As long as a crate is decimal-based, whether
fixed-point or floating-point, it can represent decimal fractions exactly.
NOTE: Floating-point arithmetic has a standard called
IEEE 754, which defines both binary
floating-point formats (used by f32/f64) and decimal floating-point formats.
However, this standard is only one implementation approach for floating-point
arithmetic, not the entirety of floating-point arithmetic itself. Other
implementations are also possible. In practice, most decimal crates do not
follow IEEE 754 decimal formats.
Fixed-size and Arbitrary-precision
Fixed-precision vs Arbitrary-precision.
First, let’s clarify the meaning of the word “precision” here. The term has two conflicting meanings:
- Number of fraction places
- Number of significant digits
For example, the value 1.23 has 2 fraction places but 3 significant digits.
Both meanings are widely used. For example,
std::fmt uses the
former meaning, while here (Fixed-precision vs Arbitrary-precision) the latter
meaning is used. This is the standard terminology,
but it easily causes confusion. “Fixed-precision” is often misunderstood as
fixed fraction places, leading to confusion with fixed-point arithmetic.
To avoid ambiguity, this article uses the term Fixed-size instead of Fixed-precision.
As the name suggests, Fixed-size types use a fixed number of integers (one or more). Arbitrary-precision types use as many integers as necessary: expanding to the left to avoid overflow, and expanding to the right to avoid precision loss.
Naturally, this requires heap allocation, meaning the type is not Copy,
and the crate is not no-alloc. All operations also become significantly slower.
Unless there is a clear requirement for arbitrary precision, Fixed-size types
are generally preferable.
Choosing Crates & Benchmarks
Refer to Comparison and Benchmarking of Rust Decimal Crates: Benchmarking by WuBingzheng.
The most common and popular are bigdecimal (Floating-point / Arbitrary-precision), fastnum, and rust_decimal, where both are Floating-point & Fixed-size.
Quick overview of the fastnum crate
Reasons as to why one would choose this particular crate:
fastnumis a crate that implements fixed-precision calculations using fixed-point decimal arithmetic, which could be summarised succinctly ascoefficient / 10^exponent = 12345 / 100 = 123.45for acoefficientof12345andexponentof2.- The codebase is only 8-months (new) as of the time of typing and appears to be popular and well maintained on Github.
- Disclaimer: this is now a core dependency of a financial platform that I’m working on for a client. The codebase uses semantic types, and my work largely interacts with them. This article is inspired by this particular implementation.
Here’s another great write up, explaining why financial systems store the base value in cents:
Because floats and doubles cannot accurately represent the base 10 multiples that we use for money. This issue isn’t just for Java, it’s for any programming language that uses base 2 floating-point types.
In base 10, you can write 10.25 as 1025 _ 10-2 (an integer times a power of 10). IEEE-754 floating-point numbers are different, but a very simple way to think about them is to multiply by a power of two instead. For instance, you could be looking at 164 _ 2-4 (an integer times a power of two), which is also equal to 10.25. That’s not how the numbers are represented in memory, but the math implications are the same.
Even in base 10, this notation cannot accurately represent most simple fractions. For instance, you can’t represent 1/3: the decimal representation is repeating (0.3333…), so there is no finite integer that you can multiply by a power of 10 to get 1/3. You could settle on a long sequence of 3’s and a small exponent, like 333333333 * 10-10, but it is not accurate: if you multiply that by 3, you won’t get 1.
However, for the purpose of counting money, at least for countries whose money is valued within an order of magnitude of the US dollar, usually all you need is to be able to store multiples of 10-2, so it doesn’t really matter that 1/3 can’t be represented.
The problem with floats and doubles is that the vast majority of money-like numbers don’t have an exact representation as an integer times a power of 2. In fact, the only multiples of 0.01 between 0 and 1 (which are significant when dealing with money because they’re integer cents) that can be represented exactly as an IEEE-754 binary floating-point number are 0, 0.25, 0.5, 0.75 and 1. All the others are off by a small amount. As an analogy to the 0.333333 example, if you take the floating-point value for 0.01 and you multiply it by 10, you won’t get 0.1. Instead you will get something like 0.099999999786…
Representing money as a double or float will probably look good at first as the software rounds off the tiny errors, but as you perform more additions, subtractions, multiplications and divisions on inexact numbers, errors will compound and you’ll end up with values that are visibly not accurate. This makes floats and doubles inadequate for dealing with money, where perfect accuracy for multiples of base 10 powers is required.
A solution that works in just about any language is to use integers instead, and count cents. For instance, 1025 would be $10.25. Several languages also have built-in types to deal with money
– Source: https://stackoverflow.com/a/3730040
Let’s cover our first topic.
Semantic typing
We can use the newtype pattern (a.k.a New Type Idiom) in Rust and introduce such a type into our codebase. It will be backed by D128 from the fastnum crate.
use fastnum::D128;
// This example has the entire kitchen sink!
#[derive(Debug, Clone, Copy, Eq, Ord, PartialOrd, PartialEq, Hash, Default)]
pub struct Amount<const DECIMALS: usize>(D128);
/// Semantic type to indicate the underlying value is in Euros and not [`Cents`].
type Euros = Amount<0>;
/// A monetary amount in cents (2 decimal places).
#[allow(dead_code)]
type Cents = Amount<2>;
/// A monetary amount in cents/100 (4 decimal places), or "1/10,000" - hence the name.
pub type Pertenthousand = Amount<4>;
Let’s expand the interface for our Amount<D> type, starting with two methods.
-
new_scaled_i32: when creating a value ofAmount<D>, we use the internal storage to scale it byN. In the context of centsN = 2and thus,new_scaled_i32(1234)is stored internally asD128(digits=[1234], exp=[-2]. -
new_f64creates aD128value, just as it says on the tin.
impl<const DECIMALS: usize> Amount<DECIMALS> {
/// Treats the input as a scaled integer (e.g. 1234 → 12.34)
pub const fn new_scaled_i32(inner: i32) -> Self {
Self(D128::from_i32(inner).div(D128::from_i32(10_i32).pow(D128::from_usize(DECIMALS))))
}
pub const fn new_f64(inner: f64) -> Self {
Self(D128::from_f64(inner))
}
}
Consider the following example:
#[test]
fn convert_from_f64_using_new() {
// Assume we have a whole currency unit, parsed from a CSV file, into a f64. Since this is
// a whole currency unit, we need to convert it to cents before we can use our semantic type.
let provided = 1.23;
// Notice in `new_scaled_i32` above this is scaled before storage, and we can see it is internally
// stored with a scaling factor of N
let converted: Cents = Amount::new_scaled_i32((provided * 100.00) as i32);
assert!(format!("{:?}", &converted).contains("D128(digits=[123], exp=[-2]"));
// converting from internal storage, this is scaled by N of Amount<N>
let d: i32 = converted.into();
assert_eq!(d, 123_i32);
// Let's do the same but change our original value; assume we have 1 Euro:
let provided = 1.00;
let converted: Cents = Amount::new_scaled_i32((provided * 100.00) as i32);
// Interestingly, this is stored as 1e0 (which is the same as 100e-2).
assert!(format!("{:?}", &converted).contains("D128(digits=[1], exp=[0]"));
// converting from internal storage, this is scaled by N of Amount<N>
let d: i32 = converted.into();
assert_eq!(d, 100_i32);
}Example: Parsing values from CSV
Assume we have a CSV with column data, that we’ve sufficiently cleaned, let’s parse this into Cents. In the example below we are handling Euros, which commonly uses , as the decimal separator.
- Replace
,to. - Parse
&strvalues asf64 - As per our previous example, since the source values are Euros, we need to first convert to cents – otherwise our semantic typing will be wrong.
/// Parse amount (Euros) from CSV file into Cents.
fn parse_amount(value: &str) -> Result<Cents, CsvParseError> {
let value = value.trim();
if value.is_empty() {
return Err(CsvParseError::AmountMissingError(String::default()));
}
// Parse amount converting from Euros to cents.
let amount = (value.replace(',', ".").parse::<f64>().map_err(|err| {
CsvParseError::AmountParseError(format!("Invalid amount: {value}: {err}"))
})? * 100.00) as i32;
Ok(amount.into())
}Github
Code examples for this article:
Further reading
📉 Binary Conversion & Precision Loss (General)
-
The Bad, the Good and the Ugly of Binary Floating Point Numbers
Explains why numbers like0.1 + 0.2 != 0.3occur and demystifies binary fractional representations. -
Maybe Demystifying Floating Point Precision
In-depth explanation of mantissa/exponent structure and why decimal digits are lost. -
Binary Representation of Floating-Point Numbers
Interactive visual tool showing why some decimal values cannot be exactly represented in binary. -
What Every Computer Scientist Should Know About Floating-Point
Classic paper explaining IEEE‑754 floating-point math, edge cases, and precision traps. -
Solving the “Floating Point Precision” Problem With… Floats?
Explores workarounds using floats with fixed-precision scaling techniques.
đź’° Best Practices in Financial Calculations (General)
-
Precision Matters: Why Using Cents Instead of Floating Point Is Crucial
Demonstrates rounding bugs in real-world fintech apps and proposes storing cents as integers. -
You Better Work in Cents, Not Dollars
Advice from fintech practitioners on using minor units to avoid floating-point inaccuracies. -
Storing Currency Values: Data Types and Best Practices
Explains and compares floats,Decimaltypes, and integer cents with pros and cons. -
Why Not Use Double or Float to Represent Currency?
High-voted StackOverflow answer with clear examples of float errors in currency math. -
Handling Monetary Amounts
Overview of various ways to handle money in code, and their pitfalls. -
Deciding on Money Data Types (Stripe Engineering)
How Stripe models money safely and why they avoid floats entirely.
🦀 Rust-Specific Articles & Discussions
-
Demystifying Floats in Rust: Precision, Performance, and When to Use Which
Comparesf32,f64, and alternatives like fixed-point in Rust. -
Handling Floating Point Challenges with Rust (Conf42 Talk)
Covers float comparison strategies, summation algorithms, and avoiding drift in Rust. -
Idiomatic Way to Handle Floating Point Precision (Reddit)
Community suggestions on using crates likerust_decimal,fastnum, orbigdecimal. -
Rust Users Forum: Floating Point Number Tricks
Tips and techniques for numerical accuracy and debugging float behavior. -
Rust Users Forum: What’s this precision in float?
FAQ-style discussion on the decimal precision off32(7 digits) andf64(15–16 digits).