StackPractices
intermediate By Mathias Paulenko

Money and Currency Handling

How to represent, parse, format, and calculate monetary values accurately across currencies.

Topics: data

Overview

Financial calculations demand precision. Floating-point numbers (float, double) can’t accurately represent most decimal fractions, so rounding errors compound in billing, invoicing, and trading systems. Below you’ll represent money as exact decimal values, format it with locale-aware symbols, do arithmetic without losing precision, and convert between currencies — in Python, JavaScript, and Java.

When to Use

Pull this up when you’re:

  • Building e-commerce carts, checkout flows, or invoicing systems. See Input Validation for sanitizing order data.
  • Aggregating financial transactions where penny-level accuracy matters
  • Displaying prices in USD, EUR, or JPY with correct rounding rules
  • Converting between currencies using external exchange rate APIs

Solution

Python

from decimal import Decimal, ROUND_HALF_UP, getcontext

# Set precision for all Decimal operations
getcontext().prec = 28

class Money:
    def __init__(self, amount: str | Decimal, currency: str = "USD"):
        self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        self.currency = currency.upper()

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies without conversion")
        return Money(self.amount + other.amount, self.currency)

    def __mul__(self, factor: Decimal) -> "Money":
        return Money(self.amount * Decimal(str(factor)), self.currency)

    def format(self) -> str:
        symbol = {"USD": "$", "EUR": "€", "GBP": "£"}.get(self.currency, self.currency)
        return f"{symbol}{self.amount:,}"

    @staticmethod
    def convert(amount: "Money", rate: Decimal, target_currency: str) -> "Money":
        converted = amount.amount * Decimal(str(rate))
        return Money(converted, target_currency)

# Usage
price = Money("19.99")
tax = Money("1.70")
total = price + tax
converted = Money.convert(total, Decimal("0.85"), "EUR")
print(total.format())      # $21.69
print(converted.format())  # €18.44

JavaScript

Dinero.js v2 exposes a functional API — amounts live in minor units (cents), so 1999 means $19.99:

import { dinero, add, toFormat, convert } from "dinero.js";
import { USD, EUR } from "@dinero.js/currencies";

const price = dinero({ amount: 1999, currency: USD });
const tax = dinero({ amount: 170, currency: USD });
const total = add(price, tax);

// Conversion with an explicit rate: 1 USD = 0.8500 EUR
const converted = convert(total, EUR, {
  EUR: { amount: 8500, scale: 4 },
});

const print = (d) =>
  toFormat(d, ({ amount, currency }) => `${currency.code} ${amount.toFixed(2)}`);

console.log(print(total));      // USD 21.69
console.log(print(converted));  // EUR 18.44

Java

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.Currency;
import java.util.Locale;

public class Money {
  private final BigDecimal amount;
  private final Currency currency;

  public Money(BigDecimal amount, String currencyCode) {
    this.amount = amount.setScale(2, RoundingMode.HALF_UP);
    this.currency = Currency.getInstance(currencyCode);
  }

  public Money add(Money other) {
    if (!this.currency.equals(other.currency)) {
      throw new IllegalArgumentException("Currency mismatch");
    }
    return new Money(this.amount.add(other.amount), currency.getCurrencyCode());
  }

  public Money multiply(BigDecimal factor) {
    return new Money(this.amount.multiply(factor), currency.getCurrencyCode());
  }

  public String format(Locale locale) {
    NumberFormat formatter = NumberFormat.getCurrencyInstance(locale);
    formatter.setCurrency(currency);
    return formatter.format(amount);
  }

  public static Money convert(Money source, BigDecimal rate, String targetCurrency) {
    BigDecimal converted = source.amount.multiply(rate);
    return new Money(converted, targetCurrency);
  }

  // Usage
  public static void main(String[] args) {
    Money price = new Money(new BigDecimal("19.99"), "USD");
    Money tax = new Money(new BigDecimal("1.70"), "USD");
    Money total = price.add(tax);
    Money eur = Money.convert(total, new BigDecimal("0.85"), "EUR");

    System.out.println(total.format(Locale.US));      // $21.69
    System.out.println(eur.format(Locale.GERMANY));   // 18,44 €
  }
}

Explanation

  • Never use float or double for money — binary floating-point can’t represent values like 0.1 exactly, causing 0.1 + 0.2 != 0.3 errors that accumulate in financial systems.
  • Use integer minor units (cents) or arbitrary-precision decimals (Decimal, BigDecimal, Dinero.js) to keep exact values throughout calculations.
  • Rounding must happen explicitly at defined boundaries (per-line item, per-tax, and per-total), never implicitly through floating-point error.
  • Currency conversion should use a fetched exchange rate, apply it with exact arithmetic, then round to the target currency’s minor units.
  • Locale formatting separates display logic from storage: store raw numeric values and format with NumberFormat or ICU libraries for symbols, separators, and placement.
  • Splitting money needs an allocation strategy — dividing $100 three ways yields $33.33/$33.33/$33.34, not three equal parts. Dinero.js allocate and Java’s divideAndRemainder distribute the leftover pennies deterministically; naive division loses them.

Every money operation follows the same pipeline — parse, validate, compute, convert, round, format:

Mermaid flowchart LR diagram

Working with Exchange Rates

Rates change constantly, so the conversion code matters less than the policy around it:

  • Fetch the rate once per transaction — pull it from your exchange rate provider and reuse that exact rate for every line in the order, never re-fetch mid-calculation.
  • Store the rate you used — keep rate, rate_timestamp, and target_amount on the transaction record. Auditors and support tickets will ask “why €18.44?” months later.
  • Cache aggressively, with a TTL — most providers update daily; a cached rate with a few hours of TTL is fine for storefronts but not for settlement.
  • Round only after converting — 21.69 × 0.85 = 18.4365 becomes 18.44 at the end, never rounded at intermediate steps.

Variants

ApproachLibrary / TypeBest For
Integer centslong / bigintHigh-frequency paths, smallest memory footprint
Arbitrary precisionDecimal (Python), BigDecimal (Java)General-purpose exact decimal math
Money libraryDinero.js, Money PHP, JSR-354 (Java)Rich formatting, allocation, and comparison APIs
Database storageDECIMAL(19,4) (SQL), NUMERIC (PostgreSQL)Persistent exact values with 4-decimal precision for rates

What Works

  1. Store amounts in minor units (cents) or exact decimal types; never store money as floating-point in databases.
  2. Round at the right boundary — calculate line items with full precision, round per-line, then sum rounded values for the total.
  3. Separate money from display — keep raw Decimal / BigDecimal / cents internally and format only at the UI/API layer.
  4. Know your rounding mode — HALF_UP is standard for commerce; HALF_EVEN (banker’s rounding, Python’s default) reduces bias across large aggregations. Pick one per domain and write down which — auditors always ask.
  5. Cache exchange rates with TTL and timestamp; always convert using the rate that applied at the transaction time.

Common Mistakes

  1. Reaching for float or double for prices — that’s how 0.30000000000000004 ends up on an invoice.
  2. Rounding only at the very end of a long calculation chain, propagating sub-penny errors.
  3. Storing money as strings and parsing with locale-dependent commas/dots, causing regional bugs.
  4. Adding different currencies directly without conversion, producing meaningless totals.
  5. Ignoring currency subunits — some currencies (JPY, KRW) have no decimal places; others (BHD, IQD) have 3.

When Not to Use

  • Display-only estimates — percentages, progress bars, and rough projections where nobody reconciles the last cent tolerate float fine.
  • Prototypes and internal tools — if no real payment touches the number, the ceremony of a Money type buys little.
  • When your framework already ships a money type — Django’s money fields, money-rails, JSR-354 implementations — use it. A hand-rolled class is yours to maintain forever.

Testing Money Code

Money bugs hide at boundaries, so test the edges rather than the happy path:

  • Rounding boundaries — 19.995 rounds to 20.00 under HALF_UP; 2.5 rounds to 2 under HALF_EVEN. Write a test for the mode you chose, not the one you assumed.
  • Currency mismatch — adding USD to EUR must raise, not silently sum.
  • Allocation round-trips — splitting 100.00 three ways must return parts that re-sum to 100.00.
  • Minor-unit edge cases — JPY (¥1234, zero decimals) and BHD (three decimals) break code that hard-codes quantize("0.01").
  • Idempotent conversions — converting USD→EUR→USD rarely returns the original amount; assert the documented tolerance instead of equality.

Further Reading

Frequently Asked Questions

Why can't I just use float or double for money?

Binary floating-point stores fractions as sums of inverse powers of 2. Decimals like 0.1 are infinite repeating sequences in binary, so they get rounded. These tiny errors accumulate in multiplication and division, causing invoices to be off by pennies or worse.

How should I store money in a database?

Use DECIMAL(19,4) (or NUMERIC in PostgreSQL) to preserve 4 decimal places of precision. This handles sub-penny calculations (tax rates, exchange rates) while keeping the final 2-decimal value exact. Avoid FLOAT, DOUBLE, and REAL columns.

What rounding mode should I use?

HALF_UP (round 0.5 away from zero) is standard for most commercial applications. HALF_EVEN (banker's rounding) is used in some financial standards and reduces bias across large aggregations. Always round consistently within your domain and document the mode for auditors.

How do I split a payment without losing cents?

Divide into minor units and distribute the remainder deterministically. $100.00 split three ways becomes 3333 + 3333 + 3334 cents. Dinero.js has allocate for exactly this; with plain decimals, sort largest-remainder first so the same split always produces the same parts.

What about currencies without decimals like JPY?

Store the minor unit count, not a fixed 2-decimal assumption. JPY and KRW have zero decimal places, BHD and IQD have three — ISO 4217 defines the exponent per currency. Code that hard-codes 100 as the divisor corrupts values the first time a ¥ price flows through.

Decimal type or integer cents — which should I pick?

Integer cents (1999 for $19.99) are fast, index-friendly, and portable across languages, but every boundary needs conversion discipline. Decimal/BigDecimal reads more naturally and handles sub-unit math (rates, tax percentages) without extra thought. Pick cents for hot paths and simple ledgers, decimals for general business logic.