Fixing Float Precision Surprises in Python: decimal vs float Explained

June 04, 2026 7 min read 51 views
Abstract flat illustration contrasting binary and decimal number representations with geometric shapes on a soft gradient background

You add 0.1 + 0.2 in Python and get 0.30000000000000004. Your invoice totals are off by a penny. A test that compares two "equal" numbers randomly fails. These aren't bugs in your logic β€” they're the predictable behavior of binary floating-point arithmetic, and Python's built-in float type is the culprit.

This article shows you exactly why this happens, when it matters, and how to fix it using Python's decimal module β€” without rewriting your entire codebase.

What you'll learn

  • Why float can't represent many decimal numbers exactly
  • When floating-point errors actually matter (and when they don't)
  • How to use the decimal module for precise arithmetic
  • How to control rounding, context, and precision in decimal
  • Common pitfalls when migrating from float to decimal

Prerequisites

You need a working Python 3 installation. No third-party packages are required β€” the decimal module is part of the standard library. Familiarity with basic Python arithmetic is assumed.

Why float Behaves This Way

Python's float type follows the IEEE 754 double-precision binary format. That means every floating-point number is stored as a sum of powers of 2. This works perfectly for values like 0.5 (which is 2⁻¹), but most decimal fractions β€” including simple ones like 0.1 β€” cannot be expressed exactly in binary.

When Python stores 0.1, it stores the closest binary approximation: 0.1000000000000000055511151231257827021181583404541015625. The error is tiny, but arithmetic compounds it.

# The classic example
print(0.1 + 0.2)          # 0.30000000000000004
print(0.1 + 0.2 == 0.3)   # False

# Rounding doesn't always save you
print(round(0.1 + 0.2, 1) == 0.3)  # True β€” but fragile

# It accumulates
total = 0.0
for _ in range(10):
    total += 0.1
print(total)  # 0.9999999999999999

This is not a Python bug. The same thing happens in JavaScript, Java, C, and almost every language that uses IEEE 754 floats. Python just makes it more visible by not hiding the representation error in its default output.

When Does It Actually Matter?

For most scientific computing and data analysis, floating-point approximation is fine. Rounding errors at the 15th significant digit rarely affect your conclusions, and libraries like NumPy are engineered to minimize accumulation of those errors.

It matters a great deal in these situations:

  • Financial applications β€” money has exact cent values; a penny error in each transaction adds up
  • Tax and invoice calculations β€” rounding rules are defined by law, not by binary arithmetic
  • Equality comparisons β€” comparing two floats with == is almost always wrong
  • Repeated accumulation β€” summing thousands of small values in a loop
  • Generating output for audits or reports β€” where the displayed value must be exact

If you're building a physics simulation or a machine learning model, stick with float. If you're calculating a refund amount or a tax total, use decimal.

Introducing the decimal Module

Python's decimal module implements decimal floating-point arithmetic to arbitrary precision, following the IBM General Decimal Arithmetic specification. Numbers are stored as base-10 values, so 0.1 is 0.1 β€” exactly.

from decimal import Decimal

print(Decimal('0.1') + Decimal('0.2'))       # 0.3
print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3'))  # True

Notice the string argument. This is not optional ceremony β€” it's essential. If you write Decimal(0.1), you're converting the already-imprecise float into a Decimal, and you inherit the error:

from decimal import Decimal

# Wrong β€” float imprecision is baked in before Decimal sees it
print(Decimal(0.1))
# Decimal('0.1000000000000000055511151231257827021181583404541015625')

# Correct β€” string preserves the exact decimal value you intend
print(Decimal('0.1'))
# Decimal('0.1')

Always construct Decimal from a string, not from a float literal. This is the single most common mistake when adopting the module.

Controlling Precision and Rounding

The decimal module exposes a Context object that controls how arithmetic behaves globally or within a local block. You can set the number of significant digits and the rounding strategy.

from decimal import Decimal, getcontext, localcontext, ROUND_HALF_UP

# Set global precision to 28 significant digits (the default)
getcontext().prec = 28

# Override precision and rounding for a specific block
with localcontext() as ctx:
    ctx.prec = 6
    ctx.rounding = ROUND_HALF_UP
    result = Decimal('1') / Decimal('3')
    print(result)  # 0.333333

# Outside the block, default context is restored
print(Decimal('1') / Decimal('3'))
# 0.3333333333333333333333333333

Use localcontext whenever you need a specific rounding rule for a section of code without affecting anything else. This is the right approach for invoice generation or tax calculations that each have different rounding requirements.

Rounding to a specific number of decimal places

The quantize method pins a Decimal to a specific exponent, applying the rounding rule you choose. This is how you express "round to the nearest cent".

from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN

price = Decimal('19.995')

# Round to 2 decimal places, half-up (common in finance)
print(price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))  # 20.00

# Truncate (floor toward zero)
print(price.quantize(Decimal('0.01'), rounding=ROUND_DOWN))     # 19.99

Python's built-in round() uses banker's rounding (round-half-to-even), which minimizes statistical bias but surprises people expecting the school-taught half-up rule. quantize with an explicit rounding constant removes the ambiguity.

Practical Example: Invoice Totals

Here's a stripped-down invoice calculation that shows how to apply these ideas end-to-end.

from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal('0.01')

def round_money(value: Decimal) -> Decimal:
    return value.quantize(CENT, rounding=ROUND_HALF_UP)

line_items = [
    {'description': 'Widget A', 'qty': 3,  'unit_price': Decimal('9.99')},
    {'description': 'Widget B', 'qty': 1,  'unit_price': Decimal('14.50')},
    {'description': 'Widget C', 'qty': 12, 'unit_price': Decimal('0.75')},
]

subtotal = sum(item['qty'] * item['unit_price'] for item in line_items)
tax_rate = Decimal('0.085')  # 8.5%
tax = round_money(subtotal * tax_rate)
total = subtotal + tax

print(f'Subtotal: {subtotal}')   # Subtotal: 52.49
print(f'Tax:      {tax}')        # Tax:      4.46
print(f'Total:    {total}')      # Total:    56.95

Compare this to the float version, where the tax calculation might drift by a cent depending on the values involved. With Decimal, every result is reproducible and matches what a human would calculate on paper.

Working with Databases and External Data

Data often arrives as strings (from a CSV or JSON payload) or as floats (from a database driver that maps NUMERIC columns to Python floats). You need to handle both carefully.

from decimal import Decimal
import json

# From a JSON string β€” safe, convert directly
raw = '{"amount": "19.99"}'
data = json.loads(raw)
amount = Decimal(data['amount'])   # String path β€” exact

# From a JSON number β€” dangerous
raw_num = '{"amount": 19.99}'
data_num = json.loads(raw_num)
amount_bad = Decimal(data_num['amount'])   # Float path β€” already imprecise
print(amount_bad)
# Decimal('19.9899999999999999289457264239899814128875732421875')

# Fix: serialize money as strings in your API contracts
# Or use json.loads with parse_float=Decimal
data_fixed = json.loads(raw_num, parse_float=Decimal)
print(data_fixed['amount'])  # Decimal('19.99')

The parse_float=Decimal trick in json.loads is particularly useful when you don't control the upstream JSON format but still need precise decimal values.

If you use SQLAlchemy, the Numeric column type returns Decimal objects automatically when asdecimal=True is set (which is the default for most dialects). Always verify your driver's behavior and test with a value like 0.10 to confirm no silent float conversion is happening.

Performance Considerations

Decimal arithmetic is slower than float arithmetic β€” typically by a factor of somewhere between 10x and 100x for intensive numeric work, depending on precision settings and the operations involved. For most business logic this is irrelevant: calculating an invoice takes microseconds either way.

Where it does matter:

  • Batch data processing β€” if you're applying Decimal arithmetic to millions of rows in a loop, profile before committing
  • NumPy and Pandas β€” these libraries don't natively support Decimal; their arrays use float64. Convert to Decimal only at the boundary where precision is required, not throughout your data pipeline

A practical pattern is to do the heavy lifting in float/NumPy, then convert aggregated results to Decimal for final rounding and display.

Common Pitfalls and Gotchas

A few mistakes come up repeatedly when developers adopt decimal:

  • Constructing from float literals β€” always use strings: Decimal('0.1'), not Decimal(0.1)
  • Mixing Decimal and float in arithmetic β€” Python raises a TypeError when you try to add a Decimal and a float directly. Explicit conversion is required.
  • Forgetting to set rounding in quantize β€” the default rounding mode is ROUND_HALF_EVEN, which may not match your business rules
  • Treating string formatting as rounding β€” f'{value:.2f}' rounds the displayed string but does not change the underlying value; use quantize if you need the rounded value in further calculations
  • Not accounting for significant digits in division β€” Decimal('1') / Decimal('3') gives 28 significant digits by default, which is fine, but be aware of the context precision if you changed it
from decimal import Decimal

# TypeError β€” can't mix types
try:
    result = Decimal('1.5') + 1.5
except TypeError as e:
    print(e)  # unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

# Correct β€” convert the float to Decimal via string
result = Decimal('1.5') + Decimal('1.5')
print(result)  # 3.0

# Or convert an integer directly (integers are exact)
result2 = Decimal('1.5') + Decimal(1)   # int is fine
print(result2)  # 2.5

Quick Reference: float vs decimal

Characteristicfloatdecimal.Decimal
Storage formatBinary (IEEE 754)Decimal base-10
Exact for 0.1?NoYes
Default precision~15-17 significant digits28 significant digits (configurable)
SpeedFast (hardware)Slower (software)
NumPy compatibleYesNo (object arrays only)
Best forScience, ML, performanceFinance, accounting, audits

Wrapping Up

Floating-point precision issues are not random β€” they're deterministic and predictable once you understand the underlying representation. The fix is straightforward: use decimal.Decimal when exactness matters, and be disciplined about how you construct values and apply rounding.

Here are concrete steps to take next:

  • Audit any code that handles money, tax, or invoice totals and replace float with Decimal initialized from strings
  • Replace all == comparisons on floats with math.isclose() for scientific code, or quantize-then-compare for financial code
  • Add parse_float=Decimal to your json.loads calls in any service that ingests monetary values
  • Run python -c "from decimal import Decimal; print(Decimal('0.1') + Decimal('0.2'))" as a quick sanity check in your environment
  • Read the decimal module documentation's section on contexts β€” understanding localcontext will help you write thread-safe code when rounding rules vary by operation

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.