Type 0.1 + 0.2 into Python and it answers 0.30000000000000004. JavaScript agrees, and so do many other languages. For most programs the stray digit is harmless. In a program that handles money, it is the first sign of a family of bugs that end with an invoice and a ledger one paisa apart.
Nobody minds a paisa. People mind a total that does not agree, because until someone explains it, nobody knows it is only a paisa. Years in audit and accounting systems taught me that rounding is a rule, and rules can be written down.
Why 0.1 is not quite 0.1
Most languages store fractions as binary floating point. One tenth has no exact binary form, just as one third has no exact decimal form, so 0.1 is stored as the nearest value the format can hold, very slightly more than a tenth. So is 0.2, and their sum lands one step away from the value the computer uses for 0.3.
The error is tiny, which is the trouble: it hides until a comparison or a rounding meets it. A check like total == paid fails for two amounts that print identically. And rounding fails on the half paisa: in Python, round(2.675, 2) gives 2.67, not 2.68, because the 2.675 in memory is really 2.67499999 and a long tail of digits.
Store paise, or store decimals
There are two sound ways to hold money. One is whole numbers in the smallest unit: 1,234.50 rupees is stored as 123450 paise, and adding whole numbers is exact. The other is a decimal type, which keeps digits in base ten so that 0.1 really is 0.1: Decimal in Python, BigDecimal in Java, DECIMAL or NUMERIC in SQL. Whole paise suit amounts with two places; decimals suit rates and quantities that carry more.
Either way, bugs gather where amounts come in and go out. Decimal(0.1) faithfully keeps the float’s error, so build decimals from strings: Decimal("0.1"). Most JSON parsers turn 1234.50 into a float before your code sees it, so send amounts as strings or whole paise. In the database, the column type does the work:
CREATE TABLE invoice_line (
invoice_id INTEGER NOT NULL,
line_no INTEGER NOT NULL,
taxable DECIMAL(14,2) NOT NULL, -- rounded by the code, then stored
gst_rate DECIMAL(5,2) NOT NULL, -- 18.00 means 18 per cent
gst_amount DECIMAL(14,2) NOT NULL,
PRIMARY KEY (invoice_id, line_no)
);
DECIMAL(14,2) holds twelve digits before the point and two after, exactly. FLOAT, DOUBLE and REAL never belong on money. Hand a decimal column more places than it has and most databases quietly round to fit, so round in code first, by a rule you chose. The exception: SQLite accepts the declaration but stores fractional amounts as floating point anyway, so on SQLite, store whole paise.
Round once, in the right place
Picture three invoice lines, each with a taxable value of 99.75 rupees at 18 per cent GST. The tax on each line is 17.955, which rounds to 17.96, so the lines add up to 53.88. Work it on the invoice total instead and 18 per cent of 299.25 is 53.865, which rounds to 53.87. Same goods, same rate, one paisa apart.
Neither answer is wrong. The bug is using both: tax printed per line and posted per invoice, or two systems rounding differently. Splitting GST into central and state halves adds another place to round: on a line of 50.50, each 9 per cent half is 4.545, rounded to 4.55, making 9.10, while 18 per cent of 50.50 is exactly 9.09.
So decide, with whoever signs the accounts: where to round (per line or per invoice), how (half up, which most people expect, or half to even, the default for Python’s round() and for Decimal), and to what (the paisa for lines, often the rupee for the grand total). Then round each figure once, store it rounded, and build totals from stored figures, never from unrounded ones. Rounding twice is a bug of its own: 1.2449 rounded to three places is 1.245, which then rounds up to 1.25, while rounded once it is 1.24.
from decimal import Decimal, ROUND_HALF_UP
PAISA = Decimal("0.01")
def to_paisa(amount):
# Decimal rounds half to even unless told otherwise
return amount.quantize(PAISA, rounding=ROUND_HALF_UP)
lines = [Decimal("99.75")] * 3 # taxable value of each line
rate = Decimal("0.18")
per_line = sum(to_paisa(v * rate) for v in lines)
per_invoice = to_paisa(sum(lines) * rate)
print(per_line, per_invoice) # 53.88 53.87
Leftover paise go somewhere on purpose
Some amounts must be split: a discount across an invoice’s lines, freight across the items it carried, a shared bill across cost centres. Split 100 rupees three ways and each share is 33.33, leaving a paisa that belongs to nobody. It surfaces months later as a difference nobody can trace.
So decide who gets the leftover. Work in whole paise, give each part its share rounded down, then hand out what remains one paisa at a time, largest remainder first. Weight by each line’s value in paise and freight follows value:
def allocate(total_paise, weights):
"""Split whole paise by whole-number weights."""
whole = sum(weights)
shares = [total_paise * w // whole for w in weights]
leftover = total_paise - sum(shares)
order = sorted(range(len(weights)), reverse=True,
key=lambda i: total_paise * weights[i] % whole)
for i in order[:leftover]:
shares[i] += 1
return shares
print(allocate(10000, [1, 1, 1])) # [3334, 3333, 3333]
The parts always add back to the total, and the same inputs always give the same split. Many Indian invoices do the same in their own way: the grand total is rounded to the nearest rupee and the difference shown on a round-off line, posted to its own ledger account. Nothing is hidden: the leftover is accounted for in plain sight, which is the whole idea.
The first test to write
When you inherit a system that handles money, write one test before any other: for every invoice, add up the stored line amounts and compare them with the stored total, to the paisa. Where they disagree, you have found where the rounding happens, and usually that it happens in more places than anyone decided. Fix that, and the paisa stops being a mystery. It goes back to being a paisa.