Most mortgage calculators you find online are quietly wrong for Canada, and most property tax snippets ignore the edge cases that matter. I needed both for a side project about British Columbia housing costs, so I wrote them from scratch in plain Python. Here's the code, the two gotchas that trip people up, and the output.

The rules we're modelling

BC property transfer tax (PTT) is charged on a home's fair market value in brackets:

Portion of price Rate
First $200,000 1%
$200,000 to $2,000,000 2%
Above $2,000,000 3%
Residential value above $3,000,000 extra 2%

The first-time home buyer exemption (for properties registered on or after April 1, 2024) removes the tax entirely up to $500,000, gives a flat $8,000 off up to $835,000, and phases that $8,000 out linearly to zero at $860,000.

Canadian mortgage payments use a rate that compounds semi-annually, by law, for fixed-rate mortgages. That's gotcha number one.

The stress test qualifies borrowers at the greater of 5.25% or their contract rate plus 2 points. That's not a gotcha, but it changes how much people can borrow far more than the rate itself.

The code

from dataclasses import dataclass def ptt(price: float) -> float: """BC general property transfer tax on fair market value.""" brackets = [(200_000, 0.01), (2_000_000, 0.02), (float("inf"), 0.03)] tax, lower = 0.0, 0.0 for upper, rate in brackets: if price > lower: tax += (min(price, upper) - lower) * rate lower = upper if price > 3_000_000: # additional 2% on residential value above $3M tax += (price - 3_000_000) * 0.02 return tax def fthb_exemption(price: float) -> float: """First-time home buyer exemption, properties registered on or after April 1, 2024.""" if price <= 500_000: return ptt(price) if price <= 835_000: return 8_000 if price < 860_000: return 8_000 * (860_000 - price) / 25_000 return 0.0 def monthly_rate(annual: float) -> float: """Canadian fixed mortgages compound semi-annually, not monthly.""" return (1 + annual / 2) ** (1 / 6) - 1 def payment(principal: float, annual: float, years: int = 25) -> float: r, n = monthly_rate(annual), years * 12 return principal * r / (1 - (1 + r) ** -n) def stress_rate(contract: float) -> float: return max(0.0525, contract + 0.02) if __name__ == "__main__": for p in (500_000, 835_000, 847_500, 1_000_000, 3_500_000): print(f"{p:>10,} PTT {ptt(p):>9,.0f} FTHB pays {ptt(p) - fthb_exemption(p):>9,.0f}") L = 600_000 for rate in (0.04, 0.045): naive = L * (rate/12) / (1 - (1 + rate/12) ** -300) print(f"{rate:.2%}: Canadian {payment(L, rate):,.2f} US-style {naive:,.2f} stress {payment(L, stress_rate(rate)):,.2f}") 

Gotcha 1: brackets are marginal, not flat

A common bug is applying the top bracket's rate to the whole price. The loop above walks the brackets and only taxes the slice of the price inside each one. On a $1,000,000 home that's $2,000 on the first $200,000 plus 2% of the next $800,000, or $18,000, not 2% of a million.

The extra 2% above $3,000,000 is layered on top rather than replacing the 3% bracket, which is why it sits outside the loop.

Gotcha 2: semi-annual compounding

US-style calculators convert an annual rate to a monthly one by dividing by 12. For a Canadian fixed mortgage the equivalent monthly rate is:

r = (1 + annual / 2) ** (1 / 6) - 1 

The difference looks small, but it's systematic:

4.00%: Canadian 3,156.12 US-style 3,167.02 stress 3,838.84 4.50%: Canadian 3,320.84 US-style 3,334.99 stress 4,018.94 

On a $600,000 mortgage over 25 years, the US formula overstates the payment by about $11 to $14 a month, which over 300 payments is more than $3,000 of phantom cost. Worse, if you're backing out "how much can I borrow" from a payment, the error compounds in the other direction.

Output

 500,000 PTT 8,000 FTHB pays 0 835,000 PTT 14,700 FTHB pays 6,700 847,500 PTT 14,950 FTHB pays 10,950 1,000,000 PTT 18,000 FTHB pays 18,000 3,500,000 PTT 93,000 FTHB pays 93,000 

Two rows are worth a second look:

  • At $847,500, halfway through the phase-out, the exemption is $4,000, so a first-time buyer pays $10,950. Moving $25,000 up in price through the phase-out band adds $8,000 of tax on top of the normal 2%, an effective marginal rate of 34%. That's a real cliff that buyers near the threshold should know about.
  • At $3,500,000, the 3% bracket and the extra 2% stack to 5% on the top $500,000.

Testing the edges

Boundary values are where tax code breaks. A few assertions catch most mistakes:

assert ptt(200_000) == 2_000 assert ptt(2_000_000) == 38_000 assert ptt(3_000_000) == 68_000 assert fthb_exemption(860_000) == 0 assert round(payment(600_000, 0.045), 2) == 3320.84 assert stress_rate(0.02) == 0.0525 

Where I used it

These functions power cost estimates in a few housing explainers I've been writing, such as a breakdown of BC property transfer tax at ten price points and a look at mortgage renewals in 2026. If you'd rather not run code, EstateBlock shows sold prices and neighbourhood data for BC listings, which is the other half of figuring out what a home really costs.

Rates and thresholds change, so treat the constants as configuration and check them against the Government of BC and your lender before relying on the numbers.