TRENDING
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
Two opened leaves of the Verona palimpsest, with an older, darker layer of writing still showing through the newer script written over it
October 28, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July
A raft of long grey wooden logs lying in a green meadow, with a forked post standing in the middle
October 28, 2026
How to Build the Raft Consensus Algorithm in Python and Test It With Crashes, Partitions, and Planted Bugs
Open handwritten account book from the Brussels Confraternity of the Seven Sorrows (1499 to 1516), with entries running down each page and blank space below them
October 28, 2026
Anthropic’s S-1 Puts $52.9 Billion More on Three Named Compute Commitments Than Its October 9 Draft Did, and Still Leaves Its Contractual-Obligations Table Blank
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Two earthworms lying together on wet brown leaf litter, photographed close up
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 268 Posts
News 271 Posts
Learning Hub 241 Posts
Home/Learning Hub/How to Handle Money in Python With Decimal and Integer Minor Units Without Losing a Cent in Rounding, SQLite, or JSON
Learning Hub

How to Handle Money in Python With Decimal and Integer Minor Units Without Losing a Cent in Rounding, SQLite, or JSON

Learn to store, round, split and save money in Python without losing a cent: Decimal done right, a tested Money type built on whole minor units, and the SQLite and JSON traps that undo both.

October 25, 2026 48 Min Read
27

Money is the one kind of number where being off by a single cent is still being wrong. A customer sees it on a receipt, an accountant sees it when two reports will not agree, and a ledger that has to balance cannot tolerate it at all. The trouble is that the most natural way to write an amount in Python, a float such as 19.99, cannot hold that number exactly. The habits built around floats (round it, add it up, store it in a column, send it as a JSON number) each add another chance to lose or invent a cent.

Table Of Content

  • What you will build, and the words you need first
  • Prerequisites
  • Create the project
  • Step 1: See exactly how floats break money
  • 1.1 Why 0.1 is not 0.1
  • 1.2 A million additions drift, and sum() hides it
  • 1.3 Rounding ties: half of them go the wrong way
  • Step 2: Use Decimal the right way
  • 2.1 Build Decimals from strings
  • 2.2 Never build a Decimal from a float
  • 2.3 Trailing zeros and normalize()
  • 2.4 The context: precision and silent rounding
  • Step 3: Choose a rounding rule on purpose
  • 3.1 The modes side by side
  • 3.2 The formatting trap
  • 3.3 Why the choice moves real money
  • Step 4: Build a Money type from whole minor units
  • 4.1 Why integers plus a currency
  • 4.2 The data and the parsing
  • The settings at the top
  • The class and its guard
  • The parsing method, in order
  • 4.3 Arithmetic, comparison and multiplication
  • 4.4 Try it, and try to break it
  • Step 5: Split an amount without losing a cent
  • 5.1 How allocate works
  • 5.2 Run it
  • Step 6: Tax on an invoice, rounded per line or on the total?
  • 6.1 What the numbers say
  • 6.2 Pick one, write it down, test it
  • Step 7: Keep money exact in SQLite
  • 7.1 A DECIMAL column that is not decimal
  • 7.2 Rounding in SQL and in Python disagree
  • 7.3 A STRICT table of integers
  • What the table accepted and refused
  • The 20.0 trap
  • Step 8: Move money through JSON without floats
  • 8.1 What the output shows
  • 8.2 Large integers and JavaScript
  • Step 9: Check how many decimals your currencies really have
  • 9.1 The assumption that breaks
  • 9.2 The table is a decision, not a fact
  • Step 10: Lock it in with tests, and prove they can fail
  • 10.1 Plant bugs to see whether the tests notice
  • Common mistakes and gotchas
  • A float sneaks in at the boundary
  • Dividing before rounding
  • Formatting as rounding
  • Assuming every currency has two decimals
  • Rounding too early or in too many places
  • Amounts below one minor unit
  • Confirm the whole thing works end to end
  • Where this stops working, and what to learn next
  • Sources and method

In this tutorial you will learn to handle money in Python so that every cent is accounted for. You will watch floats fail in measurable ways, learn how the standard decimal module works and where it still lets you down, and choose a rounding rule on purpose instead of by accident. Then you will build a small Money type of about 100 lines that stores whole minor units, refuses floats and refuses to mix currencies. After that you will split an amount without losing a cent, keep amounts exact in SQLite and in JSON, and finish with 29 tests plus a script that plants nine bugs to prove the tests notice them. Everything uses the standard library, plus pytest for the tests.

One scope note before you start. This tutorial is about representing, rounding, splitting and storing amounts. It does not cover currency conversion, tax law or any payment provider’s API. The tax example uses an 8.875 percent rate because it produces rounding questions, not because it is the right rate for anyone. Which rounding rule your invoices must follow is a question for your jurisdiction, your contracts and your accountant. The job of the code is to apply the rule you choose in exactly one way, everywhere.

What you will build, and the words you need first

By the end you will have these files in one folder:

  • money.py: the Money type, with parsing, arithmetic, explicit rounding and allocation;
  • invoice.py: two ways of rounding tax on an invoice, side by side;
  • ledger.py: a SQLite table that stores money as integers, and a total per currency;
  • wire.py: money in and out of JSON without a float in sight;
  • nine step scripts that print every number quoted below, test_money.py with 29 tests, and mutations.py, which plants nine bugs.

A few terms, defined before they are used. A float is Python’s ordinary number with a decimal point. Under the hood it is a binary fraction: the Python documentation says that almost all platforms map Python floats to IEEE 754 binary64 “double precision” values. A Decimal is the type from the standard decimal module. It stores a number in base ten, the way people write it, so Decimal("0.10") really is one tenth. A minor unit is the smallest unit a currency is normally counted in: cents for dollars and euros, so 1999 minor units of USD means 19.99 dollars. The number of digits after the decimal point differs by currency, and this tutorial calls it the currency’s exponent: 2 for USD, 0 for JPY and 3 for KWD. A rounding mode is the rule for what to do with digits that do not fit, and a tie is a value exactly halfway between two candidates, such as 2.665 between 2.66 and 2.67. Allocation means splitting a total into parts that add back up to the total.

Prerequisites

  • Python 3.12 or newer. Every command below ran on Python 3.13.14 on Windows 11, and a second pass on Python 3.15.1 printed identical results apart from the version line. Version 3.12 matters for one detail: the sum() results in Step 1 depend on how that release adds floats.
  • SQLite 3.37.0 or newer for STRICT tables in Step 7. Python ships its own copy of SQLite, and the command below prints which one you have (3.50.4 here on Python 3.13.14 and 3.53.4 on Python 3.15.1).
  • pytest 9.1.1 for the tests. No other package is needed.
  • Network access once, in Step 9, where a script downloads a currency list and saves it next to itself.
  • Background: you can read a Python class and run a script. You need no accounting knowledge, because each idea is explained where it first appears.

Create the project

Start with an empty folder and a virtual environment (a private set of installed packages for this project), then print your versions:

mkdir money-lab
cd money-lab
python -m venv .venv
source .venv/bin/activate
python -m pip install pytest==9.1.1
python -c "import sys, sqlite3; print(sys.version.split()[0], sqlite3.sqlite_version)"

On Windows in PowerShell, the activation line is .venv\Scripts\Activate.ps1. All the files below go in this one folder. The first line of every code block is a comment with the file name, and a block whose first line says “(continued)” belongs at the end of the file that was started earlier.

Check: the last command prints two version numbers, for example 3.13.14 3.50.4. If the second one is below 3.37.0, upgrade Python before Step 7, or the STRICT table there will fail.

Step 1: See exactly how floats break money

Before fixing anything, watch the problem happen, because every later design choice comes from it. Create step01_float_trouble.py. It prints a few classic surprises, adds one million payments of ten cents, and then checks how well round() and string formatting treat prices that sit exactly halfway between two cents:

# step01_float_trouble.py
"""step01_float_trouble.py: what binary floating point does to money."""
import math
from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal

print("0.1 + 0.2             =", 0.1 + 0.2)
print("0.1 + 0.2 == 0.3      =", 0.1 + 0.2 == 0.3)
print("0.1 + 0.1 + 0.1 - 0.3 =", 0.1 + 0.1 + 0.1 - 0.3)
print("Decimal(0.1)          =", Decimal(0.1))
print("Decimal(2.675)        =", Decimal(2.675))
print("round(2.675, 2)       =", round(2.675, 2))

total = 0.0
for _ in range(1_000_000):
    total += 0.10
print()
print("one million payments of 0.10")
print("  added in a loop :", repr(total))
print("  sum()           :", repr(sum([0.10] * 1_000_000)))
print("  math.fsum()     :", repr(math.fsum([0.10] * 1_000_000)))
print("  Decimal         :", sum([Decimal("0.10")] * 1_000_000, Decimal(0)))

# 1,000 prices that sit exactly halfway between two cents: 0.005, 0.015, ... 9.995
CENT = Decimal("0.01")
ties = [Decimal(n) / 1000 for n in range(5, 10_000, 10)]
half_up = [t.quantize(CENT, rounding=ROUND_HALF_UP) for t in ties]
half_even = [t.quantize(CENT, rounding=ROUND_HALF_EVEN) for t in ties]
by_round = [Decimal(str(round(float(t), 2))) for t in ties]
by_format = [Decimal(f"{float(t):.2f}") for t in ties]


def agree(a, b):
    return sum(x == y for x, y in zip(a, b))


exact = [i for i, t in enumerate(ties) if Decimal(float(t)) == t]
inexact = [i for i, t in enumerate(ties) if Decimal(float(t)) != t]
print()
print(f"{len(ties)} prices exactly halfway between two cents")
print("  round(float, 2) gives the half-up answer on  ", agree(by_round, half_up))
print("  round(float, 2) gives the half-even answer on", agree(by_round, half_even))
print("  f'{float:.2f}' gives the half-up answer on   ", agree(by_format, half_up))
print(f"  {len(exact)} of the prices are exact binary fractions (0.125, 0.375 and so on)")
up = sum(by_round[i] == half_up[i] for i in inexact)
print(f"  the other {len(inexact)} are stored a hair off the tie: {up} round up, {len(inexact) - up} round down")
misses = [(t, r) for t, r, h in zip(ties, by_round, half_up) if r != h]
print("  first misses:", ", ".join(f"{t} -> {r}" for t, r in misses[:4]))

Run python step01_float_trouble.py. You should see this:

0.1 + 0.2             = 0.30000000000000004
0.1 + 0.2 == 0.3      = False
0.1 + 0.1 + 0.1 - 0.3 = 5.551115123125783e-17
Decimal(0.1)          = 0.1000000000000000055511151231257827021181583404541015625
Decimal(2.675)        = 2.67499999999999982236431605997495353221893310546875
round(2.675, 2)       = 2.67

one million payments of 0.10
  added in a loop : 100000.00000133288
  sum()           : 100000.0
  math.fsum()     : 100000.0
  Decimal         : 100000.00

1000 prices exactly halfway between two cents
  round(float, 2) gives the half-up answer on   500
  round(float, 2) gives the half-even answer on 520
  f'{float:.2f}' gives the half-up answer on    500
  40 of the prices are exact binary fractions (0.125, 0.375 and so on)
  the other 960 are stored a hair off the tie: 480 round up, 480 round down
  first misses: 0.015 -> 0.01, 0.045 -> 0.04, 0.075 -> 0.07, 0.105 -> 0.1

1.1 Why 0.1 is not 0.1

A float is stored as a binary fraction, a sum of halves, quarters, eighths and so on. Many everyday decimal fractions, 0.1 among them, have no finite binary form, so the machine stores the nearest one it can. The Python tutorial puts it this way: in general, “the decimal floating-point numbers you enter are only approximated by the binary floating-point numbers actually stored in the machine”. The line Decimal(0.1) shows what is really stored: 0.1000000000000000055511151231257827021181583404541015625, a number that is slightly too big. Small errors like this pile up, so 0.1 + 0.2 prints 0.30000000000000004 and the comparison with 0.3 is False.

The decimal documentation spells out the consequence. In decimal floating point, “0.1 + 0.1 + 0.1 - 0.3 is exactly equal to zero”, while in binary floating point the result is a tiny number that is not zero. Those differences “prevent reliable equality testing and differences can accumulate”, and “for this reason, decimal is preferred in accounting applications which have strict equality invariants”. The last of the first three output lines, 5.551115123125783e-17, is that tiny leftover.

1.2 A million additions drift, and sum() hides it

The loop adds ten cents one million times and ends at 100000.00000133288, not 100000. That is only about a millionth of a unit off, but it is the kind of error that makes total == expected fail in a test or an audit. Notice that sum() and math.fsum() both print a clean 100000.0. Python 3.12 changed how sum() adds floats, and the documentation says that “summation of floats switched to an algorithm that gives higher accuracy and better commutativity on most builds”. That helps casual scripts and misleads your intuition: a quick test using sum() passes, while a running balance that is updated one payment at a time still drifts. Do not read the tidy 100000.0 as proof that floats are fine. It is the best float for that one sum, and the next calculation starts from a float again. The last line of the group shows the alternative: with Decimal the total is exactly 100000.00.

1.3 Rounding ties: half of them go the wrong way

The second experiment is the one that moves real money. A shopper would usually expect a tie such as 0.015 to round up to 0.02. The script builds 1,000 ties (0.005, 0.015 and so on up to 9.995) as exact Decimals, works out the half-up answer for each, and counts how often round(float, 2) and f"{x:.2f}" give that answer. Each matches it on only 500 of the 1,000 prices. The Python documentation warns about this: “round(2.675, 2) gives 2.67 instead of the expected 2.68. This is not a bug: it’s a result of the fact that most decimal fractions can’t be represented exactly as a float.”

The last lines explain why the count is 500 and not something tidier. Of the 1,000 prices, 40 are exact binary fractions (0.125, 0.375 and so on), where the float is the tie itself and round() has to apply a real rule for ties. The other 960 are stored a hair above or a hair below the tie: 480 sit above and round up, 480 sit below and round down. For those 960, round() applies no rule about ties at all. It follows an error that nobody can see in the number.

Check: your numbers should match the output above. The prices come from exact Decimals, so the 500, 520, 40 and 960 are exact on any machine, and the loop total 100000.00000133288 should match on any machine with standard IEEE 754 doubles.

Common mistake: believing that formatting fixes rounding. f"{x:.2f}" gave the half-up answer on the same 500 prices as round(). Formatting a float only chooses how many digits to print; the damage was done when the float was created.

Step 2: Use Decimal the right way

The decimal module is the right tool for the edges of a money system: parsing text, holding a tax rate, showing an exact amount. It is also easy to misuse, so this step shows the good habits and the three ways it can still betray you. Create step02_decimal_basics.py:

# step02_decimal_basics.py
"""step02_decimal_basics.py: the decimal module, the float trap and context precision."""
from decimal import Decimal, FloatOperation, Inexact, getcontext, localcontext

print("Decimal('0.1') + Decimal('0.2')     =", Decimal("0.1") + Decimal("0.2"))
print("Decimal('0.1') * 3 - Decimal('0.3') =", Decimal("0.1") * 3 - Decimal("0.3"))
print("sum of ten Decimal('0.1')           =", sum([Decimal("0.1")] * 10, Decimal(0)))

print()
print("Decimal(0.1)      =", Decimal(0.1))
print("Decimal(str(0.1)) =", Decimal(str(0.1)))
try:
    Decimal("19.99") + 0.01
except TypeError as error:
    print("TypeError:", error)

print()
with localcontext() as context:
    context.traps[FloatOperation] = True
    try:
        Decimal(0.1)
    except FloatOperation as error:
        print("FloatOperation:", error)

print()
print("Decimal('1.10') == Decimal('1.1')  :", Decimal("1.10") == Decimal("1.1"))
print("str of each                        :", Decimal("1.10"), Decimal("1.1"))
print("Decimal('100').normalize()         :", Decimal("100").normalize())

print()
context = getcontext()
print("default precision and rounding     :", context.prec, context.rounding)
big = Decimal("123456789012345678901234567.89")
print("constructed exactly                :", big)
print("after big + 0                      :", big + 0)
print("Inexact flag raised by that sum    :", bool(context.flags[Inexact]))
with localcontext() as context:
    context.traps[Inexact] = True
    try:
        big + 0
    except Inexact as error:
        print("with Inexact trapped               :", type(error).__name__)

Run it. You should see this:

Decimal('0.1') + Decimal('0.2')     = 0.3
Decimal('0.1') * 3 - Decimal('0.3') = 0.0
sum of ten Decimal('0.1')           = 1.0

Decimal(0.1)      = 0.1000000000000000055511151231257827021181583404541015625
Decimal(str(0.1)) = 0.1
TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

FloatOperation: [<class 'decimal.FloatOperation'>]

Decimal('1.10') == Decimal('1.1')  : True
str of each                        : 1.10 1.1
Decimal('100').normalize()         : 1E+2

default precision and rounding     : 28 ROUND_HALF_EVEN
constructed exactly                : 123456789012345678901234567.89
after big + 0                      : 123456789012345678901234567.9
Inexact flag raised by that sum    : True
with Inexact trapped               : Inexact

2.1 Build Decimals from strings

The first three lines are the good news. Decimal("0.1") + Decimal("0.2") is exactly 0.3, 0.1 * 3 - 0.3 is exactly zero, and ten tenths add up to exactly 1.0. A Decimal keeps the digits you typed, in base ten, so there is nothing to approximate.

2.2 Never build a Decimal from a float

The next group is the trap. Decimal(0.1) does not give you one tenth. It gives you the exact value of the float, every digit of the error. The constructor is faithful, and what it faithfully copies is the damage. Passing the float through str() first gives 0.1, which looks like a fix, but it only works because Python prints the shortest text that reads back as the same float. A float that came out of a calculation already carries its error, and str() just prints it.

Mixing the two types in arithmetic raises TypeError, and that is what you want: the language refuses to guess. Comparisons are more forgiving, and the decimal documentation shows Decimal("3.5") == 3.5 returning True. To catch accidents there as well, you can trap the FloatOperation signal. The documentation says that if it is trapped, “accidental mixing of decimals and floats in constructors or ordering comparisons raises an exception”. The script turns the trap on inside a temporary context (localcontext()), and Decimal(0.1) then raises. Step 4 builds this protection into the Money type.

2.3 Trailing zeros and normalize()

Decimal("1.10") == Decimal("1.1") is True, yet the two print differently, because a Decimal remembers how many digits you gave it. That is a feature for money, since 19.50 reads better than 19.5. It is also why normalize() is not a tidying function: Decimal("100").normalize() returns 1E+2, the same value written in scientific notation, which is not what you want on a receipt.

2.4 The context: precision and silent rounding

Every Decimal operation runs inside a context, a settings object that holds the precision, the default rounding mode and a set of signals. The defaults printed above are 28 digits and ROUND_HALF_EVEN. Creating a Decimal is always exact, whatever the precision. Arithmetic is different, because its result is rounded to the context’s precision. The 29-digit amount in the script survives its construction, but after big + 0 its last digit is gone and 567.89 has become 567.9, with no error. All that happened is that a flag called Inexact was raised on the context. The documentation describes the mechanism: “For each signal there is a flag and a trap enabler. When a signal is encountered, its flag is set to one, then, if the trap enabler is set to one, an exception is raised. Flags are sticky, so the user needs to reset them before monitoring a calculation.”

Nobody reads a sticky flag in the middle of a checkout, which is why the last two output lines matter. With the Inexact trap switched on, the same sum raises an exception instead. Twenty-eight digits are plenty for shop prices, but money code should not depend on that. In Step 4 you will build a context that raises on every silent rounding.

Check: the output above ends with True for the flag and then Inexact for the trapped sum. If you see False, an earlier statement in your session reset the flag; run the script from a fresh interpreter.

Step 3: Choose a rounding rule on purpose

Sooner or later a calculation produces more digits than a currency has: 8.875 percent of 99 cents is 8.78625 cents. Something must decide whether that becomes 8 or 9 cents, and in Python the decision is made by a rounding mode. If you do not name one, a default makes the choice for you. Create step03_rounding.py:

# step03_rounding.py
"""step03_rounding.py: rounding modes, the format() trap and the bias of always rounding halves up."""
import random
from decimal import (
    ROUND_CEILING,
    ROUND_DOWN,
    ROUND_FLOOR,
    ROUND_HALF_DOWN,
    ROUND_HALF_EVEN,
    ROUND_HALF_UP,
    ROUND_UP,
    Decimal,
    getcontext,
)

CENT = Decimal("0.01")
MODES = [
    ("HALF_UP", ROUND_HALF_UP),
    ("HALF_EVEN", ROUND_HALF_EVEN),
    ("HALF_DOWN", ROUND_HALF_DOWN),
    ("DOWN", ROUND_DOWN),
    ("UP", ROUND_UP),
    ("CEILING", ROUND_CEILING),
    ("FLOOR", ROUND_FLOOR),
]

print(f"{'value':>8}  " + "  ".join(f"{name:>9}" for name, _ in MODES))
for text in ("0.125", "0.135", "2.665", "-2.665", "0.1251"):
    cells = (f"{Decimal(text).quantize(CENT, rounding=mode):>9}" for _, mode in MODES)
    print(f"{text:>8}  " + "  ".join(cells))

print()
print("context rounding:", getcontext().rounding)
for text in ("0.125", "2.665", "2.675"):
    d = Decimal(text)
    print(f"{text}: quantize HALF_UP gives {d.quantize(CENT, rounding=ROUND_HALF_UP)}, format '.2f' gives {format(d, '.2f')}")

# 100,000 line prices from 0.01 to 99.99, taxed at 7.5 percent, rounded one line at a time
rng = random.Random(7)
prices = [rng.randint(1, 9999) for _ in range(100_000)]  # minor units
rate = Decimal("0.075")
exact = sum(Decimal(p) * rate for p in prices)  # tax in cents, never rounded
ties = sum(1 for p in prices if (Decimal(p) * rate) % 1 == Decimal("0.5"))
print()
print("lines:", len(prices), "| lines whose tax is exactly half a cent:", ties)
print("exact tax in cents:", exact)
for name, mode in (("HALF_UP", ROUND_HALF_UP), ("HALF_EVEN", ROUND_HALF_EVEN)):
    collected = sum((Decimal(p) * rate).to_integral_value(rounding=mode) for p in prices)
    print(f"{name:9}: collected {collected} cents, off by {collected - exact} cents")

Run it. You should see this:

   value    HALF_UP  HALF_EVEN  HALF_DOWN       DOWN         UP    CEILING      FLOOR
   0.125       0.13       0.12       0.12       0.12       0.13       0.13       0.12
   0.135       0.14       0.14       0.13       0.13       0.14       0.14       0.13
   2.665       2.67       2.66       2.66       2.66       2.67       2.67       2.66
  -2.665      -2.67      -2.66      -2.66      -2.66      -2.67      -2.66      -2.67
  0.1251       0.13       0.13       0.13       0.12       0.13       0.13       0.12

context rounding: ROUND_HALF_EVEN
0.125: quantize HALF_UP gives 0.13, format '.2f' gives 0.12
2.665: quantize HALF_UP gives 2.67, format '.2f' gives 2.66
2.675: quantize HALF_UP gives 2.68, format '.2f' gives 2.68

lines: 100000 | lines whose tax is exactly half a cent: 2503
exact tax in cents: 37540477.050
HALF_UP  : collected 37541746 cents, off by 1268.950 cents
HALF_EVEN: collected 37540510 cents, off by 32.950 cents

3.1 The modes side by side

The first table rounds five values to cents with seven modes, using quantize(CENT, rounding=...), where CENT is Decimal("0.01"). The method rounds a Decimal to the same number of decimal places as the argument you pass in. Three of the modes are about ties. The documentation defines ROUND_HALF_UP as “Round to nearest with ties going away from zero”, ROUND_HALF_EVEN as “Round to nearest with ties going to nearest even integer”, and ROUND_HALF_DOWN as “Round to nearest with ties going towards zero”.

Read the rows to see what that means. For the tie 0.125, half-up gives 0.13 and half-even gives 0.12, because 2 is even. For 0.135 half-up and half-even agree on 0.14, because 4 is even, so half-even is not simply “round down”. In the row for -2.665 half-up gives -2.67: “away from zero” means the number gets bigger in size, not larger in value. The last row, 0.1251, is not a tie, so all three half-modes agree. The other four modes ignore how close the value is. DOWN cuts toward zero, UP moves away from zero, CEILING moves toward positive infinity and FLOOR toward negative infinity.

3.2 The formatting trap

The next three output lines compare quantize with format(d, ".2f") on Decimals. For 0.125 and 2.665 the two disagree by a cent: quantize with half-up gives 0.13 and 2.67, while format gives 0.12 and 2.66. In these runs format followed the context’s default mode, ROUND_HALF_EVEN, which is not the rule most price lists use. A receipt printed with format and a total computed with quantize can therefore disagree. The habit that avoids it is to round once, with an explicit mode, and format only an amount that is already exact. That is how the Money type will work.

3.3 Why the choice moves real money

The last part of the script measures what a mode costs. It makes 100,000 random line prices between 0.01 and 99.99, applies 7.5 percent tax to each line, and rounds line by line. For 2,503 of those lines the tax is exactly half a cent. Always rounding those halves up collected 1,268.95 cents more than the exact tax, about 12.69 in the currency’s main unit. Half-even was off by 32.95 cents, about 0.33. The exact tax was 37,540,477.05 cents.

Do not read that as “always use half-even”. Many invoices and tax rules specify rounding half up, and the right answer comes from the rule that applies to you. Read it as: the choice moves real money, so it must be a visible decision with a name. The name “bankers’ rounding” for half-even is shakier than it sounds. Wikipedia’s article on rounding says: “If this rounding method was ever a standard in banking, the evidence has proved extremely difficult to find.” It also notes that half-even “is the default rounding mode used in IEEE 754 operations for results in binary floating-point formats”, Being the default in a standard or a library is not the same as being the rule on your price list.

Check: the two tax totals and the 2,503 count come from a seeded random generator, so they match exactly on every machine. Common mistake: leaving rounding to the context default. The default is ROUND_HALF_EVEN, and Step 4 makes every call site name its mode.

Step 4: Build a Money type from whole minor units

4.1 Why integers plus a currency

Martin Fowler’s Money pattern starts from two observations. Once you involve several currencies, “you want to avoid adding your dollars to your yen without taking the currency differences into account”. And rounding is the subtler problem: “When you do this it’s easy to lose pennies (or your local equivalent) because of rounding errors.” His answer is a Money class that handles both. Stripe’s payment API uses the same idea. Its documentation says that “All API requests expect amount values in the currency’s minor unit”, and gives the examples of 1000 to charge 10 USD and 10 to charge 10 JPY.

The type you are about to build stores exactly that: one integer number of minor units and one currency code. It can never hold half a cent, so every value is a real amount. Equality is plain integer equality, sums are exact, and the number maps straight onto an INTEGER column and onto the amounts that an API such as Stripe’s expects. Decimal still has a job, at the edges: parsing text, holding rates such as 0.08875, and showing the exact amount.

4.2 The data and the parsing

Create money.py with the first half of the class:

# money.py
"""money.py: money as whole minor units (cents) plus a currency code, with explicit rounding."""
from dataclasses import dataclass
from decimal import (
    ROUND_HALF_UP,
    Context,
    Decimal,
    DivisionByZero,
    FloatOperation,
    Inexact,
    InvalidOperation,
    Overflow,
)
from functools import total_ordering

# Digits after the decimal point, from ISO 4217 list one. Add the currencies you sell in.
EXPONENTS = {"USD": 2, "EUR": 2, "GBP": 2, "JPY": 0, "KWD": 3}

# Decimal arithmetic in this module runs in a context that never rounds silently
# and never accepts a float: those signals raise an exception instead of setting a flag.
STRICT = Context(
    prec=50,
    traps=[InvalidOperation, DivisionByZero, Overflow, Inexact, FloatOperation],
)


@total_ordering
@dataclass(frozen=True)
class Money:
    """An amount of money: a whole number of minor units (cents) and a currency code."""

    minor: int
    currency: str

    def __post_init__(self):
        if type(self.minor) is not int:
            raise TypeError(f"minor units must be an int, not {type(self.minor).__name__}")
        if self.currency not in EXPONENTS:
            raise ValueError(f"unknown currency {self.currency!r}")

    @classmethod
    def zero(cls, currency):
        return cls(0, currency)

    @classmethod
    def of(cls, amount, currency, rounding=None):
        """Build from "19.99", 19 or Decimal("19.99"). Floats are refused.

        Extra digits raise ValueError unless you pass a rounding mode.
        """
        if isinstance(amount, (float, bool)):
            raise TypeError(f"amount must be str, int or Decimal, not {type(amount).__name__}")
        if currency not in EXPONENTS:
            raise ValueError(f"unknown currency {currency!r}")
        try:
            value = Decimal(amount)
        except InvalidOperation:
            raise ValueError(f"not a number: {amount!r}") from None
        if not value.is_finite():
            raise ValueError(f"amount must be finite, got {amount!r}")
        scaled = value.scaleb(EXPONENTS[currency], context=STRICT)  # 19.99 -> 1999 for USD
        whole = scaled.to_integral_value(rounding=rounding or ROUND_HALF_UP)
        if whole != scaled and rounding is None:
            raise ValueError(
                f"{amount} has more than {EXPONENTS[currency]} decimal places for {currency}; pass rounding="
            )
        return cls(int(whole), currency)

    @property
    def amount(self):
        """The exact value as a Decimal, for example Decimal("19.99")."""
        return Decimal(self.minor).scaleb(-EXPONENTS[self.currency], context=STRICT)

The settings at the top

EXPONENTS lists the five currencies the lab supports and how many digits each has after the point. A currency that is not in the table is refused, so a typo like "USDD" cannot slip through. Step 9 explains how to choose the numbers for more currencies.

STRICT is a decimal context with 50 digits of precision, far more than any amount here needs, and five traps turned on. A trap makes a signal raise an exception instead of setting a quiet flag. The important ones are Inexact (a result had to be rounded), FloatOperation (a float met a Decimal) and InvalidOperation (for example, a malformed number). The decimal FAQ recommends the same technique for validation: quantize() rounds, and “if the Inexact trap is set, it is also useful for validation”.

The class and its guard

@dataclass(frozen=True) makes a value that cannot be changed after creation, which is what you want for money that gets passed around. @total_ordering fills in >, <= and >= from one comparison method defined later. __post_init__ runs after every construction and checks both fields. It uses type(self.minor) is not int rather than isinstance, because bool is a subclass of int, and isinstance(True, int) is True: a stray True would otherwise become one cent.

The parsing method, in order

Money.of("19.99", "USD") is the front door for text. It does seven things, in this order. It refuses floats and bools. It refuses an unknown currency. It turns the text into a Decimal and converts a parse failure into a readable ValueError. It refuses values that are not finite: Decimal("NaN") and Decimal("Infinity") are valid Decimal text, and without this line NaN would be blamed on too many decimal places while Infinity would end in an OverflowError from int(), both confusing. It shifts the decimal point right by the currency’s exponent with scaleb, which the documentation describes as returning “the first operand multiplied by 10**other”, so 19.99 becomes 1999 for USD and 19990 for KWD, with no rounding. It rounds that number to a whole number of minor units with to_integral_value. And finally, if rounding changed the value and the caller did not pass a mode, it raises. That last rule is the difference between validating and rounding: "19.999" is not a valid USD amount, and the code says so instead of quietly turning it into 20.00.

The amount property goes the other way. It returns the exact Decimal, for example Decimal("19.99"), by shifting the point back left, again without rounding.

4.3 Arithmetic, comparison and multiplication

Now add the second half of the class to money.py:

    # money.py (continued)
    def _check(self, other):
        if not isinstance(other, Money):
            raise TypeError(f"expected Money, not {type(other).__name__}")
        if other.currency != self.currency:
            raise ValueError(f"cannot combine {self.currency} and {other.currency}")

    def __add__(self, other):
        self._check(other)
        return Money(self.minor + other.minor, self.currency)

    def __sub__(self, other):
        self._check(other)
        return Money(self.minor - other.minor, self.currency)

    def __neg__(self):
        return Money(-self.minor, self.currency)

    def __lt__(self, other):
        self._check(other)
        return self.minor < other.minor

    def __str__(self):
        return f"{self.amount:,} {self.currency}"

    def times(self, factor, *, rounding):
        """Multiply by a rate or a quantity and round to whole minor units with the mode you name."""
        if isinstance(factor, (float, bool)):
            raise TypeError(f"factor must be str, int or Decimal, not {type(factor).__name__}")
        exact = STRICT.multiply(Decimal(self.minor), Decimal(factor))
        return Money(int(exact.to_integral_value(rounding=rounding)), self.currency)

_check is called by every operation that takes two amounts. It refuses anything that is not Money and anything in a different currency, so usd + eur and usd < eur both raise instead of comparing a number of dollars with a number of euros. Addition, subtraction and negation are integer arithmetic, so they are exact. __str__ prints the exact amount with thousands separators. Because amount is already exact, no rounding can happen while formatting.

times multiplies by a rate or a quantity, and it is where rounding enters. Its rounding parameter is keyword-only and has no default, so every call site has to name the mode, and a missing one is a TypeError. The multiplication runs in the STRICT context, so the product is exact or it raises, and then to_integral_value(rounding=rounding) rounds once, at the end, to whole minor units. The decimal FAQ explains why this follow-up step is needed: operations “like division and non-integer multiplication, will change the number of decimal places and need to be followed-up with a quantize() step”.

4.4 Try it, and try to break it

Create step04_money_type.py. The first half uses the type normally. The second half tries ten things that should fail:

# step04_money_type.py
"""step04_money_type.py: what the Money type accepts and what it refuses."""
from decimal import ROUND_HALF_UP

from money import Money

price = Money.of("19.99", "USD")
print(price, "| minor units:", price.minor, "| exact amount:", repr(price.amount))
print(Money.of("500", "JPY"), "|", Money.of("1.234", "KWD"), "|", Money.of("1234567.5", "EUR"))
print("19.99 + 5.01 =", price + Money.of("5.01", "USD"))
print("sum of 1,000 x 0.10 =", sum([Money.of("0.10", "USD")] * 1000, Money.zero("USD")))
print("rounded on purpose  =", Money.of("19.999", "USD", rounding=ROUND_HALF_UP))
print()

attempts = {
    "Money.of(19.99, 'USD')": lambda: Money.of(19.99, "USD"),
    "Money.of('19.999', 'USD')": lambda: Money.of("19.999", "USD"),
    "Money.of('abc', 'USD')": lambda: Money.of("abc", "USD"),
    "Money.of('NaN', 'USD')": lambda: Money.of("NaN", "USD"),
    "Money.of('Infinity', 'USD')": lambda: Money.of("Infinity", "USD"),
    "Money.of('1', 'XYZ')": lambda: Money.of("1", "XYZ"),
    "Money(19.99, 'USD')": lambda: Money(19.99, "USD"),
    "price + Money.of('5', 'EUR')": lambda: price + Money.of("5", "EUR"),
    "price + 5": lambda: price + 5,
    "price.times('0.5')": lambda: price.times("0.5"),
}
for label, attempt in attempts.items():
    try:
        attempt()
        print(f"{label:30} -> accepted")
    except Exception as error:
        print(f"{label:30} -> {type(error).__name__}: {error}")

Run it. You should see this:

19.99 USD | minor units: 1999 | exact amount: Decimal('19.99')
500 JPY | 1.234 KWD | 1,234,567.50 EUR
19.99 + 5.01 = 25.00 USD
sum of 1,000 x 0.10 = 100.00 USD
rounded on purpose  = 20.00 USD

Money.of(19.99, 'USD')         -> TypeError: amount must be str, int or Decimal, not float
Money.of('19.999', 'USD')      -> ValueError: 19.999 has more than 2 decimal places for USD; pass rounding=
Money.of('abc', 'USD')         -> ValueError: not a number: 'abc'
Money.of('NaN', 'USD')         -> ValueError: amount must be finite, got 'NaN'
Money.of('Infinity', 'USD')    -> ValueError: amount must be finite, got 'Infinity'
Money.of('1', 'XYZ')           -> ValueError: unknown currency 'XYZ'
Money(19.99, 'USD')            -> TypeError: minor units must be an int, not float
price + Money.of('5', 'EUR')   -> ValueError: cannot combine USD and EUR
price + 5                      -> TypeError: expected Money, not int
price.times('0.5')             -> TypeError: Money.times() missing 1 required keyword-only argument: 'rounding'

The first block shows exact parsing and formatting: 19.99 is 1999 minor units, 500 JPY has no decimals, 1.234 KWD has three, and a thousand payments of ten cents add up to exactly 100.00. The ten refusals are the point of the type. A float is a TypeError. Too many digits, text that is not a number, NaN, Infinity and an unknown currency are each a ValueError with a message that says what is wrong. Dollars plus euros, dollars plus a bare integer, and a multiplication with no rounding mode all raise.

Check: your output should match, line for line. Gotcha: sum() of several Money values needs a starting value, sum(items, Money.zero("USD")), because the default start of the integer 0 is refused on purpose: 0 + Money is exactly the kind of unit-less arithmetic the type exists to stop.

Step 5: Split an amount without losing a cent

Splitting money is where cents most often disappear. Divide 100.00 among three people and every share is 33.33, which together make 99.99. Someone has to take the missing cent, and the code should decide who, on purpose, every time. Create step05_allocate.py. It tries the naive ways first and then the method you are about to write:

# step05_allocate.py
"""step05_allocate.py: splitting an amount without losing a cent."""
import random
from decimal import ROUND_HALF_UP, Decimal

from money import Money

# 1. An even three-way split of 100.00
print("float round, three times :", [round(100.00 / 3, 2)] * 3, "adds up to", round(round(100.00 / 3, 2) * 3, 2))
print("integer division, cents  :", [10000 // 3] * 3, "adds up to", 3 * (10000 // 3))
print("allocate([1, 1, 1])      :", [str(m) for m in Money.of("100.00", "USD").allocate([1, 1, 1])])

# 2. Spread 9.99 of shipping over three lines in proportion to their price
lines = [Money.of("30.00", "USD"), Money.of("19.99", "USD"), Money.of("14.99", "USD")]
shipping = Money.of("9.99", "USD")
weights = [line.minor for line in lines]
naive = [shipping.times(Decimal(w) / sum(weights), rounding=ROUND_HALF_UP) for w in weights]
fair = shipping.allocate(weights)
print()
print("line prices            :", [str(m.amount) for m in lines])
print("round each share       :", [str(m.amount) for m in naive], "adds up to", sum(naive, Money.zero("USD")).amount)
print("allocate               :", [str(m.amount) for m in fair], "adds up to", sum(fair, Money.zero("USD")).amount)

# 3. How often does rounding each share lose the total? 20,000 random splits.
rng = random.Random(5)
CASES = 20_000
misses = worst = 0
for _ in range(CASES):
    total = Money(rng.randint(100, 5000), "USD")
    weights = [rng.randint(100, 9999) for _ in range(rng.randint(2, 6))]
    naive_total = sum(total.times(Decimal(w) / sum(weights), rounding=ROUND_HALF_UP).minor for w in weights)
    gap = naive_total - total.minor
    if gap:
        misses += 1
        worst = max(worst, abs(gap))
    assert sum(share.minor for share in total.allocate(weights)) == total.minor
print()
print(f"rounding each share missed the total in {misses} of {CASES} splits (worst gap {worst} cents)")
print(f"allocate matched the total in all {CASES} splits")

You will not be able to run it until allocate exists, so add the last method to money.py first:

    # money.py (continued)
    def allocate(self, weights):
        """Split into len(weights) parts in proportion to the weights without losing a minor unit."""
        if not weights or any(type(w) is not int or w < 0 for w in weights):
            raise ValueError("weights must be a non-empty list of non-negative ints")
        total_weight = sum(weights)
        if total_weight == 0:
            raise ValueError("at least one weight must be above zero")
        amount = abs(self.minor)
        shares = [amount * w // total_weight for w in weights]
        remainders = [amount * w % total_weight for w in weights]
        leftover = amount - sum(shares)
        # Hand the leftover minor units to the biggest remainders; ties go to the earlier line.
        by_remainder = sorted(range(len(weights)), key=lambda i: (-remainders[i], i))
        for i in by_remainder[:leftover]:
            shares[i] += 1
        sign = -1 if self.minor < 0 else 1
        return [Money(sign * share, self.currency) for share in shares]

5.1 How allocate works

The idea is the largest remainder method. Wikipedia notes that with the Hare quota it is called Hamilton’s method, named after Alexander Hamilton, who devised it in 1792. First give every part its share rounded down, using only integer arithmetic: amount * weight // total_weight. That can only undershoot, by less than one minor unit per part. The leftover units are then handed out one at a time to the parts whose exact shares had the largest fractional remainders, which are computed with % and compared as exact integers. Ties go to the earlier part, so the answer never depends on luck or hash order.

Work through the shipping example by hand. Spread 9.99 over lines priced 30.00, 19.99 and 14.99. The weights are the prices in cents, 3000, 1999 and 1499, which total 6498. The exact shares are 461.22, 307.33 and 230.46 cents. Rounding each one gives 461, 307 and 230, which add up to 998, so one cent has vanished. Allocation floors them to the same 461, 307 and 230, sees one leftover cent, and gives it to the third line, whose remainder (0.46) is the largest. The result is 4.61, 3.07 and 2.31, and the sum is 9.99.

The rest of the method deals with edge cases. The weights must be a non-empty list of non-negative integers with a positive total, or it raises. A negative amount is split as its absolute value and the sign is put back, so the shares of -1.00 are -0.34, -0.33 and -0.33. A weight of zero always gets a share of zero.

5.2 Run it

Run python step05_allocate.py. You should see this:

float round, three times : [33.33, 33.33, 33.33] adds up to 99.99
integer division, cents  : [3333, 3333, 3333] adds up to 9999
allocate([1, 1, 1])      : ['33.34 USD', '33.33 USD', '33.33 USD']

line prices            : ['30.00', '19.99', '14.99']
round each share       : ['4.61', '3.07', '2.30'] adds up to 9.98
allocate               : ['4.61', '3.07', '2.31'] adds up to 9.99

rounding each share missed the total in 5677 of 20000 splits (worst gap 2 cents)
allocate matched the total in all 20000 splits

The first group is the three-way split. Rounding a float share three times and integer division both end at 99.99, and allocate([1, 1, 1]) gives 33.34, 33.33 and 33.33, which is 100.00. The second group is the shipping example: rounding each share adds up to 9.98, allocation to 9.99. The last two lines are a stress test. Across 20,000 random splits with two to six weights, rounding each share missed the total in 5,677 of them (28.4 percent), by as much as 2 cents, while allocate matched the total every time. The script asserts that, so a failure would stop it with an error rather than print a quiet number.

Check: the 5,677 comes from a seeded generator and should match exactly. Common mistake: computing every share with the same rounding and then “fixing” the last one by subtracting the others from the total. That hides the error in one arbitrary part, and it can push that part more than a cent away from its fair share. Step 10 tests that every share stays within one minor unit of exact.

Step 6: Tax on an invoice, rounded per line or on the total?

The next question looks like bookkeeping, but it changes totals. When an invoice has several lines, you can round the tax of each line and add the rounded amounts, or add the lines first and round the tax once. Both are reasonable, and they do not give the same answer. Create invoice.py:

# invoice.py
"""invoice.py: two ways to round the tax on an invoice, as functions over a list of line totals."""
from decimal import ROUND_HALF_UP

from money import Money


def tax_per_line(lines, rate):
    """Round the tax of every line, then add up the rounded amounts."""
    total = Money.zero(lines[0].currency)
    for line in lines:
        total = total + line.times(rate, rounding=ROUND_HALF_UP)
    return total


def tax_on_total(lines, rate):
    """Add up the lines first, then round the tax once."""
    subtotal = Money.zero(lines[0].currency)
    for line in lines:
        subtotal = subtotal + line
    return subtotal.times(rate, rounding=ROUND_HALF_UP)

Both functions use half-up rounding, so the only thing that differs is where the rounding happens. Now measure the difference with step06_invoice_tax.py:

# step06_invoice_tax.py
"""step06_invoice_tax.py: round the tax per line, or once on the invoice total?"""
import random
from collections import Counter
from decimal import Decimal

from invoice import tax_on_total, tax_per_line
from money import Money

RATE = Decimal("0.08875")
lines = [Money.of("0.99", "USD")] * 3
print("three lines of", lines[0], "taxed at 8.875 percent")
print("  tax rounded per line :", tax_per_line(lines, RATE))
print("  tax rounded on total :", tax_on_total(lines, RATE))

rng = random.Random(2026)
BASKETS = 20_000
gaps = Counter()
baskets_of_size = Counter()
differing_of_size = Counter()
for _ in range(BASKETS):
    size = rng.randint(1, 8)
    basket = [Money(rng.randint(50, 9999), "USD") for _ in range(size)]
    gap = (tax_per_line(basket, RATE) - tax_on_total(basket, RATE)).minor
    gaps[gap] += 1
    baskets_of_size[size] += 1
    if gap:
        differing_of_size[size] += 1

print()
print(f"{BASKETS} random baskets of 1 to 8 lines (50 cents to 99.99 each)")
print("per-line tax minus on-total tax, in cents:")
for gap in sorted(gaps):
    print(f"  {gap:+d}: {gaps[gap]} baskets")
print("baskets whose two totals differ, by number of lines:")
for size in sorted(baskets_of_size):
    share = 100 * differing_of_size[size] / baskets_of_size[size]
    print(f"  {size} lines: {share:5.1f} percent of {baskets_of_size[size]}")

Run it. You should see this:

three lines of 0.99 USD taxed at 8.875 percent
  tax rounded per line : 0.27 USD
  tax rounded on total : 0.26 USD

20000 random baskets of 1 to 8 lines (50 cents to 99.99 each)
per-line tax minus on-total tax, in cents:
  -3: 2 baskets
  -2: 232 baskets
  -1: 3455 baskets
  +0: 12508 baskets
  +1: 3603 baskets
  +2: 200 baskets
baskets whose two totals differ, by number of lines:
  1 lines:   0.0 percent of 2419
  2 lines:  23.5 percent of 2540
  3 lines:  32.8 percent of 2453
  4 lines:  40.0 percent of 2600
  5 lines:  44.7 percent of 2523
  6 lines:  48.8 percent of 2466
  7 lines:  53.3 percent of 2487
  8 lines:  55.5 percent of 2512

6.1 What the numbers say

Three items at 0.99 each, taxed at 8.875 percent, give two different totals. The tax on one item is 8.78625 cents, which rounds up to 9, so per-line rounding charges 27 cents. The three items together cost 2.97, and 8.875 percent of that is 26.35875 cents, which rounds to 26. The survey puts a size on it. Of 20,000 random baskets, 7,492 (37.5 percent) came out differently, by up to 3 cents, and the chance grows with the number of lines: no difference for a single line, 23.5 percent of the baskets with two lines, and 55.5 percent of the baskets with eight.

6.2 Pick one, write it down, test it

This is a policy decision, not a bug to be fixed, and I did not research which rule your jurisdiction requires, so ask whoever owns that question. What the code can guarantee is consistency. One property of per-line rounding is worth knowing: the tax amounts printed on the lines always add up to the tax total printed on the invoice. With total rounding the tax is rounded only once, but a reader who adds up the printed line taxes may find a cent missing or extra. Whichever you choose, put it in a docstring, use one function everywhere, and pin it with a test like the one in Step 10 that expects 0.27 for the first example and 0.26 for the other.

Check: the gap table, the 7,492 differing baskets (2 + 232 + 3,455 + 3,603 + 200) and the percentages per basket size are seeded and should match exactly.

Step 7: Keep money exact in SQLite

Your numbers are exact in memory. The next place they usually get damaged is the database. This step shows what SQLite does with a column that looks like it holds decimals, and then builds a table that cannot be fooled. Start with the ledger module, which the step script imports:

# ledger.py
"""ledger.py: keep Money in SQLite as integer minor units plus a currency code."""
import sqlite3

from money import Money

SCHEMA = """
CREATE TABLE IF NOT EXISTS ledger (
    id           INTEGER PRIMARY KEY,
    memo         TEXT    NOT NULL,
    amount_minor INTEGER NOT NULL,
    currency     TEXT    NOT NULL CHECK (length(currency) = 3 AND currency = upper(currency))
) STRICT
"""


def connect(path=":memory:"):
    con = sqlite3.connect(path)
    con.execute(SCHEMA)
    return con


def add(con, memo, money):
    """Insert one entry. The caller decides when to commit."""
    cursor = con.execute(
        "INSERT INTO ledger (memo, amount_minor, currency) VALUES (?, ?, ?)",
        (memo, money.minor, money.currency),
    )
    return cursor.lastrowid


def totals(con):
    """One exact total per currency. Never add across currencies."""
    rows = con.execute(
        "SELECT currency, SUM(amount_minor) FROM ledger GROUP BY currency ORDER BY currency"
    )
    return {currency: Money(minor, currency) for currency, minor in rows}

Now create step07_sqlite.py in four parts. The first part creates the common table that many tutorials show, a column declared DECIMAL(10,2), and looks at what it stores:

# step07_sqlite.py
"""step07_sqlite.py: what SQLite does to money, and the table that avoids it."""
import sqlite3
import sys
from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal

import ledger
from money import Money

print("Python", sys.version.split()[0], "| SQLite", sqlite3.sqlite_version)

print("\n-- A column declared DECIMAL(10,2) --")
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE price_list (price DECIMAL(10,2))")
con.executemany("INSERT INTO price_list VALUES (?)", [("19.99",), ("1.10",), ("5",)])
print(con.execute("SELECT price, typeof(price) FROM price_list").fetchall())
print("0.1 + 0.2 in SQL =", con.execute("SELECT 0.1 + 0.2").fetchone()[0], "| equals 0.3:", con.execute("SELECT 0.1 + 0.2 = 0.3").fetchone()[0])
try:
    con.execute("INSERT INTO price_list VALUES (?)", (Decimal("1.10"),))
except sqlite3.Error as error:
    print(type(error).__name__ + ":", error)

Run it so far. You should see this:

Python 3.13.14 | SQLite 3.50.4

-- A column declared DECIMAL(10,2) --
[(19.99, 'real'), (1.1, 'real'), (5, 'integer')]
0.1 + 0.2 in SQL = 0.30000000000000004 | equals 0.3: 0
ProgrammingError: Error binding parameter 1: type 'decimal.Decimal' is not supported

7.1 A DECIMAL column that is not decimal

SQLite decides how to treat a column from its declared type name, using a short list of rules. The documentation says that if the declared type contains “INT” the column gets integer affinity, if it contains “CHAR”, “CLOB” or “TEXT” it gets text affinity, and so on through BLOB and REAL, and then: “Otherwise, the affinity is NUMERIC.” The name DECIMAL matches none of those words, so a DECIMAL(10,2) column has NUMERIC affinity. For that affinity, “when text data is inserted into a NUMERIC column, the storage class of the text is converted to INTEGER or REAL (in order of preference) if the text is a well-formed integer or real literal, respectively”.

The output shows the result. The text "19.99" became a REAL, a binary float, the text "1.10" became 1.1 and lost its trailing zero, and only "5" stayed an integer. Arithmetic in SQL is float arithmetic: 0.1 + 0.2 prints 0.30000000000000004 and the comparison with 0.3 returns 0. The last line is a second surprise from the Python side. sqlite3 has no built-in way to bind a Decimal, and raises ProgrammingError. An adapter that turned a Decimal into text would not help, because the output above already shows what a NUMERIC column does with text such as "19.99": it stores a float.

The second part asks whether SQL can at least round correctly, using the same 1,000 ties as Step 1:

# step07_sqlite.py (continued)
print("\n-- round() on REAL values that sit exactly halfway between two cents --")
ties = [Decimal(n) / 1000 for n in range(5, 10_000, 10)]  # 0.005, 0.015, ... 9.995
con.execute("CREATE TABLE ties (value REAL)")
con.executemany("INSERT INTO ties VALUES (?)", [(str(t),) for t in ties])
sql = [Decimal(str(v)) for (v,) in con.execute("SELECT round(value, 2) FROM ties ORDER BY rowid")]
python = [Decimal(str(round(float(t), 2))) for t in ties]
half_up = [t.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) for t in ties]
half_even = [t.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN) for t in ties]
print("SQLite round() gives the half-up answer on :", sum(a == b for a, b in zip(sql, half_up)), "of", len(ties))
print("Python round() gives the half-up answer on :", sum(a == b for a, b in zip(python, half_up)), "of", len(ties))
different = [(t, s, p) for t, s, p in zip(ties, sql, python) if s != p]
print("SQLite and Python disagree on", len(different), "of them, for example:")
for t, s, p in different[:3]:
    print(f"  {t}: SQLite {s}, Python {p}")
exact = [i for i, t in enumerate(ties) if Decimal(float(t)) == t]
inexact = [i for i, t in enumerate(ties) if Decimal(float(t)) != t]
print(f"{len(exact)} of the prices are exact binary fractions:")
print("  SQLite gives the half-up answer on", sum(sql[i] == half_up[i] for i in exact), "| Python gives the half-even answer on", sum(python[i] == half_even[i] for i in exact))
print(f"the other {len(inexact)} are stored a hair off the tie: SQLite and Python agree on", sum(sql[i] == python[i] for i in inexact))

Run it. This part of the output is:

-- round() on REAL values that sit exactly halfway between two cents --
SQLite round() gives the half-up answer on : 520 of 1000
Python round() gives the half-up answer on : 500 of 1000
SQLite and Python disagree on 20 of them, for example:
  0.125: SQLite 0.13, Python 0.12
  0.625: SQLite 0.63, Python 0.62
  1.125: SQLite 1.13, Python 1.12
40 of the prices are exact binary fractions:
  SQLite gives the half-up answer on 40 | Python gives the half-even answer on 40
the other 960 are stored a hair off the tie: SQLite and Python agree on 960

7.2 Rounding in SQL and in Python disagree

The SQLite documentation describes round(X,Y) only as returning “a floating-point value X rounded to Y digits to the right of the decimal point”. It does not say how ties are handled, so I measured. SQLite gave the half-up answer on 520 of the 1,000 ties and Python’s round() on 500. Neither follows the half-up rule: for the 960 prices that are stored a hair off the tie, the two agree on every one, following the hidden error. For the 40 that are exact binary fractions, SQLite always went up while Python always chose the even neighbor. That leaves 20 prices, 0.125, 0.625, 1.125 and so on, where the same input gives 0.13 in a SQL report and 0.12 in Python code. The lesson is not to memorize which system does what. It is to do no money arithmetic or rounding in SQL on REAL values at all.

The third part builds the table that avoids all of this and tries to feed it bad data:

# step07_sqlite.py (continued)
print("\n-- A STRICT table that stores integer minor units --")
con = ledger.connect()
for value in (1999, "2000", 19.99, "19.99", 20.0, None):
    try:
        con.execute("INSERT INTO ledger (memo, amount_minor, currency) VALUES ('test', ?, 'USD')", (value,))
        stored = con.execute("SELECT amount_minor, typeof(amount_minor) FROM ledger ORDER BY id DESC LIMIT 1").fetchone()
        print(f"{value!r:8} -> stored {stored}")
    except sqlite3.Error as error:
        print(f"{value!r:8} -> {type(error).__name__}: {error}")
try:
    con.execute("INSERT INTO ledger (memo, amount_minor, currency) VALUES ('test', 5, 'usd')")
except sqlite3.Error as error:
    print(f"'usd'    -> {type(error).__name__}: {error}")

The output:

-- A STRICT table that stores integer minor units --
1999     -> stored (1999, 'integer')
'2000'   -> stored (2000, 'integer')
19.99    -> IntegrityError: cannot store REAL value in INTEGER column ledger.amount_minor
'19.99'  -> IntegrityError: cannot store REAL value in INTEGER column ledger.amount_minor
20.0     -> stored (20, 'integer')
None     -> IntegrityError: NOT NULL constraint failed: ledger.amount_minor
'usd'    -> IntegrityError: CHECK constraint failed: length(currency) = 3 AND currency = upper(currency)

7.3 A STRICT table of integers

In ledger.py the table has amount_minor INTEGER and a three-letter currency, and ends with the word STRICT. The SQLite documentation describes the normal behavior, “SQLite tries to convert anything inserted into that column into an integer”, and says its strict typing mode exists as of version 3.37.0 (2021-11-27) and is enabled separately for each table. In a STRICT table the documentation says that SQLite attempts to coerce the data using the usual affinity rules, and then: “If the value cannot be losslessly converted in the specified datatype, then an SQLITE_CONSTRAINT_DATATYPE error is raised.”

What the table accepted and refused

The output shows the rules in action. The integer 1999 is stored. The text "2000" is coerced to the integer 2000, which is lossless. Both 19.99 and "19.99" are refused with IntegrityError: cannot store REAL value in INTEGER column, because a fractional amount cannot be an integer number of cents, and the text version is first read as a REAL. None violates NOT NULL, and a lowercase currency violates the CHECK, so "usd" and "USD" can never be two currencies in one ledger.

The 20.0 trap

One line deserves a second look. The float 20.0 was accepted and stored as the integer 20, because it converts without loss. If the writer meant 20 dollars, the ledger now holds 20 cents. A type check cannot know your unit, and that is why the column is named amount_minor and why application code should create amounts only through Money, which turns "20.00" into 2000 for you.

The fourth part adds a hundred thousand payments and asks for totals:

# step07_sqlite.py (continued)
print("\n-- Totals per currency --")
con = ledger.connect()
with con:
    con.executemany(
        "INSERT INTO ledger (memo, amount_minor, currency) VALUES ('payment', 10, 'USD')",
        [() for _ in range(100_000)],
    )
    ledger.add(con, "coffee", Money.of("3.50", "EUR"))
    ledger.add(con, "ticket", Money.of("500", "JPY"))
for currency, money in ledger.totals(con).items():
    print(f"{currency}: {money}")
-- Totals per currency --
EUR: 3.50 EUR
JPY: 500 JPY
USD: 10,000.00 USD

The totals function in ledger.py groups by currency, so dollars, euros and yen are never added together. The SQLite documentation says that the result of sum() “is an integer value if all non-NULL inputs are integers”, and that it raises an “integer overflow” exception if the sum leaves the 64-bit range, instead of drifting into a float. A hundred thousand payments of ten minor units come to exactly 10,000.00 USD.

Check: run python step07_sqlite.py and compare the whole output with the pieces above; only the version line on the first row depends on your installation. Common mistake: summing a money column without GROUP BY currency, which silently adds numbers that are not comparable.

Step 8: Move money through JSON without floats

JSON is where floats re-enter a system that was careful until then. A client writes 19.99 as a JSON number, and json.loads turns it into a float before your code has a chance to look at it. Create wire.py:

# wire.py
"""wire.py: Money in and out of JSON as strings, with numbers parsed exactly."""
import json
from decimal import Decimal

from money import Money


def money_to_dict(money):
    return {"amount": str(money.amount), "currency": money.currency}


def money_from_dict(data):
    """Read {"amount": "19.99", "currency": "USD"}. Anything but a string amount is refused."""
    if not isinstance(data.get("amount"), str):
        raise TypeError("amount must be a JSON string such as '19.99'")
    return Money.of(data["amount"], data["currency"])


def _encode(value):
    if isinstance(value, Money):
        return money_to_dict(value)
    if isinstance(value, Decimal):
        return str(value)
    raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")


def dumps(value):
    return json.dumps(value, default=_encode)


def loads(text):
    """Parse JSON so that no number ever passes through a float."""
    return json.loads(text, parse_float=Decimal)

The functions do four jobs. money_to_dict writes an amount as a string plus a currency code, for example {"amount": "1234.50", "currency": "USD"}. money_from_dict reads it back, and it refuses an amount that is not a JSON string. dumps teaches the encoder about Money and Decimal, which the standard encoder cannot serialize. loads parses with parse_float=Decimal, so any JSON number written with a decimal point or an exponent goes straight to a Decimal and never passes through a float. The json documentation shows the same call, json.loads('1.1', parse_float=decimal.Decimal), returning Decimal('1.1').

Now create step08_json.py:

# step08_json.py
"""step08_json.py: money across a JSON boundary."""
import json
from decimal import Decimal

import wire
from money import Money

text = '{"price": 19.99, "tax_rate": 0.08875, "order_id": 9007199254740993}'
as_float = json.loads(text)
as_decimal = json.loads(text, parse_float=Decimal)
for key in ("price", "tax_rate"):
    print(f"{key}: as a float it is {Decimal(as_float[key])}")
    print(f"{' ' * len(key)}  with parse_float=Decimal it is {as_decimal[key]}")
try:
    json.dumps({"price": Decimal("19.99")})
except TypeError as error:
    print("TypeError:", error)

print()
order = {"total": Money.of("1234.50", "USD"), "tax_rate": Decimal("0.08875")}
sent = wire.dumps(order)
print("sent    :", sent)
received = wire.loads(sent)
print("received:", received)
print("round trip equal:", wire.money_from_dict(received["total"]) == order["total"])

print()
bad_bodies = {
    "amount as a JSON number": {"amount": 19.99, "currency": "USD"},
    "three decimal places": {"amount": "19.999", "currency": "USD"},
    "a JavaScript float sum": {"amount": "0.30000000000000004", "currency": "USD"},
    "unknown currency": {"amount": "5", "currency": "ABC"},
}
for label, body in bad_bodies.items():
    try:
        wire.money_from_dict(body)
        print(f"{label:24} -> accepted")
    except (TypeError, ValueError) as error:
        print(f"{label:24} -> {type(error).__name__}: {error}")

print()
big = 9007199254740993
print(f"{big} survives in Python; a float (like a JavaScript number) holds {int(float(big))}")

Run it. You should see this:

price: as a float it is 19.989999999999998436805981327779591083526611328125
       with parse_float=Decimal it is 19.99
tax_rate: as a float it is 0.08874999999999999555910790149937383830547332763671875
          with parse_float=Decimal it is 0.08875
TypeError: Object of type Decimal is not JSON serializable

sent    : {"total": {"amount": "1234.50", "currency": "USD"}, "tax_rate": "0.08875"}
received: {'total': {'amount': '1234.50', 'currency': 'USD'}, 'tax_rate': '0.08875'}
round trip equal: True

amount as a JSON number  -> TypeError: amount must be a JSON string such as '19.99'
three decimal places     -> ValueError: 19.999 has more than 2 decimal places for USD; pass rounding=
a JavaScript float sum   -> ValueError: 0.30000000000000004 has more than 2 decimal places for USD; pass rounding=
unknown currency         -> ValueError: unknown currency 'ABC'

9007199254740993 survives in Python; a float (like a JavaScript number) holds 9007199254740992

8.1 What the output shows

The first group compares the two ways of reading the same JSON. As floats, 19.99 is really 19.989999999999998436805981327779591083526611328125 and the tax rate 0.08875 is slightly under. With parse_float=Decimal both are exact. The next line shows the other half of the problem: json.dumps cannot write a Decimal at all, so you need an encoder.

The second group is a round trip through wire: the total travels as {"amount": "1234.50", "currency": "USD"} and the Decimal rate as the string "0.08875", and the received amount equals the original. The third group is what the receiving side refuses: a bare JSON number, three decimal places, the string 0.30000000000000004 that a client’s float sum would produce, and an unknown currency. Rejecting is better than fixing here. If a client sends 0.30000000000000004, its own code has a float bug, and rounding it quietly would hide that bug while changing a customer’s amount.

8.2 Large integers and JavaScript

You may prefer to send integer minor units instead of strings. That works for Python, which keeps integers exact at any size, but a JavaScript client holds every number as a double-precision float. The MDN documentation gives Number.MAX_SAFE_INTEGER as 9007199254740991, and its example shows that adding 1 to it and adding 2 to it produce the same number. The last line of the output shows the effect from Python: float(9007199254740993) comes back as 9007199254740992. In US cents that limit is about 90 trillion dollars, far above any price, but ledger totals and currencies with many decimals can get close. Strings avoid the question.

Check: the printed digits of the two float expansions must match yours exactly, since they are the exact values of two standard doubles. Common mistake: parsing with json.loads and converting afterwards with Decimal(value). By then the float has already been made, and the Decimal copies its error.

Step 9: Check how many decimals your currencies really have

The EXPONENTS table in money.py has five entries because the lab needs five. Before you add more, look at the real distribution. Create step09_currencies.py. It downloads the ISO 4217 list that SIX Group publishes, saves it next to the script, and groups the currency codes by their number of minor-unit digits:

# step09_currencies.py
"""step09_currencies.py: how many decimal places do currencies have? Reads ISO 4217 list one."""
import os
import urllib.request
import xml.etree.ElementTree as ET
from collections import defaultdict

URL = "https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml"
CACHE = "iso4217_list_one.xml"
if not os.path.exists(CACHE):
    request = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(request, timeout=30) as response, open(CACHE, "wb") as handle:
        handle.write(response.read())

root = ET.parse(CACHE).getroot()
exponent_of = {}
for entry in root.iter("CcyNtry"):
    code = entry.findtext("Ccy")
    if code:
        exponent_of[code] = entry.findtext("CcyMnrUnts")

print("list published:", root.get("Pblshd"), "| distinct currency codes:", len(exponent_of))
groups = defaultdict(list)
for code, digits in sorted(exponent_of.items()):
    groups[digits].append(code)
for digits in sorted(groups):
    codes = groups[digits]
    shown = " ".join(codes) if len(codes) <= 12 else " ".join(codes[:8]) + " and more"
    print(f"minor units {digits:>4}: {len(codes):3} codes  {shown}")

Run it. At the time of writing, the output was:

list published: 2026-09-17 | distinct currency codes: 178
minor units    0:  17 codes  BIF CLP DJF GNF ISK JPY KMF KRW and more
minor units    2: 139 codes  AED AFN ALL AMD AOA ARS AUD AWG and more
minor units    3:   7 codes  BHD IQD JOD KWD LYD OMR TND
minor units    4:   2 codes  CLF UYW
minor units N.A.:  13 codes  XAG XAU XBA XBB XBC XBD XDR XPD and more

9.1 The assumption that breaks

The list published on 2026-09-17 has 178 distinct currency codes. Most (139) have two decimal digits, so “cents” works for them. But 17 have none, among them JPY, KRW and ISK, 7 have three (BHD, IQD, JOD, KWD, LYD, OMR and TND), and 2 have four (CLF and UYW). Another 13 have no minor unit at all (they include the metal codes XAG, XAU and XPD). Code that multiplies by 100 everywhere is wrong for 26 of the 178 codes (17 + 7 + 2), about one in seven, and meaningless for the 13 that have no minor unit.

9.2 The table is a decision, not a fact

The ISO number is where you start, not where you stop, because payment providers add their own conventions. Stripe’s currency page has a “Special cases” table. It says: “ISK transitioned to a zero-decimal currency, but backward compatibility requires you to represent it as a two-decimal value, where the decimal amount is always 00.” So to charge 5 ISK you send 500, even though the ISO list gives ISK zero digits. The same page says UGX is handled that way, and that HUF and TWD are treated as zero-decimal currencies for payouts only. The practical rule: keep one table for how you store amounts, which can follow ISO, and make the conversion at the provider boundary explicit and tested, one provider at a time.

Check: the totals in your run may differ from mine if ISO has published a newer list; the shape of the output is what matters. If the download fails, open the URL in a browser and save the file as iso4217_list_one.xml next to the script.

Step 10: Lock it in with tests, and prove they can fail

Everything so far was checked by reading output. Tests turn those checks into a safety net that runs in a fraction of a second. Create test_money.py. It holds 29 tests, from the parsing rules to the SQLite and JSON boundaries:

# test_money.py
"""test_money.py: the rules Money promises, written as tests."""
import random
from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal
from fractions import Fraction

import pytest

import ledger
import wire
from invoice import tax_on_total, tax_per_line
from money import Money


def minors(shares):
    return [share.minor for share in shares]


def test_of_reads_decimal_strings_exactly():
    assert Money.of("19.99", "USD").minor == 1999
    assert Money.of("19.9", "USD").minor == 1990
    assert Money.of("19", "USD").minor == 1900
    assert Money.of("1E+2", "USD").minor == 10000
    assert Money.of(Decimal("19.99"), "USD").minor == 1999
    assert Money.of(7, "EUR").minor == 700
    assert Money.of("500", "JPY").minor == 500
    assert Money.of("1.234", "KWD").minor == 1234
    assert Money.of("-0.05", "EUR").minor == -5


@pytest.mark.parametrize("bad", [19.99, 0.1, True])
def test_of_refuses_floats_and_bools(bad):
    with pytest.raises(TypeError):
        Money.of(bad, "USD")


@pytest.mark.parametrize("bad", ["abc", "", "NaN", "Infinity", "-Infinity", "1,000.00"])
def test_of_refuses_text_that_is_not_a_finite_number(bad):
    with pytest.raises(ValueError):
        Money.of(bad, "USD")


def test_extra_digits_need_a_rounding_mode():
    with pytest.raises(ValueError):
        Money.of("19.999", "USD")
    assert Money.of("19.999", "USD", rounding=ROUND_HALF_UP).minor == 2000
    assert Money.of("2.665", "USD", rounding=ROUND_HALF_UP).minor == 267
    assert Money.of("2.665", "USD", rounding=ROUND_HALF_EVEN).minor == 266
    assert Money.of("0.5", "JPY", rounding=ROUND_HALF_EVEN).minor == 0


def test_unknown_currency_and_bad_minor_units_are_refused():
    with pytest.raises(ValueError):
        Money.of("1", "XYZ")
    with pytest.raises(ValueError):
        Money(1, "XYZ")
    for bad in (19.99, True, "5", None):
        with pytest.raises(TypeError):
            Money(bad, "USD")


def test_amount_is_exact_and_round_trips():
    rng = random.Random(1)
    for currency in ("USD", "JPY", "KWD"):
        for _ in range(500):
            money = Money(rng.randint(-10**12, 10**12), currency)
            assert Money.of(money.amount, currency) == money
            assert Money.of(str(money.amount), currency) == money
    assert str(Money.of("1234.5", "USD")) == "1,234.50 USD"
    assert str(Money.of("5", "JPY")) == "5 JPY"
    assert str(Money.of("1.5", "KWD")) == "1.500 KWD"


def test_addition_subtraction_and_sum_are_exact():
    a, b = Money.of("0.10", "USD"), Money.of("0.20", "USD")
    assert a + b == Money.of("0.30", "USD")
    assert (a + b) - b == a
    assert -a == Money.of("-0.10", "USD")
    assert sum([a] * 1000, Money.zero("USD")) == Money.of("100.00", "USD")
    assert a < b and b > a and a <= a


def test_mixed_currencies_are_refused():
    usd, eur = Money.of("1", "USD"), Money.of("1", "EUR")
    for operation in (lambda: usd + eur, lambda: usd - eur, lambda: usd < eur, lambda: usd >= eur):
        with pytest.raises(ValueError):
            operation()
    with pytest.raises(TypeError):
        usd + 1
    assert usd != eur


def test_times_requires_a_rounding_mode():
    with pytest.raises(TypeError):
        Money.of("1.00", "USD").times("0.5")


def test_times_uses_the_mode_you_name():
    amount = Money(125, "USD")  # 1.25 USD, and 10 percent of it is 12.5 cents
    assert amount.times("0.1", rounding=ROUND_HALF_UP).minor == 13
    assert amount.times("0.1", rounding=ROUND_HALF_EVEN).minor == 12
    assert Money(-125, "USD").times("0.1", rounding=ROUND_HALF_UP).minor == -13
    assert Money(300, "USD").times(3, rounding=ROUND_HALF_UP).minor == 900


@pytest.mark.parametrize("bad", [0.1, True])
def test_times_refuses_float_factors(bad):
    with pytest.raises(TypeError):
        Money(100, "USD").times(bad, rounding=ROUND_HALF_UP)


def test_allocate_examples():
    assert minors(Money.of("100.00", "USD").allocate([1, 1, 1])) == [3334, 3333, 3333]
    assert minors(Money.of("9.99", "USD").allocate([3000, 1999, 1499])) == [461, 307, 231]
    assert minors(Money.of("-1.00", "USD").allocate([1, 1, 1])) == [-34, -33, -33]
    assert minors(Money.of("0.05", "USD").allocate([1, 0, 1])) == [3, 0, 2]


def test_allocate_conserves_the_total_and_stays_within_one_unit():
    rng = random.Random(2026)
    for _ in range(5000):
        total = rng.randint(-100_000, 100_000)
        weights = [rng.randint(0, 5000) for _ in range(rng.randint(1, 8))]
        if sum(weights) == 0:
            continue
        shares = Money(total, "USD").allocate(weights)
        assert sum(share.minor for share in shares) == total
        for share, weight in zip(shares, weights):
            exact = Fraction(total * weight, sum(weights))
            assert abs(share.minor - exact) < 1
            if weight == 0:
                assert share.minor == 0


@pytest.mark.parametrize("bad", [[], [0, 0], [1, -1], [1.5, 2], [True, 1]])
def test_allocate_refuses_bad_weights(bad):
    with pytest.raises(ValueError):
        Money(100, "USD").allocate(bad)


def test_the_two_tax_policies_can_differ_by_a_cent():
    lines = [Money.of("0.99", "USD")] * 3
    rate = Decimal("0.08875")
    assert tax_per_line(lines, rate) == Money.of("0.27", "USD")
    assert tax_on_total(lines, rate) == Money.of("0.26", "USD")


def test_ledger_stores_integers_and_refuses_everything_else():
    con = ledger.connect()
    ledger.add(con, "a", Money.of("19.99", "USD"))
    ledger.add(con, "b", Money.of("0.01", "USD"))
    ledger.add(con, "c", Money.of("500", "JPY"))
    assert ledger.totals(con) == {"JPY": Money.of("500", "JPY"), "USD": Money.of("20.00", "USD")}
    assert con.execute("SELECT typeof(amount_minor) FROM ledger").fetchone() == ("integer",)
    for bad in (19.99, "19.99", None):
        with pytest.raises(Exception):
            con.execute("INSERT INTO ledger (memo, amount_minor, currency) VALUES ('x', ?, 'USD')", (bad,))
    with pytest.raises(Exception):
        con.execute("INSERT INTO ledger (memo, amount_minor, currency) VALUES ('x', 5, 'usd')")


def test_wire_round_trip_and_refusals():
    order = {"total": Money.of("1234.50", "USD"), "tax_rate": Decimal("0.08875")}
    received = wire.loads(wire.dumps(order))
    assert received == {"total": {"amount": "1234.50", "currency": "USD"}, "tax_rate": "0.08875"}
    assert wire.money_from_dict(received["total"]) == order["total"]
    assert wire.loads('{"price": 19.99}')["price"] == Decimal("19.99")
    with pytest.raises(TypeError):
        wire.money_from_dict({"amount": 19.99, "currency": "USD"})
    for amount in ("19.999", "0.30000000000000004", "abc"):
        with pytest.raises(ValueError):
            wire.money_from_dict({"amount": amount, "currency": "USD"})

Three of these tests are worth reading closely. test_extra_digits_need_a_rounding_mode and test_times_uses_the_mode_you_name pin the rounding rules to concrete numbers: half-up and half-even disagree on 2.665 and on 12.5 cents. test_allocate_conserves_the_total_and_stays_within_one_unit runs 5,000 random splits from a fixed seed, including negative amounts and zero weights, and checks two properties, not examples: the shares add up to the total, and each share is within one minor unit of its exact value, computed with fractions.Fraction. And test_the_two_tax_policies_can_differ_by_a_cent turns the Step 6 policy decision into a test. For property tests that generate inputs for you, see our Hypothesis tutorial, which also found a bill splitter that lost cents.

Run python -m pytest -q. You should see this:

............................. [100%]
29 passed in 0.10s

10.1 Plant bugs to see whether the tests notice

A test suite that has never failed has not proved anything. The usual trick is to break the code on purpose and check that something goes red. Create mutations.py. It copies the lab to a temporary folder, makes one small change to money.py (a mutant), runs the tests, and reports whether any failed:

# mutations.py
"""mutations.py: plant one bug at a time in a copy of the lab and check that the tests notice."""
import glob
import os
import re
import shutil
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))
MUTANTS = [
    ("money.py", "for i in by_remainder[:leftover]:", "for i in by_remainder[:0]:", "allocate: forget the leftover cents"),
    ("money.py", "key=lambda i: (-remainders[i], i)", "key=lambda i: (-remainders[i], -i)", "allocate: break ties toward the last line"),
    ("money.py", "sign = -1 if self.minor < 0 else 1", "sign = 1", "allocate: ignore negative amounts"),
    ("money.py", "exact.to_integral_value(rounding=rounding)", "exact.to_integral_value(rounding=ROUND_HALF_UP)", "times: ignore the rounding mode"),
    ("money.py", "if isinstance(amount, (float, bool)):", "if isinstance(amount, bool):", "of: accept floats"),
    ("money.py", "if whole != scaled and rounding is None:", "if False:", "of: round extra digits silently"),
    ("money.py", "if not value.is_finite():", "if False:", "of: accept Infinity"),
    ("money.py", "if other.currency != self.currency:", "if False:", "_check: mix currencies"),
    ("money.py", "if type(self.minor) is not int:", "if not isinstance(self.minor, (int, float)):", "Money: accept a float or bool in minor"),
]


def run_tests(folder):
    env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", COLUMNS="170")
    result = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"], cwd=folder, env=env,
                            capture_output=True, text=True, encoding="utf-8", errors="replace")
    last = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else "no output"
    failed = re.search(r"(\d+) failed", last)
    return int(failed.group(1)) if failed else 0, last


caught = 0
for number, (name, old, new, label) in enumerate(MUTANTS, 1):
    with tempfile.TemporaryDirectory() as folder:
        for path in glob.glob(os.path.join(HERE, "*.py")):
            if os.path.basename(path) not in ("mutations.py", "run_all.py"):
                shutil.copy(path, folder)
        target = os.path.join(folder, name)
        text = open(target, encoding="utf-8").read()
        assert text.count(old) == 1, f"mutant {number}: expected exactly one match for {old!r}"
        open(target, "w", encoding="utf-8", newline="\n").write(text.replace(old, new))
        failing, summary = run_tests(folder)
    caught += failing > 0
    print(f"mutant {number} ({label}): {'caught, ' + str(failing) + ' tests fail' if failing else 'SURVIVED'}")
print(f"{caught} of {len(MUTANTS)} mutants caught")

Run python mutations.py. You should see this:

mutant 1 (allocate: forget the leftover cents): caught, 2 tests fail
mutant 2 (allocate: break ties toward the last line): caught, 1 tests fail
mutant 3 (allocate: ignore negative amounts): caught, 2 tests fail
mutant 4 (times: ignore the rounding mode): caught, 1 tests fail
mutant 5 (of: accept floats): caught, 2 tests fail
mutant 6 (of: round extra digits silently): caught, 2 tests fail
mutant 7 (of: accept Infinity): caught, 2 tests fail
mutant 8 (_check: mix currencies): caught, 1 tests fail
mutant 9 (Money: accept a float or bool in minor): caught, 1 tests fail
9 of 9 mutants caught

All nine mutants are caught. The mutants cover the places a money type goes wrong in practice: leftover cents forgotten, ties broken in the wrong direction, negative amounts mishandled, the caller’s rounding mode ignored, floats accepted, silent rounding of extra digits, infinite values accepted, currencies mixed, and a float or bool allowed to become the amount. If a mutant had survived, the next step would be a new test that kills it.

Common mistakes and gotchas

A float sneaks in at the boundary

The type is only as safe as its inputs. Floats arrive through JSON numbers, CSV files read with float(), spreadsheet exports, web forms parsed with a framework’s float converter, and arithmetic in a SQL REAL column. The fix is the same everywhere: read the text, hand it to Money.of, and let it raise.

Dividing before rounding

Any expression like total / 3 or total * rate produces digits that do not fit. Use allocate for splits and times with a named mode for rates.

Formatting as rounding

Both f"{x:.2f}" on a float and format() on a Decimal choose a rounding mode behind your back. Round with an explicit mode first, format afterwards.

Assuming every currency has two decimals

Store the exponent per currency, as in Step 9, and test at least one zero-decimal and one three-decimal currency, as the tests above do with JPY and KWD.

Rounding too early or in too many places

Keep rates as Decimals and round once, at the end of a calculation that produces an amount, with the mode your policy names. Rounding an intermediate result and then using it as an input is how small differences grow.

Amounts below one minor unit

Some prices have fractions of a cent, such as a per-request fee of 0.0004 USD. Money refuses to hold them. Keep such a unit price as a Decimal rate, multiply it by the quantity with times, and round only the line total.

Confirm the whole thing works end to end

From the project folder with the virtual environment active, run each of the nine step scripts once, in order, and compare the output with the blocks above. Then run the tests and the mutation check:

$ python -m pytest -q
$ python mutations.py

You should see 29 passed and 9 of 9 mutants caught. Every random number in the lab comes from a generator with a fixed seed, so the counts in the output (5,677 splits, 7,492 baskets, 2,503 half-cent lines) should match exactly. Only the timings and the version line in Step 7 will differ. If a count differs, the usual cause is a typo in a seed or a rate. I also ran all nine steps on Python 3.15.1, which ships SQLite 3.53.4, and the output was identical to Python 3.13.14 with SQLite 3.50.4 apart from the version line.

Where this stops working, and what to learn next

This type handles one currency at a time and refuses to combine them. Converting between currencies adds exchange rates, a rounding rule for the conversion and, often, a record of which rate was used, none of which is covered here. Locale-aware display, such as 1.234,56 instead of 1,234.56, is a presentation job: Money.__str__ always prints commas and a decimal point.

If your data lives in PostgreSQL, the documentation says that the numeric type “is especially recommended for storing monetary amounts and other quantities where exactness is required”, and it also warns that calculations on numeric values are very slow compared to the integer types. Integer minor units avoid that cost in SQLite and anywhere else, which is one reason this tutorial used them.

Money bugs also come from concurrency, not only from arithmetic. Two requests that read a balance and both write it back will lose an update no matter how exact the numbers are; see our tutorials on PostgreSQL isolation levels and write skew, on ETag and If-Match for lost updates, and on SQLite WAL mode and BEGIN IMMEDIATE. For a payment flow that receives webhooks, see how to verify webhook signatures and our Django tutorial on split payment webhooks, which computes a referral payout with Decimal and quantize.

A good next exercise: write down your own rounding policy in one paragraph, add it as a docstring on the function that applies it, add the currencies you sell in to EXPONENTS, and add a test for each currency whose exponent is not 2.

Sources and method

  • Python documentation: decimal (rounding modes, contexts and signals, scaleb, the FAQ on fixed-point use), Floating-Point Arithmetic: Issues and Limitations, built-in functions (round() and sum()) and json (parse_float).
  • SQLite documentation: datatypes and type affinity, STRICT tables, core functions (round) and aggregate functions (sum).
  • Martin Fowler, Money (Patterns of Enterprise Application Architecture), and Stripe, currencies (minor units and special cases).
  • The ISO 4217 list one XML file published by SIX Group (dated 2026-09-17), and MDN on Number.MAX_SAFE_INTEGER.
  • Wikipedia, Rounding and Quota method (the largest remainder method), and the PostgreSQL documentation on numeric types.
  • Method: every script was written and run on Windows 11 with Python 3.13.14, SQLite 3.50.4 and pytest 9.1.1, then rerun on Python 3.15.1 with SQLite 3.53.4. The tests, the mutation check, and every number quoted above come from those runs. I rebuilt the lab in a clean folder from this page’s own code blocks and compared the output line by line.

Tags:

Data ValidationDecimal ArithmeticPaymentspytestPythonSQLite

Share

A worn leather suitcase with a paper hang tag and several vintage hotel luggage labels stuck to it
Previous Post

Linux Retires Its Assisted-by AI Tag While Use of It Was Still Rising, and What Replaces It Mostly Lives Outside the Git Log

A red metal mail slot set into a bright green painted door
Next Post

Forever Security Says Planted AGENTS.md and CLAUDE.md Files in ~/Downloads Steered Codex and Claude Code, but Its Report Names No Versions, Run Counts or Permission Modes

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
27 Oct
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
27 Oct
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Trending
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
October 27, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
October 27, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026