Currency rounding looks trivial until an invoice is off by a cent, a JPY price shows decimals, or totals don’t match line items. This guide covers the rules that keep currency conversion precision intact: why floating point fails, how to represent money, how many decimals each currency uses, which rounding mode to pick, why you convert first and round last, and how to compute cross rates through a base currency. Examples use Python’s Decimal and JavaScript integers with Intl.NumberFormat.
Floating point pitfalls
Binary floating point (float in Python, number in JavaScript, double in Java/C#) can’t represent most decimal fractions exactly:
>>> 0.1 + 0.2
0.30000000000000004
>>> round(2.675, 2) # expected 2.68
2.67
1.005 * 100 // 100.49999999999999
Math.round(1.005 * 100) / 100 // 1, not 1.01
Each error is tiny, but money is summed, compared and reconciled. A total that is off by 0.000000001 fails an equality check; a value that should round up rounds down. The fix is to keep amounts out of binary floats entirely.
Represent money as decimals or minor units
Two safe options:
| Approach | How | Good for |
|---|---|---|
| Decimal type | Decimal (Python), BigDecimal (Java), decimal (C#), rust_decimal | Calculations with many steps, rates, accounting |
| Integer minor units | Store 1999 for 19.99 EUR, 1500 for 1,500 JPY | Databases, payment APIs, JavaScript |
Exchange rates themselves have many significant digits (an illustrative 0.8890791312). Keep the rate at full precision, convert, and only then round to minor units.
Decimal places per currency: decimal_digits and rounding
Not every currency has two decimals. ISO 4217 defines a minor unit for each code – see the ISO 4217 standard and our ISO 4217 explainer:
Don’t hard-code this. The /v1/currencies endpoint returns metadata per code, including decimal_digits and rounding:
curl -G "https://api.fxapi.com/v1/currencies" -d currencies=JPY,KWD -H "apikey: $FXAPI_KEY"
{
"data": {
"JPY": { "code": "JPY", "name": "Japanese Yen", "symbol": "¥", "decimal_digits": 0, "rounding": 0, "type": "fiat" },
"KWD": { "code": "KWD", "name": "Kuwaiti Dinar", "symbol": "KD", "decimal_digits": 3, "rounding": 0, "type": "fiat" }
}
}
The response is shortened and illustrative; it also includes symbol_native, name_plural and countries. Use decimal_digits for the number of places. rounding is a rounding increment for currencies that use one (for example cash amounts rounded to 0.05); 0 means no special increment. Currency metadata rarely changes, so cache it for a day or longer.
Currency rounding modes: half-up vs. banker’s rounding
| Value | Half-up (2 dp) | Half-even / banker’s (2 dp) |
|---|---|---|
| 2.345 | 2.35 | 2.34 |
| 2.355 | 2.36 | 2.36 |
| 0.125 | 0.13 | 0.12 |
| −2.345 | −2.35 | −2.34 |
- Half-up (away from zero on .5) is what most people learn at school and what many invoicing rules and tax calculations specify.
- Half-even (banker’s rounding) sends ties to the even neighbor, so over thousands of conversions the rounding errors cancel out instead of drifting upward. It’s the default in IEEE 754 and in Python’s
round().
Pick one deliberately, document it, and apply it everywhere – your accounting or legal requirements may prescribe a mode.
Convert first, round last
Round once, on the final amount in the target currency:
Amount: 1,000,000.00 USD Rate: 0.8890791312 (illustrative)
Correct: 1,000,000.00 × 0.8890791312 = 889,079.1312 → 889,079.13 EUR
Wrong: 1,000,000.00 × 0.8891 (rate rounded to 4 dp) = 889,100.00 EUR (20.87 EUR off)
The same applies to chains of conversions and to line items: convert each line at full precision, round each line to minor units, then sum the rounded lines so the invoice total equals the sum of its lines. If a total must be split (for example into installments), allocate the leftover minor units explicitly instead of rounding each part independently.
Cross rates via a base currency
fxapi returns every currency relative to one base_currency. Any pair can be derived from a single response:
rate(A → B) = rate(base → B) / rate(base → A)
inverse: rate(B → A) = 1 / rate(A → B)
Do the division in decimal arithmetic and don’t round the intermediate rate. That way one cached /v1/latest call serves every pair – see base currency and cross rates and caching exchange rates.
Python: precise conversion with Decimal
import json
import os
from decimal import Decimal, ROUND_HALF_EVEN
import requests
HEADERS = {"apikey": os.environ["FXAPI_KEY"]}
def fx_get(path, **params):
resp = requests.get(f"https://api.fxapi.com/v1{path}", params=params, headers=HEADERS, timeout=10)
resp.raise_for_status()
return json.loads(resp.text, parse_float=Decimal) # numbers become Decimal, never float
CURRENCIES = fx_get("/currencies")["data"] # cache for a day
RATES = fx_get("/latest", base_currency="USD")["data"] # cache per plan update interval
def quantum(code: str) -> Decimal:
return Decimal(1).scaleb(-int(CURRENCIES[code]["decimal_digits"])) # 2 -> 0.01, 0 -> 1, 3 -> 0.001
def cross_rate(src: str, dst: str) -> Decimal:
return RATES[dst]["value"] / RATES[src]["value"] # full precision, no rounding
def convert(amount: Decimal, src: str, dst: str, rounding=ROUND_HALF_EVEN) -> Decimal:
return (amount * cross_rate(src, dst)).quantize(quantum(dst), rounding=rounding)
print(convert(Decimal("1234.56"), "EUR", "JPY")) # whole yen
print(convert(Decimal("1234.56"), "EUR", "KWD")) # three decimals
parse_float=Decimal keeps the exact digits from the JSON. Pass rounding=ROUND_HALF_UP where your rules require half-up.
JavaScript: integer minor units and Intl.NumberFormat
JSON.parse turns rates into floats. Node.js 21+ and current browsers let a reviver read the original source text, so you can keep the exact digits and convert with BigInt:
// Keep each rate as its exact decimal string
const res = await fetch("https://api.fxapi.com/v1/latest?base_currency=USD", {
headers: { apikey: process.env.FXAPI_KEY },
});
const rates = JSON.parse(await res.text(), (key, value, ctx) =>
key === "value" && typeof value === "number" ? ctx.source : value,
).data;
// "0.8890791312" or "1e-7" -> { digits: 8890791312n, scale: 10 }
function toScaled(str) {
const [mantissa, exp = "0"] = str.toLowerCase().split("e");
const [int, frac = ""] = mantissa.split(".");
let digits = BigInt(int + frac);
let scale = frac.length - Number(exp);
if (scale < 0) { digits *= 10n ** BigInt(-scale); scale = 0; }
return { digits, scale };
}
// Convert integer minor units with one half-even rounding step (positive amounts)
function convertMinor(amountMinor, src, dst, fromDigits, toDigits) {
const a = toScaled(rates[src].value); // base -> src
const b = toScaled(rates[dst].value); // base -> dst
// amount × (b / a), rescaled from src minor units to dst minor units
const num = amountMinor * b.digits * 10n ** BigInt(a.scale + toDigits);
const den = a.digits * 10n ** BigInt(b.scale + fromDigits);
let q = num / den;
const r = num % den;
if (2n * r > den || (2n * r === den && q % 2n === 1n)) q += 1n;
return q;
}
const yen = convertMinor(123456n, "EUR", "JPY", 2, 0); // 1,234.56 EUR -> whole yen
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "JPY" });
console.log(fmt.format(Number(yen)));
For display only, Intl.NumberFormat is enough: it applies the usual decimals per currency (resolvedOptions().maximumFractionDigits is 0 for JPY and 3 for KWD) and the locale’s separators. It uses CLDR data, which can differ from accounting conventions for a few currencies, so for stored amounts rely on decimal_digits from the API. If you’d rather not hand-roll BigInt math, a decimal library such as decimal.js or big.js does the same job.
Checklist
- No binary floats for amounts – decimals or integer minor units.
- Keep rates at full precision; never round the rate.
- Convert first, round once, to the target currency’s
decimal_digits. - Choose half-up or half-even deliberately and apply it consistently.
- Derive cross rates from one base currency response.
- Store the rate and
meta.last_updated_atwith each transaction so conversions are reproducible.
Ready to try it? Get a free API key or see the currency converter API for server-side conversion.
The examples in this guide use fxapi, a foreign exchange rates API with a free plan of 300 requests a month – no credit card required.
Frequently asked questions
Should I round the exchange rate or the converted amount?
How many decimal places does a currency have?
/v1/currencies returns this as decimal_digits.What is banker's rounding?
Can I use JavaScript numbers for money?
Intl.NumberFormat. For stored amounts, invoices and ledgers, use integer minor units (BigInt or safe integers) or a decimal library, and round explicitly.