DEV Community

anders lee
anders lee

Posted on

What I learned turning government property tax spreadsheets into one county dataset

I wanted a simple answer to a simple question: how much property tax does a typical homeowner pay in each U.S. county, and what are the actual local rates behind it?

The data exists, but it's spread across the Census Bureau and fifty state revenue departments, each with its own format. I started with the national picture plus three states — Texas, North Carolina and Florida — and ended up with five clean CSVs: 3,133 counties, 51 states, 4,849 Texas taxing units, 690 North Carolina county/municipal rate rows and 67 Florida counties.

Here are the parts that took longer than expected.

1. The national layer: Census ACS, and the values that aren't numbers

The American Community Survey (5-year estimates, 2019–2023) has everything needed for an "effective rate":

  • B25103 – median real estate taxes paid
  • B25077 – median value of owner-occupied homes
  • B25119 – median household income by tenure (owners only)
  • B25003 / B01003 – tenure and population

Effective rate = median tax ÷ median home value. Easy — until you sort by it.

Sixteen counties report a median tax of exactly 10001, and seven report exactly 199. Those aren't measurements. The ACS caps this table: 10001 means "$10,000 or more" and 199 means "less than $200". If you treat them as numbers, Marin County and most of northern New Jersey look like they pay the same bill, and their effective rates are understated.

I kept the numeric value (so the column stays numeric) and added a flag:

flag = "10000+" if med_tax >= 10001 else "<200" if med_tax <= 199 else ""
Enter fullscreen mode Exit fullscreen mode

For those rows the effective rate is a bound, not an estimate. Worth checking for in any ACS median you use — home value and income tables have their own caps.

Connecticut is the other trap. Since 2022 the Census uses Connecticut's nine planning regions as county equivalents instead of the eight historical counties, so FIPS codes from older shapefiles won't join.

2. Texas: one spreadsheet layout per year

The Texas Comptroller publishes every taxing unit's rate — counties, cities, school districts, hospital districts, MUDs, emergency services districts. That's what makes Texas bills hard to estimate: a single address can sit in six or seven units.

Two surprises:

  • The 2024 and 2025 files don't share a layout. Columns moved between years, so where a file has a usable header row I look columns up by header name instead of position:
idx = {h: i for i, h in enumerate(header_row)}
rate = row[idx["TOTAL COUNTY TAX RATE"]]
Enter fullscreen mode Exit fullscreen mode
  • Unit IDs changed shape too. The 2024 total-rate file identifies each taxing unit with a five-part ID; the 2025 file uses three parts. Joining on the first three parts of the old ID matched units year over year:
key = "-".join(str(unit_id).split("-")[:3])
Enter fullscreen mode Exit fullscreen mode

I also derive a unit_type (county, school, city, special, emergency, hospital, college) from the unit's name and ID, because the file doesn't label it. And 347 units have a rate of exactly 0 — mostly utility districts that exist on paper but levied nothing this year. Keep them, but exclude them before computing averages.

3. North Carolina: per $100, plus an "effective" rate

North Carolina's Department of Revenue publishes county and municipal rates per $100 of value, the combined rate, and an effective combined rate adjusted by each county's sales-assessment ratio. That last column matters: counties reappraise on different cycles (some last did it in 2018), so nominal rates aren't comparable across counties — effective ones are closer.

Small thing: the source writes place names like Mcadenville and Mcdonald. One regex fixed it:

name = re.sub(r"\bMc([a-z])", lambda m: "Mc" + m.group(1).upper(), name)
Enter fullscreen mode Exit fullscreen mode

4. Florida: mills, and one county with no county tax

Florida reports millage — dollars per $1,000 — split into county operating, county debt, school operating, school debt, dependent and independent special districts, plus levies that only cover part of a county (MSTUs, cities) expressed as a countywide equivalent.

Mixing units is the easiest mistake in this whole project: Texas and North Carolina are per $100, Florida per $1,000. I kept the source unit in every column name (rate_per_100_2025, total_mills) rather than converting silently.

And Duval County shows a county operating millage of zero. That's not missing data: Jacksonville and Duval are consolidated, so the levy appears as a municipal rate instead.

5. What the data says

A few results once everything lined up:

  • The median U.S. homeowner pays $2,969 a year, about 0.98% of home value.
  • State medians range from 0.27% (Hawaii) to 2.23% (New Jersey).
  • Log home value vs. log tax bill correlates at about 0.66 — strong, but the scatter around the line is where state policy shows up.
  • Northern New Jersey and the New York suburbs dominate the "share of income" ranking: around 7% of owner household income goes to property tax.

Takeaways for working with government data

  1. Look for sentinel values before sorting or averaging. Round numbers at the edges of a distribution are suspicious.
  2. Put the unit in the column name. Per $100, per $1,000 and percent all look like small decimals.
  3. Match on headers, not positions, when a publisher has more than one year of files.
  4. Treat zeros and blanks differently and document which is which.
  5. Read the footnotes. Duval, Connecticut and the ACS caps were all documented — just not in the spreadsheet itself.

The compiled CSVs are public under CC BY 4.0 as the U.S. County Property Tax Rates dataset on Kaggle and Hugging Face. If you just want to look up a county or estimate a bill, I built the calculators on top of it at countypropertytaxes.com.

Top comments (0)