Money and Precision

Why float arithmetic breaks invoice totals, and how Decimal with quantize or integer cents fixes it for good.

SyntaxDecimal(value='0').quantize(exp, rounding=None, context=None) -> Decimal

0.1 + 0.2 is 0.30000000000000004. Nothing is broken: a float is a binary fraction, and 0.1 has no exact binary representation, any more than one third has an exact decimal one. The error is tiny and it compounds. Sum a few hundred invoice lines and the total is a cent out from the one the customer's spreadsheet computed, and no amount of round() at the end explains where it went.

Decimal

decimal.Decimal does base-10 arithmetic with a configurable precision — default context prec=28, rounding ROUND_HALF_EVEN. Build it from a string or an int: the docs note that a float argument is "losslessly converted to its exact decimal equivalent", so Decimal(0.1) is Decimal('0.1000000000000000055511151231257827021181583404541015625'). Decimals refuse to mix with floats in arithmetic — adding one to a float raises TypeError, which is the language catching the mistake for you.

quantize, and when to round

d.quantize(exp, rounding=None) returns d rounded to the exponent of exp: d.quantize(Decimal("0.01")) gives two decimal places. round(d, 2) is the shorthand for exactly that call under the current context, so the two agree — including on ROUND_HALF_EVEN, which is not what most tax rules say. Pass rounding=ROUND_HALF_UP when the rule says half up. Do arithmetic at full precision and quantize once, at the point the number is stored or shown.

The other correct answer is integer minor units: hold cents as int and format at the edge. Both work. Float does not.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Total an invoice so the figure matches the customer's own spreadsheet to the cent.
  • Apply a VAT rate with a documented half-up rounding rule rather than the default half-even.
  • Read currency amounts out of JSON or CSV without passing through float.
  • Split a discount across order lines so the lines still sum to the order total.

More examples

Money as integer minor units

Pick this when the whole system can agree on minor units: database column, API field and internal arithmetic all int. The trade-off is that percentages still need one division, so there is exactly one place where a rounding rule is applied and it should be commented with the rule it implements.

Example · python
# Amounts as an int of minor units: no rounding mode to argue about, and it is
# how most payment APIs model money on the wire (amount=1999 means EUR 19.99).

def line_total_cents(unit_price_cents: int, qty: int) -> int:
    return unit_price_cents * qty

subtotal = line_total_cents(1999, 3) + line_total_cents(495, 1)   # 6492
vat = round(subtotal * 20 / 100)      # the single documented rounding, in one place
print(subtotal, vat, subtotal + vat)  # 6492 1298 7790

def money(cents: int) -> str:
    """Format at the edge, not in the arithmetic."""
    sign = "-" if cents < 0 else ""
    cents = abs(cents)
    return f"{sign}{cents // 100}.{cents % 100:02d}"

print(money(7790))                    # 77.90

localcontext for a rounding rule that is not the default

Use localcontext when a whole calculation shares a rounding rule, instead of threading rounding= through every quantize call. Prefer it to decimal.setcontext() at import time: a global change to rounding behaviour is invisible to the next reader and leaks into unrelated code.

Example · python
from decimal import Decimal, localcontext, ROUND_HALF_UP

# The default context is prec=28, rounding=ROUND_HALF_EVEN - bankers' rounding,
# which few tax authorities specify. localcontext() is a context manager: the
# settings apply inside the block and are restored on exit.
with localcontext() as ctx:
    ctx.prec = 12
    ctx.rounding = ROUND_HALF_UP
    print(Decimal("64.925").quantize(Decimal("0.01")))   # 64.93

print(Decimal("64.925").quantize(Decimal("0.01")))       # 64.92 - default again

Splitting a total without losing a cent

Needed for proportional discounts, VAT per line, revenue shares and refunds. Rounding each share independently gives 3.33 three times and loses a cent, which an accounting reconciliation will find. The rule to encode is that the parts must sum to the whole, not that each part is individually nearest.

Example · python
from decimal import Decimal, ROUND_DOWN

def split(total: Decimal, parts: int) -> list[Decimal]:
    """Round every share down, then hand the remainder out a cent at a time,
    so the shares always add back up to the total."""
    cent = Decimal("0.01")
    share = (total / parts).quantize(cent, rounding=ROUND_DOWN)
    shares = [share] * parts
    remainder = total - share * parts
    for i in range(int(remainder / cent)):
        shares[i] += cent
    return shares

print(split(Decimal("10.00"), 3))                    # 3.34, 3.33, 3.33
print(sum(split(Decimal("10.00"), 3)) == Decimal("10.00"))   # True

Discussion

  • Be the first to comment on this lesson.