Currency Conversion Rounding: How to Convert Money Without Floating-Point Errors
5 min read
If you convert currencies with ordinary floating-point numbers, you will eventually get a total that is off by one cent. The fix is short: keep rates and amounts as exact decimals, multiply without rounding, and apply currency conversion rounding exactly once, at the moment you display or invoice the value.
This article explains why floats fail, which rounding rule to pick, how many decimals each currency needs, and how to put it together in JavaScript and Python. The same rules apply in any language.
Why floats break currency conversion
Binary floating point cannot represent most decimal fractions exactly. The classic demonstration works in JavaScript, Python and most other languages:
0.1 + 0.2; // 0.30000000000000004
1.005 * 100; // 100.49999999999999
(1.005).toFixed(2); // "1.00", not "1.01"
Each error is tiny, but money code does two things that magnify them: it multiplies by rates with four to six decimals, and it rounds the result. A value that should sit exactly on a rounding boundary lands just below it, and you lose a cent. Across thousands of invoices, your ledger and your customer's calculator disagree.
Floats also lose precision on large or very small values. Crypto amounts with 8 to 18 decimals, or a total in a currency with big nominal values, can exceed what a 64-bit float holds exactly.
The rule is simple: never let a float touch money. That includes the moment you parse JSON. If an API returns "rate": 1.0842 as a JSON number, your parser may already have converted it to a float. A rate returned as a string avoids that trap.
Keep decimals exact from API to database
Exactness has to survive every hop: API response, parser, calculation, storage.
- Transport: receive rates and amounts as strings, not JSON numbers.
- Calculation: use a decimal type (
Decimalin Python,BigDecimalin Java,decimalin C#,bcmathor a decimal library in PHP) or integers scaled by a power of ten. - Storage: use
NUMERICorDECIMALcolumns, neverFLOATorDOUBLE. Store the rate you used alongside the converted amount so the conversion can be audited later.
GetRatesAPI returns every rate and amount as a JSON string and does not round results. A response looks like this:
{"base":"EUR","quote":"USD","rate":"1.0842","as_of":"2026-01-02T10:00:00Z","status":"fresh","type":"direct"}
The rate value is exactly what the API computed. Rounding is your decision, because the right rounding depends on whether you are showing a price, charging a card or booking an invoice line.
Pick a rounding rule and a precision
Rounding mode
Two modes cover nearly everything:
| Mode | Behavior | Typical use |
|---|---|---|
| Half up | 2.5 becomes 3, 136.065 becomes 136.07 | Prices, invoices, most consumer-facing totals |
| Half even (banker's rounding) | 2.5 becomes 2, 3.5 becomes 4 | Accounting systems that sum many rounded values and want to avoid upward drift |
Neither is universally correct. Check your accounting policy or local invoicing rules, pick one, and apply it everywhere. Many language defaults surprise people: Python's built-in round() uses half even, while JavaScript's Math.round rounds halves toward positive infinity. State the mode explicitly in code instead of relying on defaults.
Decimal places per currency
Not every currency has two decimals. The ISO 4217 standard defines minor units per currency:
| Currency | Minor unit digits | Example |
|---|---|---|
| USD, EUR, GBP | 2 | 136.07 |
| JPY, KRW | 0 | 20,415 |
| KWD, BHD | 3 | 41.352 |
Hard-coding two decimals will overcharge or undercharge in zero- and three-decimal currencies. Keep a small lookup table keyed by currency code, with a default of 2. For crypto, choose a display precision that suits the asset and keep full precision internally.
Round once, at the end
The most common source of drift is rounding at every step. Follow these habits:
- Multiply first, round last. Compute
amount × ratewith full precision, then round the result a single time. - Do not chain conversions. Converting USD to EUR and then EUR to GBP, rounding in between, gives a different answer than USD to GBP directly. Ask for the pair you need.
- Do not invert rates yourself. Computing
1 / rateproduces a repeating decimal that you must truncate somewhere. Request the reverse pair instead. GetRatesAPI works for any pair, deriving it through USD, EUR or BTC when no market quotes it directly, and each response says whether it wasdirectorderived. - Decide line-item versus total rounding up front. Rounding each line then summing can differ from summing then rounding. Either is acceptable if it is a documented policy, and the customer-facing invoice must add up.
Example: Python with Decimal
Python's decimal module does this cleanly. This script calls the convert endpoint, reads the exact string, and quantizes it to two places:
import os
from decimal import Decimal, ROUND_HALF_UP
import requests
resp = requests.get(
"https://api.getratesapi.com/v1/convert",
params={"from": "USD", "to": "EUR", "amount": "125.50"},
headers={"Authorization": f"ApiKey {os.environ['GETRATESAPI_KEY']}"},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
# Parse the string directly. Never pass it through float().
exact = Decimal(data["converted_amount"])
display = exact.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(exact, "->", display)
If you already hold a rate and want to calculate locally, Decimal("125.50") * Decimal("1.0842") gives exactly 136.067100, which rounds half up to 136.07. Note the amount is passed as a string. Decimal(125.50) starts from a float and defeats the purpose, and with a value like Decimal(1.1) you would inherit the binary error.
Example: JavaScript without floats
JavaScript has no built-in decimal type, but BigInt works for exact arithmetic. This sketch handles positive values only and scales both numbers to integers:
function parseDecimal(str) {
const [whole, frac = ""] = str.split(".");
return { int: BigInt(whole + frac), scale: frac.length };
}
function multiplyAndRound(amountStr, rateStr, places) {
const a = parseDecimal(amountStr);
const r = parseDecimal(rateStr);
const product = a.int * r.int;
const scale = a.scale + r.scale;
let rounded;
if (scale <= places) {
rounded = product * 10n ** BigInt(places - scale);
} else {
const divisor = 10n ** BigInt(scale - places);
const quotient = product / divisor;
const remainder = product % divisor;
rounded = remainder * 2n >= divisor ? quotient + 1n : quotient; // half up
}
const digits = rounded.toString().padStart(places + 1, "0");
return places === 0
? digits
: `${digits.slice(0, -places)}.${digits.slice(-places)}`;
}
console.log(multiplyAndRound("125.50", "1.0842", 2)); // "136.07"
For production code with negative numbers and multiple rounding modes, use a well-tested decimal library rather than extending this sketch. The principle stays the same: the input strings never become floats.
Where GetRatesAPI helps
Because the API returns strings and leaves results unrounded, the pipeline above has no hidden precision loss. The convert endpoint returns converted_amount for an exact conversion, and the current rate endpoint returns the rate if you prefer to calculate locally. Both cost one request unit.
A few practical notes:
- Store the
rate,as_ofandtypefields next to every converted amount you persist. They explain the number later. - Check
statusbefore charging. Astalerate is flagged, not withheld, so your code decides what to do. For caching strategies that keep this safe, see how to cache exchange rates without showing stale prices. - Validate input amounts before sending them. A malformed amount returns an
invalid_amounterror (HTTP 400), and malformed requests are not billed. Match on thecodefield, not the message text.
Checklist before you ship
- No floats anywhere between the API response and the database.
- Rates and amounts parsed from strings.
- A documented rounding mode, set explicitly in code.
- Minor-unit digits looked up per currency.
- One rounding step, at the end.
- Rate, timestamp and status saved with each conversion.
Try it with exact decimals
You can test the conversion examples above on the free plan with no credit card: create an account, copy a key, and use the dashboard playground to call the convert endpoint. If you need more volume later, see the pricing page for plan limits.