Lesson 3 · Beginner · 20 min
Compute real property tax penalties with Decimal
See why floats are unsafe for money, then compute 2% monthly penalties with a 36-month cap, rounded correctly.
✓ Last checked: November 2026 · code run, output real, facts checked
In this lesson
- See the rounding errors that floats introduce
- Write a small, reusable function
- Round to the centavo with ROUND_HALF_UP
- Apply a ceiling with min()
RA 7160 charges interest of 2% a month on delinquent real property tax, for at most 36 months; most offices simply call it the penalty. The computation looks simple until the centavos stop matching. Before writing the penalty function, it is worth seeing why money in Python should be handled with the Decimal type and not with ordinary float numbers.
Why not float?
"""Why money should not be stored as float."""
from decimal import Decimal
print(0.10 + 0.20) # float
print(Decimal("0.10") + Decimal("0.20")) # Decimal
# Add one centavo a hundred thousand times
total_float = 0.0
total_decimal = Decimal("0.00")
for _ in range(100_000):
total_float += 0.01
total_decimal += Decimal("0.01")
print(total_float)
print(total_decimal)
0.30000000000000004 0.30 999.9999999992356 1000.00
A float stores numbers in binary, and most decimal fractions like 0.10 cannot be stored exactly. The error is tiny at first, but it builds up over many additions. Decimal keeps the amount exactly as written, which is what a treasury report needs.
The data
Save this as delinquent.csv. The months_late column is the number of months of delinquency, already counted for each tax declaration.
td_number,owner,year,tax_due,months_late
TD-2021-00145,Juan Dela Cruz,2024,1250.75,34
TD-2019-00872,Rosa Mendoza,2022,980.40,58
TD-2024-01130,Santos Bakery,2026,4580.33,10
The code
"""Compute the penalty on delinquent real property tax with exact decimals.
The rule used here: 2% of the unpaid tax for every month of delinquency,
counted up to a ceiling of 36 months (72%), as provided in Section 255 of RA 7160.
"""
import csv
from decimal import Decimal, ROUND_HALF_UP
RATE_PER_MONTH = Decimal("0.02")
MAX_MONTHS = 36
CENTAVO = Decimal("0.01")
def penalty(tax_due: Decimal, months_late: int) -> Decimal:
months = min(months_late, MAX_MONTHS)
amount = tax_due * RATE_PER_MONTH * months
return amount.quantize(CENTAVO, rounding=ROUND_HALF_UP)
print(f"{'TD number':<15}{'Year':>5}{'Tax due':>11}{'Months':>8}{'Penalty':>11}{'Total':>11}")
with open("delinquent.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
tax = Decimal(row["tax_due"])
months = int(row["months_late"])
pen = penalty(tax, months)
shown = f"{min(months, MAX_MONTHS)}" + ("*" if months > MAX_MONTHS else "")
print(f"{row['td_number']:<15}{row['year']:>5}{tax:>11,.2f}{shown:>8}{pen:>11,.2f}{tax + pen:>11,.2f}")
print("* capped at 36 months")
Output
TD number Year Tax due Months Penalty Total TD-2021-00145 2024 1,250.75 34 850.51 2,101.26 TD-2019-00872 2022 980.40 36* 705.89 1,686.29 TD-2024-01130 2026 4,580.33 10 916.07 5,496.40 * capped at 36 months
How it works
def penalty(tax_due, months_late) puts the rule in one place. If the rule changes, you change one function and every report that uses it follows.
min(months_late, MAX_MONTHS) applies the ceiling. A property delinquent for 58 months is charged for 36, the maximum.
quantize(CENTAVO, rounding=ROUND_HALF_UP) rounds to two decimal places the way people round by hand: 916.066 becomes 916.07, and a half centavo always goes up.
Note: This lesson keeps the month count as given. How months are counted (from which date, and how partial months and installments are treated) follows your local revenue code and the applicable rules, so check those before using a script like this for real assessments.
Try it yourself
Practice
Add a discount function for early payment: a percentage off the tax due when the tax is paid in advance. Keep it in Decimal and round it the same way.
Show answer
"""Answer: a discount for paying real property tax in advance.
The rate here (10%) is only an example. RA 7160 lets the sanggunian set a
discount of up to 20%, so use the rate in your own ordinance.
"""
from decimal import Decimal, ROUND_HALF_UP
CENTAVO = Decimal("0.01")
def discount(tax_due: Decimal, rate_percent: Decimal) -> Decimal:
amount = tax_due * rate_percent / 100
return amount.quantize(CENTAVO, rounding=ROUND_HALF_UP)
for tax in (Decimal("1250.75"), Decimal("980.40"), Decimal("4580.33")):
off = discount(tax, Decimal("10"))
print(f"Tax {tax:>9,.2f} discount {off:>7,.2f} to pay {tax - off:>9,.2f}")
Tax 1,250.75 discount 125.08 to pay 1,125.67 Tax 980.40 discount 98.04 to pay 882.36 Tax 4,580.33 discount 458.03 to pay 4,122.30