skip to content

Why should a decimal.Decimal be built from a string rather than from a float?

level: juniorimportance: must knowfreq 62%

answer

  1. The error is already in the float
  2. The constructor copies, it does not clean
  3. Binary base two cannot hold one tenth
  4. Decimal(0.1) against Decimal('0.1')
  5. FloatOperation trap makes it raise

basics

~10 s

Decimal(0.1) converts the float exactly, so it stores 0.1000000000000000055511151231257827..., the binary approximation the float already was. Decimal("0.1") parses the decimal digits you actually wrote and stores exactly one tenth.

solid answer

~40 s

A binary float cannot represent one tenth; `0.1` is really 0.1000000000000000055511151231257827... The `decimal.Decimal` constructor is an *exact* conversion, so `Decimal(0.1)` faithfully copies that error into the Decimal instead of removing it, while `Decimal("0.1")` parses the decimal literal and stores exactly one tenth. So the rule is that a Decimal must be built from a string, an int, or another Decimal — never from a float — and the discipline has to reach back to where the value enters the process: read amounts out of JSON, a form field or a database driver as text or as integer minor units, not as floats. If you want the mistake to be loud rather than silent, enable the `FloatOperation` trap in the decimal context and constructing a Decimal from a float raises.

code

pycon · 9 lines
pycon
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> Decimal("0.1")
Decimal('0.1')
>>> sum([Decimal("0.1")] * 3) == Decimal("0.3")
True
>>> sum([0.1] * 3) == 0.3
False

go deeper

for a junior

Be ready to state the rule and the reason in one breath: build a Decimal from a string or an int, never from a float, because the float is already an approximation and the constructor copies it exactly rather than fixing it.

for a middle

Explain the mechanics: binary64 stores integers over powers of two, so one tenth is not representable, and Decimal stores a coefficient with a base-ten exponent. Show the fifty-five-digit output and know that the constructor ignores context precision.

for a senior

Show where you enforce this in a real codebase — parsing amounts as text at the process boundary, the FloatOperation trap turned on in tests, and a clear answer for a float that arrives from a system you do not control.

for a principal

Own the house rule for how money is represented across services: Decimal end to end against integer minor units, where conversion happens, and how the choice survives the JSON, database and message-queue boundaries in between.

### The float you started with is already wrong A Python `float` is an IEEE-754 binary64 value: a sign, an 11-bit exponent and a 53-bit significand, all in base two. Every value it can hold is some integer divided by a power of two. One tenth is not such a value — 1/10 in binary is an infinitely repeating expansion, exactly the way 1/3 repeats in decimal — so the literal `0.1` in your source is silently replaced by the nearest binary64 value, which written out in full is `0.1000000000000000055511151231257827021181583404541015625`. Nothing later in the program can recover the tenth you meant, because the tenth was never stored. ### The Decimal constructor is exact, and that is the trap `decimal.Decimal` is a base-ten type: it stores a sign, an integer coefficient and a decimal exponent, so one tenth is representable exactly as coefficient 1 and exponent -1. The catch is that the constructor never rounds and never guesses. Given a `str`, it parses the decimal digits you wrote. Given a `float`, it performs an exact base conversion of the binary value — and since that binary value is a dyadic rational, its exact decimal form is finite, which is why you get fifty-five digits of noise rather than a friendly `0.1`: ```pycon >>> from decimal import Decimal >>> Decimal(0.1) Decimal('0.1000000000000000055511151231257827021181583404541015625') >>> Decimal("0.1") Decimal('0.1') ``` People often read `Decimal(0.1)` as "convert this to a decimal type and clean it up". It does the opposite: it preserves, permanently and visibly, the error that the float already carried. Passing through a float on the way in defeats the entire point of using Decimal. ### Why it matters in practice Base ten is not merely prettier; it makes the sums people expect actually hold. Three tenths added as floats is not three tenths, while three tenths added as Decimals is: ```pycon >>> sum([0.1] * 3) == 0.3 False >>> sum([Decimal("0.1")] * 3) == Decimal("0.3") True ``` That is the property money, tax rates, invoice lines and any regulated arithmetic depend on. The usual production discipline is that a monetary value is never a float anywhere in the process: it arrives as a string (JSON text, an HTML form field, a CSV cell) or as an integer number of minor units, and is turned into a Decimal at the boundary. Watch the boundary carefully — a JSON parser will hand you a float for `1.15` unless you tell it not to, and a database driver may or may not hand back Decimals for a numeric column. ### Making the mistake loud The decimal module has a signal for exactly this. Turning on the `FloatOperation` trap in the current context makes building a Decimal from a float raise instead of quietly succeeding, which turns a class of silent data bugs into a stack trace during tests: ```python from decimal import Decimal, FloatOperation, localcontext with localcontext() as ctx: ctx.traps[FloatOperation] = True try: Decimal(0.1) except FloatOperation: print("refused to build a Decimal from a float") ``` ### What to do when a float is genuinely all you have Sometimes the float is upstream and unfixable. Then you must decide, explicitly, what the float was supposed to mean. If it came from human decimal data, round it back to the number of places that data has — `Decimal(str(x))` uses `repr`'s shortest round-tripping form, and `Decimal(x).quantize(Decimal("0.01"))` rounds under the context's rounding rule. If it came from a physical measurement, the extra digits are meaningless anyway. What you must not do is copy the float in exactly and then pretend the result is exact. ### Where floats sneak in The rule is easy to state and easy to violate, because the float usually appears before your code runs. `json.loads` produces a float for every JSON number with a point in it, so an amount is already damaged by the time you construct a Decimal from it; the fix is the parser hook, `json.loads(payload, parse_float=Decimal)`, which builds Decimals straight from the text the wire carried. A CSV reader hands you strings, which is safer, provided nobody helpfully calls `float()` on the column first. A database driver may return Decimals for a numeric column or floats for a double-precision one, so the column type upstream decides what you receive. Review these three boundaries and the problem largely disappears; review only the arithmetic and it will not. ### Two related details worth knowing First, the constructor is also exempt from the context's precision: `Decimal("1.23456789")` keeps all nine digits no matter what `prec` says, because only *operations* apply the context. If you want the context applied at construction time, use the context's own `create_decimal` method. Second, the constructor accepts `int`, `str`, `tuple` and `Decimal` — an int is always safe, which is why storing money as integer cents and dividing once at the edge is a legitimate alternative design to using Decimal end to end.

  • A third-party client hands you a float and you cannot change it. How do you turn it into a Decimal responsibly?
    Decide what the float was meant to represent and say so in code. If it came from human decimal data, go through text — Decimal(str(x)) uses repr's shortest round-tripping digits — or convert exactly and then quantize to the scale the domain actually has. Record the decision at the boundary rather than deep in business logic, and never let the exactly-converted value flow on as if it were an exact amount.
  • Does the current decimal context's precision apply when you call the Decimal constructor?
    No. The constructor is exact and ignores prec, so Decimal("1.23456789") keeps all nine digits even under a context whose precision is four. Only arithmetic operations round to the context precision. If you want construction itself to obey the context, call the context object's create_decimal method instead of the constructor.
  • Is storing money as an integer number of cents a valid alternative to using Decimal?
    Yes, and it is common. Integers are exact and fast, and the arithmetic is obvious. The cost is that scale lives in your head rather than in the value: you must remember which fields are minor units, handle currencies with a different number of decimal places, and hand-roll rounding at every division. Decimal keeps the scale and the rounding rule in the object.

Photocopying a smudged page at very high resolution does not remove the smudge; it records it in perfect detail. Decimal(0.1) is that photocopy, and Decimal("0.1") is retyping the page.

saying these in an interview costs you the question

  • Says Decimal(0.1) cleans up the float
  • Believes floats are fine for money if you round at the end
  • Cannot explain why binary cannot hold one tenth
  • Thinks Decimal and float differ only in precision digits
  • Parses incoming JSON amounts as floats then converts
  • Claims the constructor rounds to the context precision

context