rustdecimal — Exact Decimal Arithmetic

SkillCommerce & finance

rust_decimal — fixed-point decimal arithmetic for Rust. 128-bit signed decimal, exact (no float rounding errors), suitable for finance, currency, Bitcoin sat ↔ fiat conversions, accounting. Up to 28 significant digits, arithmetic preserves precision. Includes RoundingStrategy for explicit truncation/rounding control. Integrates with serde, sqlx, postgres.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the rustdecimal — Exact Decimal Arithmetic skill

What this skill tells your AI

The instructions your AI receives, as published by claude-dev-suite/claude-dev-suite in skills/data-processing/rust-decimal/SKILL.md and read by ahel’s review.

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: rust-decimal.

Why Not f64 for Money

let total: f64 = 0.1 + 0.2;
println!("{}", total);                            // 0.30000000000000004 — WRONG

let cents: f64 = 100.50_f64;
let cents_int = (cents * 100.0) as i64;
println!("{}", cents_int);                        // 10049 — WRONG (should be 10050)

Floating-point cannot represent most decimal fractions exactly. For money, this is unacceptable: rounding bugs become real losses, audit failures, regulatory issues.

For Bitcoin specifically: always use i64 for sats (1 BTC = 100,000,000 sats fits easily). For fiat conversions, multi-currency display, fee calculations involving rates → use Decimal.

Setup

[dependencies]
rust_decimal = "1.36"
rust_decimal_macros = "1.36"

# Optional features
rust_decimal = { version = "1.36", features = ["serde-with-str", "db-postgres", "macros"] }

Useful features:

  • serde-with-str — serialize as string (preserves precision in JSON)
  • serde-with-float — serialize as float (loses precision, but compatible)
  • serde-with-arbitrary-precision — preserve via serde_json's arbitrary precision
  • db-postgres, db-tokio-postgres, db-diesel — DB integration
  • macrosdec! literal macro
  • maths — extra math functions (exp, ln, sin, etc.)
  • tokio-pg — async postgres
  • borsh — binary serialization for blockchain projects

Basic Usage

use rust_decimal::Decimal;
use rust_decimal_macros::dec;

let a = dec!(0.1);
let b = dec!(0.2);
let c = a + b;
assert_eq!(c, dec!(0.3));                         // EXACT

let price = dec!(19.99);
let quantity = dec!(3);
let total = price * quantity;
assert_eq!(total, dec!(59.97));

// Division
let avg = total / quantity;
assert_eq!(avg, dec!(19.99));

dec! macro creates Decimal at compile time, validates literal.

From Strings

use std::str::FromStr;

let amount = Decimal::from_str("0.00012345")?;
let amount: Decimal = "12345.6789".parse()?;

For untrusted input always parse — never as from float.

From Integers

let sats = Decimal::from(100_000_000_i64);
let btc = sats / dec!(100_000_000);
assert_eq!(btc, dec!(1));

To Various Types

let d = dec!(123.456);

let s: String = d.to_string();                     // "123.456"
let f: f64 = d.try_into()?;                        // 123.456 (lossy)
let i: i64 = d.try_into()?;                        // ERROR — has fractional part
let i: i64 = (d * dec!(1_000)).round().try_into()?; // 123456 (scaled)

Bitcoin sats ↔ fiat Conversion

use rust_decimal::Decimal;
use rust_decimal_macros::dec;

const SATS_PER_BTC: i64 = 100_000_000;

fn sats_to_fiat(sats: i64, btc_to_fiat_rate: Decimal) -> Decimal {
    let btc = Decimal::from(sats) / Decimal::from(SATS_PER_BTC);
    btc * btc_to_fiat_rate
}

fn fiat_to_sats(fiat: Decimal, btc_to_fiat_rate: Decimal) -> i64 {
    let btc = fiat / btc_to_fiat_rate;
    let sats = btc * Decimal::from(SATS_PER_BTC);
    sats.round().to_i64().unwrap_or(0)
}

// Example: at $60,000/BTC
let rate = dec!(60000);
let sats = 100_000;                                // 0.001 BTC
let usd = sats_to_fiat(sats, rate);
assert_eq!(usd, dec!(60.00));

let euro_amount = dec!(100);
let euro_rate = dec!(55000);                       // €55,000/BTC
let needed_sats = fiat_to_sats(euro_amount, euro_rate);
assert_eq!(needed_sats, 181_818);                  // sats

Rounding

use rust_decimal::RoundingStrategy;

let d = dec!(2.5);

let rounded = d.round();                                          // banker's rounding by default → 2
let rounded_half_up = d.round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);  // 3
let truncated = d.round_dp_with_strategy(0, RoundingStrategy::ToZero);  // 2

// Specific decimal places
let price = dec!(19.9999);
let display_2dp = price.round_dp(2);                              // 20.00
let display_4dp = price.round_dp(4);                              // 19.9999

RoundingStrategy variants:

  • MidpointAwayFromZero — "round half up" (most intuitive)
  • MidpointTowardZero — "round half down"
  • MidpointNearestEven — banker's rounding (default for round())
  • ToZero — truncate
  • AwayFromZero — always round away from zero
  • ToNegativeInfinity — floor
  • ToPositiveInfinity — ceiling

For fiat display: typically MidpointAwayFromZero to 2 decimal places.

For Bitcoin fee calculations: use ToPositiveInfinity (always pay enough fee).

Comparison

let a = dec!(1.0);
let b = dec!(1.00);

assert_eq!(a, b);                                  // ✅ equal in value
assert_eq!(a.scale(), 1);                          // a has scale 1
assert_eq!(b.scale(), 2);                          // b has scale 2

// Hash works correctly even with different scales
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(a);
assert!(set.contains(&b));

Serde

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Transaction {
    amount: Decimal,
    fee: Decimal,
    rate_at_send: Decimal,
}

let tx = Transaction {
    amount: dec!(0.001),
    fee: dec!(0.00001),
    rate_at_send: dec!(60000),
};

let json = serde_json::to_string(&tx)?;
println!("{}", json);
// Default: {"amount":0.001,"fee":0.00001,"rate_at_send":60000}
// With "serde-with-str" feature: amounts as strings

For wallet apps storing tx history in JSON, use serde-with-str to preserve exact values:

rust_decimal = { version = "1.36", features = ["serde-with-str"] }

SQLite / SQLCipher Storage

rust_decimal doesn't have native rusqlite support, but easy pattern:

use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rust_decimal::Decimal;
use std::str::FromStr;

// Storage as TEXT (preserves precision)
struct DecimalSql(Decimal);

impl ToSql for DecimalSql {
    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
        Ok(ToSqlOutput::from(self.0.to_string()))
    }
}

impl FromSql for DecimalSql {
    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
        let s = value.as_str()?;
        Decimal::from_str(s)
            .map(DecimalSql)
            .map_err(|_| FromSqlError::InvalidType)
    }
}

For SQLite always store as TEXTREAL (float) loses precision.

For Postgres: use db-postgres feature → maps to NUMERIC type natively.

Custom Display Formatting

use rust_decimal::Decimal;

let amount = dec!(1234567.89);

println!("{}", amount);                            // 1234567.89
println!("{:.2}", amount);                          // 1234567.89

// Locale-aware (manual)
fn format_eu(d: Decimal) -> String {
    let s = d.to_string();
    let parts: Vec<&str> = s.split('.').collect();
    let integer = parts[0];
    let decimal = parts.get(1).copied().unwrap_or("");

    // Add thousand separators
    let chars: Vec<char> = integer.chars().rev().collect();
    let with_seps: String = chars
        .chunks(3)
        .map(|chunk| chunk.iter().collect::<String>())
        .collect::<Vec<_>>()
        .join(".")
        .chars().rev().collect();

    if decimal.is_empty() {
        with_seps
    } else {
        format!("{},{}", with_seps, decimal)        // EU: 1.234.567,89
    }
}

For real i18n use unic-langid + icu crate.

Math Operations

let a = dec!(10);
let b = dec!(3);

let q = a / b;
println!("{}", q);                                 // 3.3333333333333333333333333333

// Limit precision
let q_truncated = q.round_dp(2);                  // 3.33

// Power
let n = dec!(2).powi(10);                          // 1024

// Absolute, sign
assert_eq!(dec!(-5).abs(), dec!(5));
assert!(dec!(-1).is_sign_negative());

// MIN/MAX
let max = Decimal::MAX;                            // 79228162514264337593543950335
let min = Decimal::MIN;                            // -79228162514264337593543950335

// Constants
assert_eq!(Decimal::ZERO, dec!(0));
assert_eq!(Decimal::ONE, dec!(1));
assert_eq!(Decimal::TWO, dec!(2));
assert_eq!(Decimal::TEN, dec!(10));
assert_eq!(Decimal::ONE_HUNDRED, dec!(100));
assert_eq!(Decimal::ONE_THOUSAND, dec!(1000));

With maths feature:

use rust_decimal::MathematicalOps;

let e = Decimal::E;
let pi = Decimal::PI;
let exp = dec!(1).exp();                           // 2.71828...
let ln = dec!(2.71828).ln();                       // 1

Wallet Pattern (BHODL Cost-Basis Tracking)

use rust_decimal::Decimal;
use rust_decimal_macros::dec;

struct Lot {
    sats_acquired: i64,
    cost_basis_per_sat: Decimal,                   // fiat / sat at acquisition
    timestamp: i64,
}

struct CapitalGain {
    proceeds: Decimal,
    cost_basis: Decimal,
    gain: Decimal,
    long_term: bool,
}

fn calculate_gain(
    lot: &Lot,
    sats_sold: i64,
    sale_price_per_sat: Decimal,
    sale_timestamp: i64,
) -> CapitalGain {
    let proceeds = Decimal::from(sats_sold) * sale_price_per_sat;
    let cost_basis = Decimal::from(sats_sold) * lot.cost_basis_per_sat;
    let gain = proceeds - cost_basis;

    let one_year_secs = 365 * 24 * 60 * 60;
    let long_term = sale_timestamp - lot.timestamp >= one_year_secs;

    CapitalGain { proceeds, cost_basis, gain, long_term }
}

Critical: never compute capital gains in float. Real money, real tax exposure.

Anti-Patterns

Anti-patternWhy it's badCorrect approach
f64 for moneyPrecision loss compoundsAlways Decimal (or integer cents/sats)
as i64 from Decimal (no rounding)Truncates silentlyExplicit .round().to_i64()
Mixing Decimal and f64Silent conversions lose precisionStay in Decimal end-to-end
Storing as REAL in SQLiteLoses precisionStore as TEXT, parse on read
Comparing with == after float roundtripFalse positivesStay in Decimal; or compare with epsilon (rare)
Hardcoded "$" in app stringsi18n bugUse locale-aware formatter
Decimal for sat amountsOverkill, slowerUse i64 (sats fit easily in 64-bit)
Re-creating Decimal::from_str("60000") in hot loopSlowUse dec!(60000) literal
Forgetting .round_dp(2) for displayLong fractional digits leakFormat for display vs storage
Using unwrap() on try_into::<i64>()Panic on out-of-rangeHandle the error

Performance

  • 128-bit operations are fast (1-3ns on modern CPU)
  • Slower than i64 arithmetic (~5-10x) — for large bulk math, consider integer
  • Allocation-free (Decimal is Copy)
  • For BHODL-scale apps (single user, hundreds of txs/day), zero perf concern

Troubleshooting

SymptomCauseFix
dec!(1.234567890123456789012345) compile errorExceeds 28-digit precisionReduce precision or use Decimal::from_str_exact
try_into::<i64>() failsDecimal too large or has fractionCheck magnitude; round first
Serde serializes as floatDefault behaviorEnable serde-with-str feature
SQLite query returns wrong valueStored as REALStore as TEXT, custom ToSql/FromSql
.round() gives unexpected resultBanker's rounding (default)Use round_dp_with_strategy for explicit
Postgres NUMERIC mismatchDriver feature missingEnable db-postgres
dec!(0.1) + dec!(0.2) != 0.3_f64Comparing Decimal to f64Stay in Decimal (dec!(0.3))
Slow JSON parsingserde-with-arbitrary-precision overheadserde-with-str is faster

When NOT to Use This Skill

ScenarioUse Instead
Crypto primitives (signatures, hashes)security/libsodium / bitcoin/cryptography/*
Arbitrary-precision integersnum-bigint
Time/duration mathchrono / time
Sat amounts only (no fractional)i64 directly
Server-side accounting (full ERP)SQL NUMERIC / dedicated decimal lib
Heavy floating-point sciencef64 is correct

Signals

GitHub stars
33
Forks
6
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
rust-decimal
Source
github.com/claude-dev-suite/claude-dev-suite