pyguides

Precise Arithmetic with the decimal Module

Why floats bite you, and what Decimal fixes

If you’ve ever written Python that handles money, percentages, or any “obvious” decimal arithmetic and got burned by 0.1 + 0.2 == 0.30000000000000004, you already know why this article exists. The decimal module exists for the same reason: floats are binary IEEE-754, and most decimal fractions don’t have a finite binary expansion. The error is small, but it never goes away, and it accumulates across operations.

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 + 0.1
0.4000000000000001
>>> sum([0.1] * 10)
0.9999999999999999

The decimal module gives you a number type that works in base 10, with controllable precision and rounding. That’s what makes it the right tool for money, tax, accounting, and any calculation where you need the answer to match what a human would write on a slip of paper.

The Decimal class and how to construct it

The workhorse is decimal.Decimal. It accepts a few different input types, and the one you pick matters more than any other decision in this module.

>>> from decimal import Decimal
>>> Decimal(10)                          # int → exact
Decimal('10')
>>> Decimal('3.14')                      # str → exact (preferred)
Decimal('3.14')
>>> Decimal(3.14)                        # float → captures the binary value
Decimal('3.140000000000000124344978758017532527446746826171875')
>>> Decimal((0, (3, 1, 4), -2))          # (sign, digits, exponent) tuple
Decimal('3.14')
>>> Decimal('NaN')
Decimal('NaN')

That third line is the single most common footgun. Decimal(0.1) does not give you 0.1; it gives you the exact value of the float 0.1, written out to 53+ digits. If you’re parsing JSON, pulling from a database, or accepting user input, route through str. Save the float form for the rare case where you specifically want to preserve a binary result (matching an external IEEE-754 computation, for example).

The tuple form is useful when you’re already working with digits in a structured form. It looks like (sign, digits_tuple, exponent), where exponent is the power of 10 of the last digit. So (0, (3, 1, 4), -2) means “positive, digits 3, 1, 4, exponent −2,” which is 3.14.

Special values

Decimal understands Infinity, Inf, -Infinity, NaN, and sNaN (signaling NaN), and signed zeros. You can construct them with strings, or by arithmetic when the relevant trap is disabled: Decimal(1) / Decimal(0) raises DivisionByZero by default, but with the trap off, it produces Infinity.

One thing to know: Decimal('NaN') == Decimal('NaN') is False. NaN never equals itself in this module. Use is_nan() or the explicit compare() and compare_signal() methods when you need to handle NaN.

The context: precision, rounding, and traps

Decimal arithmetic is governed by a thread-local Context. You almost never build one from scratch; you read or modify the current one with getcontext() and setcontext().

>>> from decimal import getcontext
>>> getcontext().prec
28
>>> getcontext().rounding
'ROUND_HALF_EVEN'

Two fields do most of the work:

  • prec — the number of significant digits kept in any arithmetic result. Default is 28.
  • rounding — which rounding mode applies when an operation has to discard digits. Default is ROUND_HALF_EVEN, also called banker’s rounding.

prec only affects arithmetic, not construction. Decimal('1.234567') keeps all six digits no matter what prec is. They only get rounded once you actually do something with the value:

>>> from decimal import Decimal, getcontext
>>> getcontext().prec = 3
>>> Decimal('1.234567')                  # construction: no rounding
Decimal('1.234567')
>>> Decimal('1.234567') + 0              # arithmetic: rounded to 3 significant digits
Decimal('1.23')

Eight rounding modes

The module ships with eight rounding constants. The names match the spec, and they cover every “round half X” variation you’ll see in business requirements.

ModeBehavior
ROUND_HALF_EVENBanker’s rounding. Ties go to the nearest even digit. This is the default.
ROUND_HALF_UPTies away from zero. “Round half up” in the school sense.
ROUND_HALF_DOWNTies toward zero.
ROUND_UPAlways away from zero.
ROUND_DOWNAlways toward zero (truncate).
ROUND_CEILINGToward positive infinity.
ROUND_FLOORToward negative infinity.
ROUND_05UPRound away from zero if the last digit would be 0 or 5.
>>> from decimal import Decimal, ROUND_HALF_UP
>>> Decimal('2.5').quantize(Decimal('1'))                 # default: banker's → 2
Decimal('2')
>>> Decimal('3.5').quantize(Decimal('1'))                 # banker's → 4 (3.5 is even at 4)
Decimal('4')
>>> Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_UP)
Decimal('3')

A subtle one: round(Decimal('2.675'), 2) returns Decimal('2.67'), not 2.68. People who expect the float behavior round(2.675, 2) == 2.68 are running into the IEEE-754 representation of 2.675, which is actually 2.6749999.... With Decimal, 2.675 is exactly 2.675, and banker’s rounding sends the tie to the nearest even, which is 2.67.

Scoping changes with localcontext()

getcontext().prec = X mutates the thread-local context (coroutine-local since Python 3.8.3). Don’t do that in library code; it leaks into other parts of the program. Use localcontext() instead.

>>> from decimal import Decimal, localcontext
>>> with localcontext() as ctx:
...     ctx.prec = 50
...     Decimal(1) / Decimal(7)
...
Decimal('0.14285714285714285714285714285714285714285714285714')
>>> # outside the block, default 28 digits are back
>>> Decimal(1) / Decimal(7)
Decimal('0.142857142857142857142857142857')

localcontext() also accepts keyword arguments, so localcontext(prec=50, rounding=ROUND_HALF_UP) is the one-liner form.

The quantize() money workhorse

Decimal.quantize() is the method you’ll reach for every time you need a “rounded to N decimal places” answer. The argument is itself a Decimal whose exponent tells the target. Decimal('0.01') means “round to two places after the point.” Decimal('1') means “round to an integer.” Decimal('0.0001') means four decimal places.

>>> from decimal import Decimal, ROUND_HALF_UP
>>> subtotal = Decimal('99.99')
>>> rate = Decimal('0.0825')
>>> tax = (subtotal * rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
>>> tax
Decimal('8.25')
>>> total = (subtotal + tax).quantize(Decimal('0.01'))
>>> total
Decimal('108.24')

The pattern is: do your work in full precision, then call quantize at the boundary. This keeps intermediate errors from compounding and makes the rounding behavior explicit.

If your prec is too low, quantize can raise InvalidOperation. If you have a 4-digit context and try to quantize a 6-digit value, you may need a wider context. Bump prec first or wrap the call in localcontext.

Common patterns

Invoices, tax, and percentages

The shape of “subtotal, apply a tax rate, add them up, format to currency” is the headline use case for Decimal. Keep the rate and the subtotal as Decimal strings, multiply, then quantize at the end.

>>> from decimal import Decimal, ROUND_HALF_UP
>>> def invoice_total(subtotal, tax_rate):
...     subtotal = Decimal(subtotal)
...     tax_rate = Decimal(tax_rate)
...     tax = (subtotal * tax_rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
...     return subtotal + tax
...
>>> invoice_total('99.99', '0.0825')
Decimal('108.24')

Cumulative division

1 / 7 is a classic case where the float version drifts and the Decimal version is as exact as you want it to be, with the precision under your control. Repeated division chains are exactly where binary float quietly accumulates error, so this is a useful test of the module: if the result looks right here, the context is wired up correctly.

>>> from decimal import Decimal, localcontext
>>> with localcontext(prec=60):
...     Decimal(1) / Decimal(7)
...
Decimal('0.142857142857142857142857142857142857142857142857142857142857')

Sums and averages

sum() works on Decimal because Decimal(0) is a valid start, and the addition runs in full precision with the current context’s prec. The catch is that math.fsum() does not work, since it only accepts floats; there is no automatic promotion. functools.reduce(operator.add, decimals) is the other option when you want a custom seed or a fold with a different operator.

>>> from decimal import Decimal
>>> amounts = [Decimal('12.50'), Decimal('7.25'), Decimal('30.00')]
>>> sum(amounts)
Decimal('49.75')
>>> sum(amounts) / len(amounts)
Decimal('16.58333333333333333333333333')

JSON and databases

json.dumps(Decimal('1.10')) raises TypeError because the standard library does not know how to serialize a Decimal by default. The standard fix is a custom encoder that converts each value to its string form, which preserves the exact decimal representation. That string can then be parsed back on the other side with Decimal(...).

>>> import json
>>> from decimal import Decimal
>>> class DecimalEncoder(json.JSONEncoder):
...     def default(self, o):
...         if isinstance(o, Decimal):
...             return str(o)
...         return super().default(o)
...
>>> json.dumps({'total': Decimal('108.24')}, cls=DecimalEncoder)
'{"total": "108.24"}'

For SQL, register an adapter so the database driver knows how to round-trip Decimal values. The sqlite3 module is part of the standard library, and the pattern is short:

>>> import sqlite3
>>> from decimal import Decimal
>>> sqlite3.register_adapter(Decimal, str)
>>> sqlite3.register_converter('DECIMAL', Decimal)

psycopg2 ships with built-in Decimal adapters for numeric columns, but you still need to declare DECIMAL types in your cursor if you want them back as Decimal instead of float.

Common mistakes

Decimal(0.1) is a trap

Decimal(0.1) is not 0.1. It’s the exact value of the float 0.1, which is a 53-digit binary fraction. Use Decimal('0.1') for anything that comes from text, JSON, or a database.

Default rounding is banker’s rounding

Most people expect “round half up” because that’s what they learned in school. The decimal module defaults to ROUND_HALF_EVEN to reduce systematic bias across many calculations. That’s a good default for statistics; it’s a bad default for invoices if your customers expect “round half up.” Pick the rounding mode explicitly, and write a test that locks it in.

Mixing Decimal and float silently

Mixed arithmetic (Decimal('1.1') + 0.1) raises TypeError. Mixed comparison (Decimal('1.1') > 0.1) is allowed and returns the right answer. The asymmetry is by design, but it can hide bugs. If you want to ban float inputs outright, enable the FloatOperation trap:

>>> from decimal import getcontext, Decimal, FloatOperation
>>> getcontext().traps[FloatOperation] = True
>>> Decimal(3.14)  # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
    ...
decimal.FloatOperation: [<class 'decimal.FloatOperation'>]

This is the kind of thing that’s worth wiring into a test fixture in any codebase that handles money.

Avoid math module functions on Decimal

Decimal has its own sqrt(), exp(), ln(), log10(), and fma(). Reach for those instead of the math module functions when you’re already in Decimal land. And remember that math.fsum and math.cos only accept floats; there’s no automatic promotion.

Precision only affects arithmetic

Setting getcontext().prec = 2 does not change Decimal('1.234567') into Decimal('1.2'). Construction preserves every digit you put in. The unary plus +x re-applies the current context’s precision to an already-built value, which is occasionally useful for normalizing input.

Conclusion

decimal.Decimal is the right tool for money, percentages, taxes, currency conversion, and any other calculation where the answer needs to be exact in base 10. The key habits are: construct from str (or int); use localcontext() to scope precision; reach for quantize() at output boundaries; pick your rounding mode explicitly; and ban float inputs in test suites with the FloatOperation trap.

Skip Decimal for tight numerical kernels. For scientific computing, numpy with float64 is the right tool; you’ll be running tens to hundreds of times faster, and the small per-operation errors are exactly what those algorithms are designed to handle. Decimal and numpy are different answers to different questions.

See also