Skip to content
Tyrone Software
Python Coding

Money in Python: Decimal, rounding and the 2.675 problem

Abstract illustration for the article Money in Python: Decimal, rounding and the 2.675 problem

Reconciliation reports differ by a cent here and there, and nobody can explain why. Three properties of Python numbers, each easy to verify, account for most of it.

Floats cannot store most decimal fractions

>>> 0.1 + 0.2
0.30000000000000004
>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')

The float error is small, but it accumulates, and comparisons with == fail in surprising places.

The value you typed is not the value you have

>>> round(2.675, 2)
2.67
>>> Decimal(2.675)
Decimal('2.67499999999999982236431605997495353221893310546875')

The literal 2.675 is stored as the closest binary fraction, which is slightly below 2.675, so round correctly rounds it down. Create decimals from strings or integers, never from floats. Decimal('2.675') holds exactly what you wrote.

round() and quantize() round halves to even

>>> round(2.5), round(3.5)
(2, 4)
>>> Decimal('2.5').quantize(Decimal('1'))
Decimal('2')
>>> from decimal import ROUND_HALF_UP
>>> Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_UP)
Decimal('3')

Rounding halves to the nearest even number (often called banker's rounding) is statistically fairer, but it is not what most invoices, tax rules or people expect. Decide which rule your business requires and pass it explicitly. Where a regulation or a payment provider defines the rounding, use theirs, and apply it at the same point in the calculation, per line item or on the total, because the two can differ by a cent.

Table of Python rounding and Decimal examples with the reason for each result
All results were produced on Python 3.14.

Storing and moving the values

  • In a database, use NUMERIC(precision, scale) or integer minor units (cents), not a floating-point column.
  • In JSON, send amounts as strings, or as integers in minor units. Many JSON parsers turn numbers into floats, and the error reappears on the other side.
  • Decimal arithmetic uses a context whose default precision is 28 significant digits. That is plenty for money, but it matters for very large or very precise values, such as token amounts with 18 decimals, where you may need to raise the precision or work in integers.
  • Divide with care: Decimal(10) / Decimal(3) produces a long fraction, so the last cent of a split payment has to be assigned deliberately.

The rule that survives everything: pick one representation for amounts inside your system, convert at the boundaries, and round only once, as late as possible.

A scoped context instead of a global setting

decimal.getcontext() returns the thread's current default context, and changing it (getcontext().prec = 50) affects every Decimal operation on that thread from then on, which is easy to forget you did in one function and hard to debug from another. decimal.localcontext() gives you a context that only applies inside a with block and is restored automatically afterwards:

with decimal.localcontext() as ctx:
    ctx.prec = 50
    result = Decimal(1) / Decimal(3)   # computed at 50 digits of precision
# precision back to the previous default here

This is the right tool for a calculation that genuinely needs unusual precision, such as splitting a large payment across many recipients, without leaving the rest of the codebase running under a changed default. Each thread gets its own context, so this is also the safe way to change precision in a multithreaded service without one request's setting leaking into another's.

Keep reading

Technologies

  • Python
  • PHP
  • Laravel
  • Django
  • PostgreSQL
  • MySQL
  • HTML5
  • CSS3
  • Bootstrap