Money is not a double: A Hard Lesson from Building a Trading App
Why supporting high-precision assets exposes the cracks in floating point math.

When you build your first fintech app in Flutter, using double for money feels completely normal.
double price = 10.25;
It compiles.
It runs.
The UI looks correct.
QA passes it.
So what's the problem?
The problem is that double was never meant to store money.
And one day, usually when your product grows, that decision quietly comes back to hurt you.
The Problem Nobody Sees at First
In Dart (like most languages), double uses floating-point representation.
Here's something simple:
void main() {
double a = 0.1;
double b = 0.2;
print(a + b);
}
Output:
0.30000000000000004
That extra 4 at the end?
That's not a bug in Dart.
That's how floating-point numbers work.
Most decimal numbers cannot be stored exactly in binary.
And double stores numbers in binary.
Why This Doesn’t Break Your App Immediately
Most traditional fiat currencies use only 2 decimal places:
10.25
99.99
1.50
Even if the internal number is slightly wrong, we usually format it like this:
value.toStringAsFixed(2)
And the UI shows:
10.25
So everything looks correct.
The error is still there.
It's just hidden.
When Things Start Breaking: Crypto
Now imagine your app starts supporting:
Bitcoin - 8 decimal places
Ethereum - 18 decimal places
Tether - 6 decimal places
Now precision really matters.
Example:
0.00000013 BTC
That last digit is real money.
If you use double:
Adding values can shift decimals.
Multiplication introduces rounding.
Conversions between currencies create tiny mismatches.
Formatting hides precision loss.
Parsing user input mutates values silently.
And the worst part, its a bug hidden in plain sight but still goes away unnoticed.
Issue entrypoint: UI & Backend Round Trip
This is where things actually break.
Imagine this flow:
Backend sends:
"0.00000013"You convert it to
doubleUser edits the amount
You format and send it back
Somewhere in that round trip, the value changes slightly.
You expected:
0.00000013
You send:
0.00000012
In financial systems, that is unacceptable.
Small rounding issues lead to:
Ledger mismatches
Transaction validation failures
“Amount does not match” backend errors
Floating point errors are small.
But money systems require exactness, not approximation.
Why double Is the Wrong Tool
Floating point numbers are stored like this:
sign × mantissa × 2^exponent
They are optimised for:
Scientific calculations
Graphics
Physics simulations
They are not optimised for:
Accounting
Trading systems
Ledger calculations
User-entered currency
Money is decimal.
Floating point is binary.
That mismatch is the root cause.
So what's the right way?
1. Store the Smallest Unit
Instead of:
double amount = 10.25;
Do this:
int amountInCents = 1025;
For crypto:
1 BTC = 100,000,000 satoshis
Store satoshis as
int
No rounding.
No precision loss.
No floating point.
2. Create a Proper Money Type
Instead of passing raw numbers everywhere, define a safe structure.
class Money {
final BigInt amount; // smallest unit
final int scale; // decimal places
final String currency;
Money({
required this.amount,
required this.scale,
required this.currency,
});
String format() {
final divisor = BigInt.from(10).pow(scale);
final whole = amount ~/ divisor;
final fraction = (amount % divisor)
.toString()
.padLeft(scale, '0');
return "\(whole.\)fraction $currency";
}
}
Now:
Precision is controlled.
Each currency defines its scale.
UI formatting is deterministic.
Math operations stay exact.
This is how robust financial systems are built.
Example: Showing Safe Values on a Transaction History Screen
Let's say your backend sends smallest unit values.
Transaction model:
class TransactionModel {
final String title;
final BigInt amount; // smallest unit
final int scale;
final String currency;
TransactionModel({
required this.title,
required this.amount,
required this.scale,
required this.currency,
});
String formattedAmount() {
final divisor = BigInt.from(10).pow(scale);
final whole = amount ~/ divisor;
final fraction = (amount % divisor)
.toString()
.padLeft(scale, '0');
return "\(whole.\)fraction $currency";
}
}
UI:
ListView.builder(
itemCount: transactions.length,
itemBuilder: (context, index) {
final txn = transactions[index];
return ListTile(
title: Text(txn.title),
trailing: Text(
txn.formattedAmount(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
);
},
);
Notice what's missing?
No double.
No toStringAsFixed.
No floating point math.
Just exact numbers.
One Rule You Should Never Break
Never do this for currency:
double.parse(userInput)
Instead:
Keep input as string
Validate format
Convert manually to smallest unit
Store as
BigIntorint
Money should never pass through floating point.
The Lesson
Using double for money is easy.
It works in prototypes.
It works in demos.
It works when you only support 2 decimal currencies.
But products evolve.
They add:
Crypto
Micro-transactions
Fractional trading
High precision calculations
And that's when floating point becomes a silent production bug.
It doesn't crash your app.
It just slowly corrupts your numbers.
Final Thought
Floating point errors are invisible in the beginning.
But in financial systems, invisible errors become very visible money.
And systems involving money don't forgive approximation.





