Read Property Tax History Without Confusing Assessed Value and Market Value
Turn the taxHistory array into a clear table of tax paid, assessed value and yearly changes, and avoid treating an assessment as a market price or a complete tax record.
What the tax history in a response contains
A property response can include taxHistory, an array of yearly entries taken from the property's public Zillow page. Each entry has five fields: year, taxPaid, value, taxIncreaseRate and valueIncreaseRate. The same response also has three summary fields: taxAnnualAmount, taxAssessedValue and propertyTaxRate. Together they let you show how a home's tax bill and assessment moved over the years the source lists.
They do not make the response an official tax record. The years shown can be incomplete, a year can be missing, and the array can be empty, which is common for new construction and apartment buildings. For a closing, an appeal or a legal question, the county assessor and tax collector remain the authority. This guide is about reading the returned entries correctly, not about tax advice for any jurisdiction.
The API also does not return owner names or mailing addresses. Tax history here describes the parcel's bill and assessment, not who paid it.
| Field | What it is | Unit |
|---|---|---|
| taxHistory[].year | The tax year of the entry | Calendar year |
| taxHistory[].taxPaid | Tax amount the source lists for that year | Dollars |
| taxHistory[].value | Assessed value for that year | Dollars |
| taxHistory[].taxIncreaseRate | Change in tax paid from the prior entry | Fraction, so multiply by a hundred for percent |
| taxHistory[].valueIncreaseRate | Change in assessed value from the prior entry | Fraction |
| taxAnnualAmount | Annual tax shown in the listing facts | Dollars |
| taxAssessedValue | Assessed value shown in the listing facts | Dollars |
| propertyTaxRate | Tax rate shown by the source | Percent |
Assessed value is not market value
The value field in each tax entry is an assessed value, a number a local government uses to compute tax. How it relates to market value depends on the jurisdiction. Cook County, Illinois, for example, defines assessed value as fair market value multiplied by an assessment level set for the property's class, and residential property is assessed at a small fraction of its market value. Texas appraisal districts, by contrast, appraise property at market value as of January first each year, and local taxing units then set rates. Other states cap annual increases, apply exemptions or reassess only when a home sells.
So an assessed value can be close to a listing price in one county and a small fraction of it in the next. Neither case says anything about what the home would sell for today. Never put taxHistory values on the same chart axis as price, priceHistory events or zestimate without labeling them as different measures. Never compute a gain or loss by comparing an assessment with a sale price.
In our own recorded test responses, the top-level taxAssessedValue and the latest taxHistory value did not always agree, and in one condo response they differed by an order of magnitude. That is a reason to pick one source per analysis, label it, and avoid mixing the summary field with the yearly array.
Read the change rates and the tax rate correctly
taxIncreaseRate and valueIncreaseRate are fractions, not percents. A value of a twentieth means the amount rose by five percent from the previous entry. Multiply by a hundred before you print a percent sign. A null rate means the source supplied no rate, often for the oldest year shown, and should stay null rather than becoming zero.
propertyTaxRate is different: it is already a percent. In our recorded test responses, homes in the same city shared one value, which suggests an area-level rate rather than this home's own bill, so do not expect to reproduce it by dividing one year's tax by one year's assessed value. Because assessment levels vary, tax divided by assessed value can look absurdly high in counties that assess at a fraction of market value. If you show that ratio, call it tax as a share of assessed value and compare it only within the same jurisdiction.
A large jump in assessed value from one year to the next often follows a reassessment cycle, a sale, new construction or an expired exemption. The response does not say which. Present the jump as an observation and leave the explanation to someone who can check the local record.
Build a labeled tax-history table
The script below reads one saved property response and prints a table with tax paid, assessed value, both change rates and tax as a share of assessed value. It sorts by year, keeps entries without a year at the end, and prints n/a for anything missing. The column names say assessed, never value, so a reader cannot mistake the column for a price.
Save a response first with the property endpoint. Use a Zillow link or a full address. The key stays in your environment, and the saved file includes meta.fetched_at, which the script prints as the collection time.
"""tax_history.py: print a labeled tax-history table from one saved property response."""
import json, sys
def pct(rate):
# taxIncreaseRate and valueIncreaseRate are fractions of the prior year (0.05 means 5%)
return "n/a" if rate is None else f"{rate * 100:+.1f}%"
def money(v):
return "n/a" if v is None else f"${v:,.0f}"
def tax_rows(data):
rows = []
for e in data.get("taxHistory") or []:
rows.append({
"year": e.get("year"),
"tax_paid": e.get("taxPaid"),
"assessed_value": e.get("value"), # the jurisdiction's assessed value, not a market value
"tax_change": e.get("taxIncreaseRate"),
"assessment_change": e.get("valueIncreaseRate"),
})
# newest first; entries without a year go last instead of being guessed
return sorted(rows, key=lambda r: (r["year"] is None, -(r["year"] or 0)))
def effective_rate(row):
# tax paid as a share of the ASSESSED value for that year; compare only within one jurisdiction
if row["tax_paid"] is None or not row["assessed_value"]:
return None
return row["tax_paid"] / row["assessed_value"]
if __name__ == "__main__":
body = json.load(open(sys.argv[1], encoding="utf-8"))
data = body["data"]
print(data.get("address"), "| collected", body["meta"].get("fetched_at"))
print("Summary fields: taxAnnualAmount", money(data.get("taxAnnualAmount")),
"| taxAssessedValue", money(data.get("taxAssessedValue")),
"| propertyTaxRate", data.get("propertyTaxRate"), "(percent, as the source reports it)")
rows = tax_rows(data)
if not rows:
print("No tax history returned. That is not evidence that no tax was paid.")
print(f"{'Year':<6}{'Tax paid':>12}{'Assessed':>14}{'Tax chg':>9}{'Assess chg':>11}{'Tax/assessed':>14}")
for r in rows:
eff = effective_rate(r)
print(f"{str(r['year'] or 'n/a'):<6}{money(r['tax_paid']):>12}{money(r['assessed_value']):>14}"
f"{pct(r['tax_change']):>9}{pct(r['assessment_change']):>11}"
f"{'n/a' if eff is None else f'{eff:.2%}':>14}")
Save one response and run the script
The request below saves a single property response. A live lookup costs one credit and a repeat within the cache window is free; check the meta object to see which happened. The example output underneath is illustrative. It is shaped like a real county that assesses at a fraction of market value, which is why the last column looks surprisingly large.
curl --get "https://api.propertiesapi.com/v1/zillow/property" \
--header "x-api-key: $PROPERTIESAPI_KEY" \
--data-urlencode "url=$ZILLOW_URL" \
--max-time 130 -o property.json
python tax_history.py property.json
# Illustrative output (made-up numbers, shaped like a real response)
Year Tax paid Assessed Tax chg Assess chg Tax/assessed
2025 $10,950 $54,520 -9.7% -5.1% 20.08%
2024 $12,120 $57,430 +2.6% +0.0% 21.10%
# A "rate" near 20% is not a tax rate here: this county assesses homes at a
# fraction of market value, so tax divided by assessed value looks huge.Present tax history honestly
A good tax-history view answers three questions without overreaching: how the bill changed, how the assessment changed, and how current the information is. It does not claim to show what the owner will pay next year, because exemptions, caps and rate changes can reset after a sale.
If you show tax history on a listing or research page, put the collection date next to it and link to the source page. Say no tax history returned when the array is empty instead of no taxes. When users ask what a buyer would pay, point them to the local tax office, since a buyer's bill can differ from the current owner's.
- Label the column assessed value, and say that its relation to market value depends on the jurisdiction.
- Convert the change rates from fractions to percents in one place, and test that conversion.
- Keep taxAnnualAmount and taxAssessedValue separate from the yearly array, and choose one per analysis.
- Show missing years and null amounts as missing.
- Keep tax history and priceHistory in separate tables or clearly separate series.
Where tax history fits in a property review
Tax history is a useful cost signal for a shortlist: it shows the size of the recurring bill and whether it has been rising. Pair it with monthlyHoaFee for condos and with your own insurance and maintenance assumptions when you estimate holding costs. Keep those assumptions in your own columns so nobody mistakes them for returned data.
For the market side of the story, read listing events from priceHistory and treat the Zestimate as Zillow's estimate. The price history guide explains how to turn listing events into a timeline without filling gaps.
Sources and documentation
Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.
- PropertiesAPI property fields: taxHistory, taxAnnualAmount, taxAssessedValue, propertyTaxRate
- Cook County Assessor glossary: assessed value and assessment level
- Texas Comptroller: property tax basics
Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.