Cents: money does not belong in float
0.1 + 0.2 == 0.30000000000000004. A binary float cannot represent most
decimal fractions, so every arithmetic step on money loses a sliver. Across a
million transactions the slivers add up, the books do not balance, and the bug
is invisible until an auditor finds it.
The fix is old and boring — use Decimal, or store integer minor units (cents)
— so this skill is not about the fix. It is about finding every place the
mistake was made, which by hand means reading every arithmetic line in the
codebase and asking "is this money?"
The trap that makes naive scanning useless
float is not wrong. It is wrong for money. A ratio, a coordinate, a
temperature, an interest rate, an ML weight all belong in float. A scanner that
flags every float drowns the real bugs in a scientific codebase and gets turned
off within a day.
So the question is never "is this a float". It is:
Is this a float that represents money?
cents answers it by pairing two signals: a float, and a money name next to it.
Same shape as the rest of this plugin — the construct is only a bug in context.
Step 1: scan
scripts/cents.py billing.py # one file
scripts/cents.py --all src/ # a tree
scripts/cents.py --json payments.py # for pipelines
AST-based, standard library only. It reports each place a float meets a money name, with the line, the name, and the fix.
billing.py:6 [arithmetic] (subtotal)
subtotal = subtotal + item.price * 0.1
-> float arithmetic on money -- rounding error compounds
billing.py:8 [rounding] (total)
return round(total, 2)
-> round() on money hides the float error rather than fixing it
It stays silent on legitimate floats: learning_rate = 0.001, latitude = 37.77, tax_rate = 0.08 (a rate is a ratio, not an amount). It also stays
silent on money already done right: Decimal, or integer cents.
Step 2: read each finding by kind
| Kind | What it means | Fix |
|---|---|---|
assignment |
a money variable holds a float | assign Decimal(...) or an int of cents |
arithmetic |
float math on money — the compounding case | move the whole calculation to Decimal |
float_call |
money parsed via float(...) |
parse with Decimal(str(x)) |
rounding |
round(money, 2) |
not a fix — it rounds a value that is still a float; the error already happened |
The rounding finding is the subtle one. round(total, 2) looks like the
fix and is the most common non-fix in billing code. It rounds one float to two
places, but every step before it already lost precision, and the next
calculation reintroduces it.
Step 3: choose the representation, then convert
Two correct options, and which to use is a real decision:
- Integer minor units (cents). Store
1999, not19.99. Fast, exact, and impossible to reintroduce a float. Best when the currency's smallest unit is fixed and you control the whole path. The catch: every display and every external interface must agree on the scale. Decimal. StoreDecimal("19.99"). Exact decimal arithmetic, handles mixed scales and division with an explicit rounding mode. Best when you divide (splitting a bill, applying a percentage) or interface with systems that speak decimal strings. The catch: never construct from a float —Decimal(19.99)is already wrong; useDecimal("19.99")orDecimal(str(x)).
references/money.md covers division and rounding modes, currency scale,
databases and serialization, and the interest/percentage cases the scanner
flags but cannot fix for you.
What it does and does not catch
- Catches: literal floats,
float()calls, and float arithmetic that meet a money name in Python source. - Does not catch: money whose variable is not named like money (a bare
x = 0.1feeding a total three lines later), floats arriving from a database column or JSON already typed as float, or money math inside a library you call. Static naming analysis has this ceiling; read the flagged functions in full rather than trusting a clean scan of one file.
A clean report means "no money-named float here," not "this program handles money correctly." For anything financial, read the calculation paths too.
Resources
scripts/cents.py— the scanner. Kinds, money/non-money name signals,--all,--json.references/money.md— Decimal vs integer cents in depth: division, rounding modes, currency scale, storage, serialization, and the percentage and interest cases.