Storing Money in Room: Every Balance Bug Starts as a Double
A dashboard that disagrees with the sum of its own rows by a penny. Minor units as a Long, the currencies where multiplying by 100 is wrong, and the JSON round trip that undoes it.
A user opens the app and the dashboard says they are owed £412.35. They tap through to the list, add up the rows themselves because that is what people do with money, and get £412.34. One penny. No exception, no crash report, nothing in Crashlytics, and no way to reproduce it on the two rows you test with.
A balance is not a measurement. It is a count of the smallest unit there is, and the moment you store it as a floating-point number it stops being exactly anything. Every rounding bug in a finance app is downstream of one decision made in the entity class on the first afternoon, usually by writing val amount: Double because that is what the field is called on the screen.
This is the storage decision and the four places it leaks: the column type, the currency, the JSON on the way in and out, and the arithmetic that splits one number into several. The offline-first architecture around it — the write path, the sync queue, the conflict rule — is a separate post; this one is about what goes in the column.
What SQLite actually stores
Room maps a Kotlin Double to SQLite's REAL storage class, which is a 64-bit IEEE-754 binary float. Binary floats represent sums of powers of two, and 0.1 is not one — it is stored as the nearest representable value, which is slightly more than a tenth. Every decimal amount that is not a sum of halves, quarters and eighths goes in approximately and comes out approximately.
0.1 + 0.2 = 0.30000000000000004
0.1 + 0.7 = 0.7999999999999999
1.1 * 3 = 3.3000000000000003
4.35 * 100 = 434.99999999999994
(4.35 * 100).toLong() = 434 // the 435 pennies you meant
BigDecimal(0.1) = 0.1000000000000000055511151231257827…
// the constructor faithfully preserves the errorThe fourth line is the one to sit with, because multiplying by a hundred to get pennies is exactly what code does at the boundary between a text field and a database. £4.35 is not 435 pence there. It is a hair under, and a truncating conversion turns it into 434 — a penny gone before it was ever stored.
On one row the rest of it is invisible: the display formatter rounds it away and £4.35 shows as £4.35. It becomes visible when you sum a few hundred rows in SQL, because SUM() over a REAL column accumulates every one of those errors and the total drifts away from what the same numbers produce when a person adds them up. The dashboard and the list disagree, and both of them are reading the same table.
Count the smallest unit
Store an integer number of pence, cents or fils. A Long holds about nine quintillion of them, which is a ceiling no personal finance app will meet, and integer arithmetic is exact by construction. A value class keeps that from decaying back into a bare number that somebody adds to a quantity.
@JvmInline
value class Money(val minor: Long)
@Entity(tableName = "payments")
data class PaymentEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "amount_minor") val amount: Money,
// Never store an amount without the currency it is in. Adding
// this column later means guessing what the old rows meant.
@ColumnInfo(name = "currency") val currency: String,
@ColumnInfo(name = "occurred_at") val occurredAt: Long,
)Room has supported Kotlin value classes in entities since 2.6.0 on KSP, so Money costs nothing at runtime and needs no type converter — the column is a plain INTEGER and the wrapper exists only in the type system. If you are on an older version, the same shape works with a one-line @TypeConverter in each direction.
The naming matters more than it looks. Calling the column amount_minor rather than amount is the thing that stops the next person — or you, in March — from writing amount = 19.99 and storing nineteen pence.
Multiplying by 100 is wrong in currencies you will meet
The conversion between what the user types and what you store is not a constant. The number of decimal places is a property of the currency: most have two, the Japanese yen and the Icelandic króna have none, and the Kuwaiti, Bahraini, Jordanian, Omani and Tunisian dinars have three. A hundred hard-coded in a formatter is a factor-of-ten error in Kuwait and a factor-of-a-hundred error in Tokyo.
fun exponentFor(code: String): Int =
Currency.getInstance(code).defaultFractionDigits
exponentFor("GBP") // 2 -> 412.35 is stored as 41235
exponentFor("JPY") // 0 -> 4000 is stored as 4000
exponentFor("KWD") // 3 -> 4.125 is stored as 4125Multi-currency has a second rule that costs more to learn late. Never store a converted total on its own. An amount, its currency, and — if you converted it — the rate and the moment you used, all in the same row. A column holding "the value in your home currency" is a number that was true once, and every report built on it silently rewrites history the next time rates move.
The JSON round trip that undoes all of it
You can get the entity right and still lose the integer on the way to a server and back, because JSON has one number type and the parser decides what to make of it.
Gson's default policy for a number landing in an Any or an Map<String, Any> is Double. A stored 41235 comes back as 41235.0, which is harmless, and a larger identifier or amount comes back as 1.234567890123E12, which is not. The fix is not a smarter formatter — it is never letting money pass through an untyped map. Deserialise into a class whose field is a Long, or set the object-to-number policy explicitly if you cannot.
The same trap has a cloud-shaped version. Firestore stores integers and doubles as two different types, and which one a field holds is decided by whatever client wrote it last — not by a schema. One web admin screen writing 12.34 into a field your Android app reads as a Long is a crash on somebody's phone and a document you cannot see anything wrong with in the console.
Splitting a total without losing a penny
Integers make addition exact. They do not make division exact, and division is where the last penny goes: £10.00 shared three ways is 333, 333 and 333, and one penny is now nowhere. The same gap opens up on a percentage discount, on interest, and on any fee taken as a share of a total.
The rule is to allocate rather than to round independently. Give everybody the floor of their share, then hand out the remaining units one at a time, largest fractional part first, until the pieces sum exactly to what you started with.
fun allocate(total: Long, weights: List<Long>): List<Long> {
val totalWeight = weights.sum()
require(totalWeight > 0) { "weights must not sum to zero" }
val shares = weights.map { total * it / totalWeight }.toMutableList()
var remainder = total - shares.sum()
// Whoever was cut by the most gets the next spare unit. Any
// deterministic order works; what matters is that the parts
// add back up to the total, every time, with no rounding step.
val order = weights.indices.sortedByDescending { (total * weights[it]) % totalWeight }
for (i in order) {
if (remainder == 0L) break
shares[i] += 1
remainder -= 1
}
return shares
}
allocate(1000, listOf(1, 1, 1)) // [334, 333, 333] — sums to 1000Round once, at the point the money actually moves, and never round an already-rounded number a second time. Two rounding steps in a chain is how a report ends up a few pence away from the ledger it was built from.
Where BigDecimal belongs, and where it does not
BigDecimal is the right type for a rate and for multi-step interest arithmetic, where you need more precision than a penny during the calculation and a stated scale at the end. It is the wrong type for the column: Room has no built-in converter for it, storing it as text makes SUM() and ORDER BY useless, and storing it as REAL puts you back where you started. Keep Long in the table, lift to BigDecimal for the sum, come back down.
One trap inside the trap: never construct one from a double. BigDecimal(0.1) faithfully preserves the error you already have. valueOf, or the string constructor, or — best — an exact one built from the minor units you stored, as in BigDecimal.valueOf(41235, 2).
Also outside this post: which way to round is a jurisdictional and contractual question, not a technical one. Half-up, half-even and directed rounding all have places where they are the required answer for tax or for interest, and a library cannot pick for you. What is being argued here is only that the number should be exact enough to round on purpose.
If the column is already a Double
You cannot fix it on read. The values in the table are already approximations, and formatting them nicely just hides the drift for another release. Do it once, in a Room migration: add the integer column and the currency column, populate the first by rounding the old value at the currency's scale, and drop the old one in the same version so there is never a period where both exist and disagree.
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE payments ADD COLUMN amount_minor INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE payments ADD COLUMN currency TEXT NOT NULL DEFAULT 'GBP'")
// ROUND before CAST — CAST alone truncates toward zero, which
// loses a penny on the way in and gains one on a negative row.
// Every stored value was entered by a human at two decimal
// places, which is what makes this recoverable at all.
db.execSQL("UPDATE payments SET amount_minor = CAST(ROUND(amount * 100) AS INTEGER)")
}
}Then keep the integer all the way to the widget and format at the last possible moment, from the minor units rather than from a division: BigDecimal.valueOf(minor, exponent) handed to a currency formatter. The double never comes back, the dashboard agrees with the list, and the penny that started all of this has nowhere left to go.
LoanDebtManager — $11
Loans, debts and repayments tracked offline in Kotlin — Room, Compose, running totals and monthly interest, with the whole project to read.