Stop Storing Money in float64

go dev.to

I've been writing Go backends for over a decade, and money handling is the one topic where I keep seeing the same bug in codebase after codebase: a Price float64 field somewhere in a struct, a DECIMAL column mapped to float64 by the ORM, and a team that swears the numbers "look fine" — right up until finance asks why the invoice totals are off by a cent.

I got this wrong myself for years. This post is the fix I finally settled on and now bake into every project: a Money value object built on integer minor units, with the currency attached, and validation that makes invalid money literally unconstructible. All code below is real code from my go-ddd template repo (just tagged v1.0.0).

Why float64 money is broken

Not "risky in theory". Broken. Binary floating point cannot represent most decimal fractions exactly. 0.1 doesn't exist in float64 — you get the closest representable value, which is 0.1000000000000000055511151231257827021181583404541015625.

You've seen this one:

package main

import "fmt"

func main() {
    fmt.Println(0.1 + 0.2)                 // 0.30000000000000004
    fmt.Println(0.1+0.2 == 0.3)           // false
}
Enter fullscreen mode Exit fullscreen mode

Cute party trick, easy to dismiss. The version that actually hurts is drift under accumulation, because real systems don't add two prices — they sum line items, apply percentages, aggregate daily revenue:

package main

import "fmt"

func main() {
    var total float64
    for i := 0; i < 1000; i++ {
        total += 0.10 // a 10-cent fee, a thousand times
    }
    fmt.Println(total)        // 99.9999999999986
    fmt.Println(total == 100) // false
}
Enter fullscreen mode Exit fullscreen mode

A thousand 10-cent charges and you've already lost the exact comparison. Now imagine that total feeding a >= threshold check, or a reconciliation job diffing against the payment provider's report. The diff is never zero, someone writes an epsilon comparison, the epsilon is wrong for large amounts, and you're in whack-a-mole territory.

And float64 has a second, quieter problem that has nothing to do with binary representation: it's a bare number. 19.99 of what? EUR? USD? Cents already? I've debugged a production incident where one service sent euros and another interpreted the same field as cents. The type system happily let a 100x pricing error through, because float64 == float64.

So the fix has to solve both: exact arithmetic and an amount that can't be separated from its currency.

The Money value object

The whole thing fits in one small file. Amount in minor units (cents) as int64, currency as a typed string, both fields unexported:

// Currency is the ISO 4217 code of a Money value.
type Currency string

const (
    EUR Currency = "EUR"
    USD Currency = "USD"
)

var supportedCurrencies = map[Currency]struct{}{
    EUR: {},
    USD: {},
}

// Money is an immutable value object storing an amount in minor units
// (cents) to avoid floating-point rounding errors.
type Money struct {
    cents    int64
    currency Currency
}

func NewMoney(cents int64, currency Currency) (Money, error) {
    if cents < 0 {
        return Money{}, fmt.Errorf("%w: amount must not be negative", ErrValidation)
    }
    if _, ok := supportedCurrencies[currency]; !ok {
        return Money{}, fmt.Errorf("%w: unsupported currency %q", ErrValidation, currency)
    }

    return Money{cents: cents, currency: currency}, nil
}

func (m Money) Cents() int64      { return m.cents }
func (m Money) Currency() Currency { return m.currency }

func (m Money) String() string {
    return fmt.Sprintf("%d.%02d %s", m.cents/100, m.cents%100, m.currency)
}
Enter fullscreen mode Exit fullscreen mode

Design decisions worth spelling out:

Unexported fields, constructor with validation. Because cents and currency are unexported, the only way to build a Money outside the package is NewMoney. That means every Money in the system passed the negative check and the currency whitelist. There's no "construct it raw and validate later" path to forget. This is the value-object idea from DDD in its most useful form: make invalid states unrepresentable, then stop re-validating everywhere else.

Integer cents, not big.Rat or a decimal library. int64 cents gives exact addition and comparison for free, is trivially indexable and sortable in the DB, and covers roughly ±92 quadrillion dollars. If you need arbitrary precision (FX rates, interest calc), reach for a decimal type — but for prices and balances, minor units are the boring answer that works.

Currency is part of equality. Money is a comparable struct, so == compares amount and currency. NewMoney(1000, EUR) != NewMoney(1000, USD). The euros-vs-cents-vs-dollars class of bug now fails at the type level instead of on an accountant's spreadsheet.

A whitelist of currencies. Two entries look restrictive, and that's the point. The domain says which currencies the business actually supports. Adding one is a one-line change and forces you to think about it — which is exactly what you want when the alternative is silently accepting "BTC" or "EURO" from a client.

Using it in the domain

The Product entity takes a Money, not a number:

type Product struct {
    Id        uuid.UUID
    CreatedAt time.Time
    UpdatedAt time.Time
    Name      string
    Price     Money
    SellerId  uuid.UUID
}

func (p *Product) validate() error {
    if p.Name == "" {
        return fmt.Errorf("%w: name must not be empty", ErrValidation)
    }
    if p.Price.Cents() == 0 {
        return fmt.Errorf("%w: price must be greater than 0", ErrValidation)
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Notice what the entity does not have to check: negative prices, garbage currencies. NewMoney already refused those. The entity only adds the rule that belongs to products — a price of zero makes no sense for a product, but zero is a perfectly fine Money elsewhere (a discount that bottomed out, an empty balance). Each layer validates its own invariant, once.

Surviving the round trips: DB and JSON

A value object is only as good as its edges. Money enters and leaves your process constantly — database, API, message queue — and every crossing is a chance to smuggle in an invalid value.

Database. The schema mirrors the value object: a BIGINT for cents and a TEXT for the currency. Here's the actual migration from the repo, moving off the old DECIMAL column:

-- Store money as integer minor units plus an ISO 4217 currency code.
-- Floating point (and implicit currency) is how money bugs are born.
ALTER TABLE products ADD COLUMN price_cents BIGINT;
UPDATE products SET price_cents = ROUND(price * 100);
ALTER TABLE products ALTER COLUMN price_cents SET NOT NULL;
ALTER TABLE products DROP COLUMN price;

ALTER TABLE products ADD COLUMN currency TEXT;
UPDATE products SET currency = 'USD';
ALTER TABLE products ALTER COLUMN currency SET NOT NULL;
Enter fullscreen mode Exit fullscreen mode

The UPDATE ... SET currency = 'USD' line is the honest part of this migration: the old schema had no idea what currency those decimals were in. We were only able to backfill because the system happened to be single-currency. If it hadn't been, this migration would have been a data archaeology project. Attach the currency before you need to.

JSON. This is the part most people skip. encoding/json bypasses your constructor — it writes straight into struct fields via reflection. With unexported fields it can't, so we implement the interfaces ourselves and route unmarshaling back through NewMoney:

type moneyJSON struct {
    Cents    int64    `json:"cents"`
    Currency Currency `json:"currency"`
}

func (m Money) MarshalJSON() ([]byte, error) {
    return json.Marshal(moneyJSON{Cents: m.cents, Currency: m.currency})
}

// UnmarshalJSON goes through NewMoney so a Money can never be deserialized
// into an invalid state.
func (m *Money) UnmarshalJSON(data []byte) error {
    var raw moneyJSON
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }

    money, err := NewMoney(raw.Cents, raw.Currency)
    if err != nil {
        return err
    }

    *m = money
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Why bother, when the value was valid when we serialized it? Because JSON doesn't only come from us. It comes from an outbox table written by last year's code, a queue message from another service, a fixture someone hand-edited. Revalidating on the way in costs two comparisons and closes the whole category.

The API contract

At the REST edge I don't expose the domain type — DTOs carry the same shape explicitly:

type CreateProductRequest struct {
    IdempotencyKey string `json:"idempotency_key"`
    Name           string `json:"name"`
    PriceCents     int64  `json:"price_cents"`
    Currency       string `json:"currency"`
    SellerId       string `json:"seller_id"`
}
Enter fullscreen mode Exit fullscreen mode

The field is named price_cents, not price. That naming does real work: no client developer will ever wonder whether to send 19.99 or 1999. The response mirrors it (PriceCents int64, Currency string), and the app layer converts the raw DTO values through NewMoney, so a request with "currency": "GBP" or a negative amount gets a 400 before it ever touches an entity.

Yes, that means the number 1999 on the wire instead of a pretty "19.99". Formatting is a display concern; let the frontend format for locale. Wire formats should be exact.

What this doesn't solve

I'd rather tell you the sharp edges myself:

  • Display formatting is on you. My String() does cents/100 with a two-digit remainder, which is correct for EUR and USD and wrong for JPY (0 decimal places) or KWD (3). If you go properly multi-currency, you need per-currency exponents — steal them from ISO 4217 or use a library.
  • Cross-currency arithmetic needs a decision. The moment you add Add() or Sub(), define what EUR + USD does. My answer: it returns an error, and conversion is an explicit domain operation with a rate and a timestamp. Never implicit.
  • Division and percentages still round. Integer cents make addition exact, but 100 cents split three ways is still 33+33+34. You need an allocation strategy (largest-remainder works fine) — no representation saves you from that.
  • DECIMAL in the DB is fine, actually — if you read it into a decimal type, not a float. If your reporting team lives in SQL and wants SUM(price) to look like money, NUMERIC(12,2) plus a currency column is a legitimate choice. What's not legitimate is DECIMAL in Postgres scanned into float64 in Go, which is the worst of both worlds and, in my experience, the most common setup out there.

The pattern's core is small enough to remember: integer minor units, currency attached, one constructor, and every deserialization path goes back through it. Everything else — the migration, the DTO naming, the entity validation — falls out of that.

The full implementation lives in my DDD template at https://github.com/sklinkert/go-ddd — alongside the rest of the patterns I keep rebuilding: validated entities, a transactional outbox for domain events, race-safe idempotency keys, and testcontainers-based integration tests.

Source: dev.to

arrow_back Back to Tutorials