worldwide (ISO 4217)
for FX rate storage
tolerance threshold
Why Floating Point Will Betray You
Let's start with the thing that bites every engineer who's new to financial systems. Open any programming language and try this:
// Go
fmt.Println(0.1 + 0.2)
// Output: 0.30000000000000004
That's IEEE 754 double-precision floating point doing exactly what it's designed to do. For scientific computing, that's fine. For money, it's a disaster. That tiny error compounds across thousands of transactions. I've seen ledgers drift by hundreds of dollars over a month because someone used float64 for balances.
The fix is straightforward: never use floating point for money. Use integers representing the smallest currency unit, or use a decimal library that gives you exact arithmetic.
// Go — representing money as smallest currency unit (integer cents)
type Money struct {
Amount int64 // in smallest unit: cents, yen, fils
Currency string // ISO 4217 code: "USD", "JPY", "BHD"
}
// $19.99 becomes:
usd := Money{Amount: 1999, Currency: "USD"}
// 1000 yen (no decimals):
jpy := Money{Amount: 1000, Currency: "JPY"}
// 1.234 Bahraini dinar (3 decimal places):
bhd := Money{Amount: 1234, Currency: "BHD"}
func (m Money) Add(other Money) (Money, error) {
if m.Currency != other.Currency {
return Money{}, fmt.Errorf("cannot add %s to %s", m.Currency, other.Currency)
}
return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}
Rule of thumb: If you ever see float64 or double anywhere near a money value in your codebase, treat it as a bug. No exceptions. Even "temporary" float conversions for display can introduce rounding errors that propagate back into your data.
Not All Currencies Have Two Decimal Places
This is the second thing that catches people off guard. Most developers assume every currency works like USD — two decimal places, divide by 100, done. Reality is messier.
Your system needs a lookup table mapping ISO 4217 currency codes to their exponent (number of decimal places). Hard-coding / 100 everywhere is a ticking time bomb. When you expand to Japan or the Middle East, every calculation breaks.
// Go — currency exponent lookup
var currencyExponents = map[string]int{
"USD": 2, "EUR": 2, "GBP": 2,
"JPY": 0, "KRW": 0, "VND": 0,
"BHD": 3, "KWD": 3, "OMR": 3,
"CLF": 4,
}
func ToMinorUnits(amount string, currency string) (int64, error) {
exp, ok := currencyExponents[currency]
if !ok {
return 0, fmt.Errorf("unknown currency: %s", currency)
}
d, err := decimal.NewFromString(amount)
if err != nil {
return 0, err
}
return d.Shift(int32(exp)).IntPart(), nil
}
FX Rate Storage and Precision
When you're converting between currencies, the exchange rate itself needs careful handling. Most FX providers give you rates with 4-6 decimal places, but interbank rates can go deeper. I store rates with at least 6 decimal places, sometimes 8 for exotic pairs.
In your database, use DECIMAL(18,8) or similar. Never store FX rates as floats. And always store the rate pair explicitly — "1 USD = 0.921483 EUR" — along with the timestamp and source.
-- SQL: FX rate storage
CREATE TABLE fx_rates (
id BIGSERIAL PRIMARY KEY,
base_currency CHAR(3) NOT NULL, -- e.g., 'USD'
quote_currency CHAR(3) NOT NULL, -- e.g., 'EUR'
rate DECIMAL(18,8) NOT NULL, -- e.g., 0.92148300
rate_source VARCHAR(50) NOT NULL, -- e.g., 'reuters', 'ecb'
captured_at TIMESTAMPTZ NOT NULL, -- when we fetched it
effective_at TIMESTAMPTZ NOT NULL, -- when it applies
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_fx_rates_lookup
ON fx_rates (base_currency, quote_currency, effective_at DESC);
Trade Date vs. Settlement Date
This is where it gets tricky. When do you lock in the exchange rate? There are two schools of thought:
- Trade date accounting — you record the conversion at the rate on the day the transaction happens. This is what most payment platforms do for customer-facing transactions.
- Settlement date accounting — you record it at the rate when money actually moves. This matters for treasury operations and large B2B transfers where settlement can be T+2 or longer.
The gap between these two dates creates FX exposure. If you quoted a customer EUR 92.15 for their $100 transfer on Monday, but settlement happens Wednesday and the rate has moved, someone eats that difference. Your system needs to track which rate was quoted, which rate was used at settlement, and the delta between them.
with markup
original currency
per target currency
target currency
diff to FX account
Multi-Currency Ledger Design
A single-currency ledger is straightforward — debits and credits in one denomination, everything balances. Multi-currency ledgers are fundamentally harder because you need to track amounts in both the original currency and a reporting currency, and those two views won't always agree due to rate fluctuations.
The pattern I've found most reliable is storing every ledger entry in its native currency, with a separate column for the reporting-currency equivalent at the time of booking. Each FX conversion creates entries in both currency ledgers, linked by a transaction ID.
-- SQL: Multi-currency double-entry ledger
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL,
account_id BIGINT NOT NULL,
entry_type VARCHAR(6) NOT NULL CHECK (entry_type IN ('debit', 'credit')),
currency CHAR(3) NOT NULL,
amount BIGINT NOT NULL, -- in minor units
reporting_ccy CHAR(3) DEFAULT 'USD',
reporting_amount BIGINT, -- equivalent in reporting currency
fx_rate_id BIGINT REFERENCES fx_rates(id),
description TEXT,
booked_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- For an FX conversion of $100 USD -> EUR at 0.9215:
-- Entry 1: debit sender_usd_account, USD, 10000
-- Entry 2: credit fx_holding_account, USD, 10000
-- Entry 3: debit fx_holding_account, EUR, 9215
-- Entry 4: credit receiver_eur_account, EUR, 9215
-- Rounding difference (if any) goes to an FX rounding account
Key insight: Your ledger should always balance within each currency. USD debits equal USD credits. EUR debits equal EUR credits. The cross-currency "balance" only makes sense when you pick a reporting currency and apply rates — and that's a reporting concern, not a ledger integrity concern.
Rounding — The Silent Account Killer
When you multiply 100 USD by a rate of 0.921483, you get 92.1483 EUR. But EUR only has 2 decimal places. Do you round to 92.15 or truncate to 92.14? That one-cent difference, multiplied across millions of transactions, is real money.
There's no single right answer — it depends on your jurisdiction and business rules. But here's what I've seen work:
- Banker's rounding (round half to even) — this is the default in most financial systems. 92.145 rounds to 92.14, but 92.155 rounds to 92.16. It eliminates systematic bias over large volumes.
- Always round in the platform's favor — some payment companies do this. Simpler, but regulators in some jurisdictions frown on it.
- Book the difference — whatever rounding method you use, capture the sub-cent difference in a dedicated FX rounding account. This makes reconciliation possible and keeps auditors happy.
For zero-decimal currencies like JPY, the rounding impact is much larger. Converting $9.99 to yen at 149.32 gives you 1491.7068 — you're rounding away nearly a full yen. Your tolerance thresholds need to be currency-aware.
Realized vs. Unrealized FX Gains and Losses
If your platform holds balances in multiple currencies, you have FX exposure. The value of those balances in your reporting currency changes every time rates move. Accounting standards (IFRS, GAAP) require you to track this.
- Unrealized gains/losses — you're holding EUR and the EUR/USD rate moves. On paper, your USD-equivalent balance changed. You haven't sold the EUR, so the gain or loss is unrealized. You still need to report it, typically at month-end.
- Realized gains/losses — you actually convert the EUR to USD. The difference between the rate you booked the EUR at and the rate you sold it at is a realized gain or loss. This hits your P&L.
In practice, I run a nightly job that revalues all non-reporting-currency balances at the day's closing rate and books the difference to an unrealized FX gain/loss account. When actual conversions happen, the unrealized portion gets reclassified as realized. It's not glamorous work, but skip it and your financial statements will be wrong.
Regulatory Reporting Considerations
Different jurisdictions have different rules about how you report multi-currency transactions. A few things to keep in mind:
- Central bank reporting — many countries require you to report cross-border transactions above certain thresholds. You need to store enough metadata (source country, purpose code, beneficiary details) to generate these reports.
- Tax implications — FX gains and losses are taxable events in most jurisdictions. Your system needs to produce reports that your tax team can actually use.
- Anti-money laundering — currency conversion is a common vector for money laundering. Your transaction monitoring system needs to flag unusual patterns in FX activity, not just in individual currencies.
Lessons from Production
- Test with real currency pairs. Your unit tests probably use USD/EUR. Add JPY (0 decimals), BHD (3 decimals), and CLF (4 decimals) to your test suite. Edge cases in rounding only show up with non-standard exponents.
- Never derive — always store. Store the exact rate used for every conversion. Don't try to recalculate it later from the amounts. Rates change by the second, and you'll never reproduce the exact number.
- Reconcile daily. Sum all debits and credits per currency per day. If they don't balance, something is wrong. The earlier you catch it, the easier it is to fix.
- Plan for rate source failures. Your FX rate provider will go down. Have a fallback provider, and have a policy for how stale a rate can be before you stop accepting conversions.
- Separate display from storage. Store amounts in minor units as integers. Only format for display at the very last moment, using locale-aware formatting. Never round for display and then use that rounded value in a calculation.
References
- ISO 4217 — Currency Codes (Official ISO page)
- IEEE 754 — Standard for Floating-Point Arithmetic
- SIX Group — ISO 4217 Currency Code Maintenance
- shopspring/decimal — Arbitrary-precision decimal library for Go
- IAS 21 — The Effects of Changes in Foreign Exchange Rates (IFRS)
- Martin Fowler — Money Pattern
Disclaimer: This article reflects the author's personal experience and opinions. It is not financial or legal advice. Currency regulations vary by jurisdiction — always consult with compliance and legal teams for your specific situation. Product names and standards referenced are property of their respective owners.