Skip to main content

Command Palette

Search for a command to run...

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.

Published
5 min read
Money is not a double: A Hard Lesson from Building a Trading App

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:

  1. Backend sends: "0.00000013"

  2. You convert it to double

  3. User edits the amount

  4. 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 BigInt or int

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.