A price of 0.10, a tax rate of 8.25%, and a total rounded to cents look like ordinary numbers. But the representation you choose determines which arithmetic rules your program actually follows.

Python’s float is binary floating point. It is excellent for measurements, graphics, scientific calculations, and many other workloads where small approximation is expected. The problem appears when your domain requires values and rounding rules expressed in base 10.

For example:

>>> 0.1 + 0.2
0.30000000000000004

That result is not a Python arithmetic bug. Most simple-looking decimal fractions, including 0.1, cannot be represented exactly as finite binary fractions, so float stores the nearest representable binary value.

Python’s decimal.Decimal uses decimal arithmetic instead. It can represent values such as 0.1, 19.99, and 0.0825 exactly when you construct them from decimal text.

The useful mental model is not “Decimal is more accurate.” It is:

Choose a number system whose representation and rounding rules match the problem you are modeling.

This article develops that model, shows how to use Decimal safely, and explains when an integer or ordinary float is still the better choice.

Binary approximation matters when the domain specifies decimal values

A Python float is normally implemented as IEEE 754 binary floating point. A value such as decimal 0.1 has no finite representation in base 2, just as one third has no finite representation in base 10.

The stored value is therefore an approximation.

That often causes no practical problem. If a temperature sensor reports 21.7 degrees, a tiny representation difference is usually far below the sensor’s own uncertainty.

Money and accounting rules are different. A business rule may explicitly say:

  • prices have two decimal places;
  • a rate is 8.25%;
  • intermediate values retain more precision;
  • the final amount rounds to the nearest cent using a specified tie-breaking rule.

Those are decimal rules. Modeling them with decimal arithmetic makes the program’s number system align with the contract.

Start by constructing Decimal values from strings

The smallest useful example is:

from decimal import Decimal

result = Decimal("0.1") + Decimal("0.2")

print(result)

The result is:

0.3

The strings are important.

Decimal("0.1") means “construct the exact decimal value 0.1.”

By contrast, this code starts from an already approximated binary float:

from decimal import Decimal

value = Decimal(0.1)

print(value)

The resulting Decimal exactly represents the value of that float, including its binary approximation. Converting to Decimal after the approximation has happened does not reconstruct the original textual intent.

For decimal values coming from configuration, HTTP payloads, CSV files, database text fields, or user input, parse the decimal text directly:

price = Decimal("19.99")
tax_rate = Decimal("0.0825")

Integers are also safe construction inputs:

quantity = Decimal(3)

An integer has an exact decimal representation, so no binary fractional approximation is introduced.

Decimal arithmetic still has a precision context

Using Decimal does not mean every possible calculation becomes infinitely precise.

Python decimal arithmetic operates within a context. Among other settings, the context defines the arithmetic precision and the default rounding mode.

You can inspect it:

from decimal import getcontext

print(getcontext())

The default precision is sufficient for many application calculations, but the important point is conceptual: decimal representation can be exact while a later arithmetic operation can still require rounding.

For example, division can produce an infinite decimal expansion:

from decimal import Decimal

print(Decimal(1) / Decimal(7))

There is no finite decimal representation of one seventh. The decimal context determines how many significant digits are retained.

So there are two different questions:

  1. Can the input value be represented exactly?
  2. Can the result of this operation be represented within the active precision?

Decimal("0.1") answers the first question exactly. It does not remove the need to think about precision for arbitrary arithmetic.

Keep full precision until the rounding boundary

A common money calculation has a natural rounding boundary.

Suppose three items cost 19.99 each and tax is 8.25%:

from decimal import Decimal

price = Decimal("19.99")
quantity = Decimal(3)
tax_rate = Decimal("0.0825")

subtotal = price * quantity
tax = subtotal * tax_rate

print(subtotal)
print(tax)

The values are:

59.97
4.947525

The tax calculation produces more than two decimal places. That is not an error. It is an intermediate result.

Rounding every intermediate value to cents can change the final answer. Instead, keep the precision required by the calculation and round at the boundary defined by the business rule.

For a final currency amount, quantize() is the standard tool.

Use quantize() to apply an explicit decimal scale

quantize() rounds a Decimal to the exponent of another Decimal.

For cents:

from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

subtotal = Decimal("19.99") * 3
tax = subtotal * Decimal("0.0825")

total = (subtotal + tax).quantize(
    CENT,
    rounding=ROUND_HALF_UP,
)

print(total)

The result is:

64.92

Decimal("0.01") is not merely documentation. Its exponent defines the two-decimal-place scale used by quantize().

The rounding mode should come from the domain rule, not from habit.

ROUND_HALF_UP rounds ties away from zero in the familiar “5 rounds up” style for positive values. Python’s decimal context defaults to ROUND_HALF_EVEN, which rounds a tie toward the choice with an even final digit.

Neither rule is universally correct for money. Tax authorities, payment systems, accounting standards, and contracts can specify different policies. Encode the required rule explicitly when the boundary matters.

Do not round each component unless the rule requires it

Consider a shopping cart with many line items. Two reasonable-looking systems can disagree:

A. round each line's tax, then sum the rounded taxes
B. sum unrounded tax amounts, then round the total tax

Those algorithms are not mathematically equivalent.

If each line produces a fraction of a cent, method A discards or adds fractions repeatedly. Method B accumulates them before one final rounding operation.

There is no universal answer about which method is correct. The correct algorithm is whatever the governing business rule specifies.

The engineering lesson is to make rounding boundaries part of the model:

line_tax = (line_subtotal * rate).quantize(CENT, rounding=ROUND_HALF_UP)

means something different from:

tax_total = sum(unrounded_line_taxes).quantize(
    CENT,
    rounding=ROUND_HALF_UP,
)

Do not hide that choice inside a formatting helper.

Formatting and rounding are separate concerns

Formatting controls how a value is displayed:

from decimal import Decimal

amount = Decimal("12.5")

print(f"{amount:.2f}")

Output:

12.50

That is useful at a presentation boundary, but a formatted string is no longer a numeric value.

If your application requires the numeric result itself to have a two-place decimal exponent, use quantize():

amount = Decimal("12.5").quantize(Decimal("0.01"))

print(amount)

Output:

12.50

Keeping arithmetic and presentation separate avoids code where a string-formatting decision accidentally becomes a calculation rule.

Avoid mixing Decimal and float arithmetic

Once a calculation uses Decimal, keep decimal operands throughout that calculation.

This is invalid:

from decimal import Decimal

amount = Decimal("10.00")
discount = 0.15

new_amount = amount * (1 - discount)

Decimal arithmetic does not silently combine with float arithmetic in operations such as multiplication and addition.

The preferred approach is to keep the rate decimal too:

from decimal import Decimal

amount = Decimal("10.00")
discount = Decimal("0.15")

new_amount = amount * (Decimal(1) - discount)

This is valuable beyond avoiding a TypeError. It prevents an API boundary from quietly introducing binary approximations into a calculation whose rules are supposed to be decimal.

A useful design practice is to convert inputs at the edge of the system and then keep one numeric representation internally.

Parse external decimal values before doing arithmetic

Suppose an API supplies:

{
  "price": "19.99",
  "tax_rate": "0.0825"
}

Representing decimal values as strings is unambiguous:

from decimal import Decimal

price = Decimal(payload["price"])
tax_rate = Decimal(payload["tax_rate"])

If an external system instead sends JSON numbers, the JSON parser may produce Python float values by default. Converting those floats afterward with Decimal(value) preserves the already-parsed binary value.

Python’s standard json module can parse JSON floating-point numbers directly as Decimal:

import json
from decimal import Decimal

payload = json.loads(
    '{"price": 19.99, "tax_rate": 0.0825}',
    parse_float=Decimal,
)

Now both values enter the application as decimals without first becoming binary floats.

This is a useful example of treating representation as an API-boundary decision rather than repairing it deep inside business logic.

Decide how Decimal crosses serialization boundaries

Python’s standard JSON encoder does not serialize Decimal objects automatically:

import json
from decimal import Decimal

json.dumps({"amount": Decimal("19.99")})

That raises TypeError with the default encoder.

Do not respond by converting the value to float unless the receiving contract explicitly accepts binary floating-point approximation.

Common boundary representations include:

  • a decimal string such as "19.99";
  • an integer minor-unit value such as 1999 cents;
  • a database decimal/numeric column;
  • a protocol type that explicitly supports decimal values.

The correct representation depends on the API contract. The important part is to choose it deliberately and document where conversion occurs.

Integer minor units can be simpler than Decimal

Decimal is not the only safe model for money.

If every amount in a subsystem uses one fixed scale, storing integer minor units can be simpler:

unit_price_cents = 1999
quantity = 3

subtotal_cents = unit_price_cents * quantity

The subtotal is exactly 5997 cents.

Integers are especially attractive when the domain is naturally discrete and all operations preserve that scale.

The model becomes less direct when calculations involve rates, prorating, exchange rates, interest, or currencies with different minor-unit rules. At that point you still need a defined higher-precision representation and a rounding policy.

A practical split is often:

  • use integer minor units for settled amounts with a fixed known scale;
  • use decimal arithmetic for calculations whose inputs and rules are decimal;
  • convert at an explicitly defined rounding boundary.

Do not choose integer cents merely because “money should never use floats.” Choose them when the fixed-scale integer model actually matches the domain.

Float is still the right tool for many problems

Decimal should not replace float everywhere.

Binary floating point is a natural choice when:

  • values come from physical measurements that are already approximate;
  • numerical libraries expect native floating-point arrays;
  • the algorithm is defined in terms of floating-point tolerances;
  • exact base-10 representation is not a domain requirement.

Scientific and machine-learning workloads, for example, generally depend on binary floating-point hardware and specialized numeric libraries. Replacing those values with Decimal would change the computational model and often prevent use of optimized vectorized operations.

The question is not which type is “better.” It is which representation matches the guarantees the application needs.

Common Decimal mistakes come from crossing representation boundaries

Three mistakes account for many surprising results.

Constructing from a float after parsing

Risky:

Decimal(19.99)

Preferred when the source value is decimal text:

Decimal("19.99")

The first preserves the exact value of the binary float. The second preserves the exact decimal value written in the string.

Rounding because the UI shows two places

A display requirement such as “show two digits after the decimal point” does not automatically mean every intermediate calculation should be rounded to two places.

Round where the business rule defines a monetary boundary.

Assuming Decimal means unlimited precision

Decimal represents decimal values and supplies controllable arithmetic, but operations still use a context with finite precision.

If a calculation can create many significant digits, choose and test an appropriate precision policy rather than relying on the default accidentally.

Test monetary rules with boundary cases

A test suite should verify the rounding policy, not merely a few ordinary totals.

Include cases around half-way values and values just above or below them. For a two-place boundary, examples such as these are useful:

1.004
1.005
1.006
-1.005

Also test the real aggregation rule:

  • per-item versus aggregate rounding;
  • discounts applied before or after tax;
  • zero and negative adjustments;
  • large quantities;
  • values near any application-defined limits.

These tests document the accounting algorithm. They also protect against future refactoring that keeps the code syntactically correct while moving a rounding boundary.

Keep one numeric policy inside each calculation

The maintainable approach is to establish a small set of rules for each domain calculation.

For example:

Input representation: decimal text
Internal arithmetic: Decimal
Intermediate rounding: none
Settlement scale: 0.01
Settlement rounding: ROUND_HALF_UP
Output representation: decimal string

That policy is more valuable than sprinkling Decimal() calls through the code.

It tells maintainers where approximation can enter, when rounding is permitted, and which representation crosses system boundaries.

If the business rule changes, there is also a clear place to update tests and implementation together.

Conclusion

Use Decimal when the problem itself is expressed in exact decimal values and explicit decimal rounding rules.

Construct values from strings or integers instead of routing decimal input through float. Keep decimal arithmetic consistent through the calculation. Preserve useful intermediate precision, then use quantize() with the rounding mode required by the domain at an explicit boundary.

For fixed-scale settled amounts, integer minor units may be simpler. For approximate measurements and numerical computing, float is usually the more natural model.

The reusable lesson is broader than Python: numeric correctness starts by choosing a representation whose guarantees match the rules of the domain.