Skip to content

LOG//ENTRY

Why I Store Money as Integers in Swift, Not Double

Double can't hold 0.1 exactly, so money totals drift. How MoneyLog stores rupees as whole paise in an Int64, converts from Decimal safely and blocks mixed currencies.

By Siva Sundar · · 4 min read

When I started MoneyLog, my SwiftUI expense tracker, the first decision I made had nothing to do with UI. It was how to store a number like ₹250.75. Getting that wrong doesn't crash the app. It does something worse: the totals slowly stop agreeing with each other, and nobody notices until a user does.

This post explains the problem with Double, the Money type I use instead, and the small rules around it that keep every total in the app exact.

The problem: Double can't hold 0.1

Double is a binary floating-point type. It's great for physics and graphics, but it can only represent fractions whose denominator is a power of two exactly. One tenth isn't one of them, so 0.1 is stored as the nearest binary value, which is very slightly off.

swift
let total = 0.1 + 0.2
print(total == 0.3)        // false
print(total)               // 0.30000000000000004

One error like that is invisible once you format to two decimal places. The trouble is that money apps add things up all day: every expense in a month, every transaction on an account, every category in a chart. Tiny errors accumulate, and different screens that sum the same data in a different order can round to different answers. A budget screen that says ₹4,999.99 while the dashboard says ₹5,000.00 destroys trust in the whole app.

The fix: store whole paise in an Int64

Every currency has a smallest unit. For the rupee it's the paisa, one hundredth of a rupee. If you store amounts as a whole number of those units, there's nothing to round: ₹250.75 becomes the integer 25075, and integer addition is exact.

swift
struct Money: Hashable, Codable, Sendable {
    var minorUnits: Int64
    var currency: CurrencyCode

    init(minorUnits: Int64, currency: CurrencyCode = .default) {
        self.minorUnits = minorUnits
        self.currency = currency
    }

    var decimalValue: Decimal {
        Decimal(minorUnits) / Decimal(currency.minorUnitScale)
    }
}

The currency travels with the number, and minorUnitScale says how many minor units make one major unit (100 for INR). An Int64 holds about 9.2 quintillion paise, far more than any personal finance app will ever need.

Converting user input safely

People type decimals, so somewhere you have to turn 250.75 into 25075. That conversion is the one place rounding is allowed to happen, and it should happen once, deliberately:

swift
init?(decimal: Decimal, currency: CurrencyCode = .default) {
    guard decimal.isFinite else { return nil }
    var scaled = decimal * Decimal(currency.minorUnitScale)
    var rounded = Decimal()
    NSDecimalRound(&rounded, &scaled, 0, .plain)
    guard rounded <= Decimal(Int64.max),
          rounded >= Decimal(Int64.min + 1) else { return nil }
    self.init(minorUnits: NSDecimalNumber(decimal: rounded).int64Value,
              currency: currency)
}

Three details matter here:

  • The input is a Decimal, not a Double, so the value the user typed isn't already wrong before we start.
  • Rounding to the nearest paisa uses .plain rounding (half away from zero), the rule people expect on a receipt.
  • The initialiser is failable. If a number is too large to fit, it returns nil instead of silently wrapping around to a nonsense value. The range stops at Int64.min + 1 so that negating any stored amount is always safe.

In the add-transaction sheet, the amount entry builds up digits directly: rupees first, then paise only after you tap the decimal point. So the common path never goes through floating point at all.

Making mixed currencies impossible to ignore

Adding ₹100 to $100 has no correct answer without an exchange rate. A lot of code quietly adds the raw numbers anyway. In MoneyLog, arithmetic between different currencies is treated as a programming error:

swift
extension Money {
    static func + (lhs: Money, rhs: Money) -> Money {
        precondition(lhs.currency == rhs.currency,
            "Cannot add \(lhs.currency.rawValue) to \(rhs.currency.rawValue)")
        return Money(minorUnits: lhs.minorUnits + rhs.minorUnits,
                     currency: lhs.currency)
    }
}

precondition stops the app in debug builds, at the exact line where the mistake happens, instead of letting a wrong total reach the screen. The same check guards subtraction and comparison, so sorting a list of mixed-currency amounts fails loudly too.

Formatting is a separate job

Storage and display are different concerns. Money knows its value. A separate MoneyFormatter turns it into text for the user's region: ₹1,50,000 in India (grouped in lakhs) and $150,000 in the US. It shows ₹250 for a round number and ₹250.75 when there are paise. Keeping formatting out of the model means the maths never depends on a locale setting.

Testing the parts where being wrong is expensive

Money arithmetic and formatting have their own tests, written with Swift Testing. They're cheap to write because Money is a plain value type with no database or UI attached. The cases MoneyLog checks:

  • A decimal like 1234.56 round-trips to exactly 123456 minor units and back
  • Rounding at half a paisa goes the way people expect
  • Zero-decimal currencies such as the Japanese yen, where one unit is the minor unit
  • Addition, subtraction, comparison, negative results and magnitude
  • Indian grouping (₹1,50,000), no “.00” on whole amounts, and a plus sign only for positive values

Takeaways

  1. Never store money in Double or Float.
  2. Store an integer count of the currency's smallest unit, together with the currency.
  3. Round exactly once, when converting user input, and make that conversion fail instead of overflowing.
  4. Treat cross-currency arithmetic as a bug, not something to guess at.
  5. Keep formatting separate from storage, and test the arithmetic directly.

None of this is visible in the UI, and that's the point. When the numbers are right, users never think about them. The full source, including the tests, is in the MoneyLog repository on GitHub.