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.
let total = 0.1 + 0.2
print(total == 0.3) // false
print(total) // 0.30000000000000004One 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.
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:
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 aDouble, so the value the user typed isn't already wrong before we start. - Rounding to the nearest paisa uses
.plainrounding (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
nilinstead of silently wrapping around to a nonsense value. The range stops atInt64.min + 1so 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:
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.56round-trips to exactly123456minor 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
- Never store money in
DoubleorFloat. - Store an integer count of the currency's smallest unit, together with the currency.
- Round exactly once, when converting user input, and make that conversion fail instead of overflowing.
- Treat cross-currency arithmetic as a bug, not something to guess at.
- 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.