Skip to content
Best practice

Currency rounding and conversion precision

Floats, rounding modes and decimal places cause most currency conversion bugs. Here’s how to convert with full precision, round once to the right number of minor units, and compute cross rates – in Python and JavaScript.

Last updated: · fxapi team

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:

ApproachHowGood for
Decimal typeDecimal (Python), BigDecimal (Java), decimal (C#), rust_decimalCalculations with many steps, rates, accounting
Integer minor unitsStore 1999 for 19.99 EUR, 1500 for 1,500 JPYDatabases, 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:

CurrencyMinor unit1,234.5678 rounded
USD, EUR, GBP21,234.57
JPY, KRW01,235
KWD, BHD31,234.568

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

ValueHalf-up (2 dp)Half-even / banker’s (2 dp)
2.3452.352.34
2.3552.362.36
0.1250.130.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

  1. No binary floats for amounts – decimals or integer minor units.
  2. Keep rates at full precision; never round the rate.
  3. Convert first, round once, to the target currency’s decimal_digits.
  4. Choose half-up or half-even deliberately and apply it consistently.
  5. Derive cross rates from one base currency response.
  6. Store the rate and meta.last_updated_at with 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?
Round the converted amount, once, at the end. Rounding the rate first (for example to 4 decimals) introduces an error that grows with the amount.
How many decimal places does a currency have?
It depends on the currency’s ISO 4217 minor unit: 2 for most (USD, EUR), 0 for JPY and KRW, 3 for KWD and BHD. fxapi’s /v1/currencies returns this as decimal_digits.
What is banker's rounding?
Round half to even: a value exactly halfway rounds to the nearest even digit, so 0.125 becomes 0.12 and 0.135 becomes 0.14. It avoids the upward bias of half-up rounding across many transactions.
Can I use JavaScript numbers for money?
For display, yes – with Intl.NumberFormat. For stored amounts, invoices and ledgers, use integer minor units (BigInt or safe integers) or a decimal library, and round explicitly.
How do I calculate a cross rate between two non-USD currencies?
Divide the two rates against a shared base: rate(EUR→GBP) = rate(USD→GBP) / rate(USD→EUR). Do the division with full decimal precision and round only the final amount.
Free plan · no credit card

Get your free exchange rate API key

300 requests a month, latest and historical rates, fluctuation and averages – free forever. Upgrade when you need faster updates or more requests.