#!/usr/bin/env python3 """ count_nashville.py — Sterling Digital Partners field study No. 06 Nashville and Davidson County, Tennessee: the address is not the government. Reproduces EVERY figure published at https://glvtl.com/field-study/nashville-commercial-record/ Standard library only. No API key, no login, no fee. python3 count_nashville.py # count and print python3 count_nashville.py --csv out.csv # also write the counts CSV python3 count_nashville.py --cache DIR # cache the raw pull in DIR Source, read 20 September 2026: maps.nashville.gov/arcgis/rest/services/Cadastral/Parcels/MapServer/0 "Ownership Parcels", 287,112 parcels, 56 fields. FOUR TRAPS THIS SCRIPT EXISTS TO HANDLE. Every one of them fails SILENTLY — no error, just a wrong number: 1. TaxDist carries trailing whitespace on 43,132 rows, 15.0% of the county. `TaxDist = 'USD'` misses 34,046 parcels that are in the Urban Services District. Always strip before comparing. The raw column holds 22 distinct values; there are only 15 districts. 2. LUCode is a ZERO-PADDED STRING and is not numeric. '011' is single family, '032' is office. One value, '80M', contains a letter. Any numeric cast or range comparison silently drops rows or raises. 3. PropDate is NOT the transfer date. It runs back to 1849 and disagrees with OwnDate on the year for 261,740 of 287,061 rows — 91.2%. OwnDate is the one that tracks the deed. Getting this wrong wrecks every date-filtered figure without erroring. 4. LUDesc is not a usable grouping key in either direction. Misspellings split codes ('WARHOUSE' is the MAJORITY spelling of code 077), and five separate descriptions are each shared by two different codes, so grouping on the label both splits and merges. Group on LUCode, always. A NOTE ON WHAT "COMMERCIAL" MEANS HERE. We do not use the land use code, because finding 03 shows it cannot carry that weight. We use the county's own constitutional classification: Tennessee assesses commercial and industrial real property at 40% of appraised value and residential and farm property at 25% (Tenn. Const. art. II, s 28; Tenn. Code Ann. s 67-5-801). A parcel whose assessed value is 40% of its appraised value has been classified commercial or industrial by the assessor. That is the county's judgement, not ours. THE RECORD IS REBUILT NIGHTLY. Nothing counted here can be reproduced for a past date by anyone, including us. That is why the counts CSV is published beside this script. Licence: CC BY 4.0. Attribute to Sterling Digital Partners with a link to the study above. """ import argparse, csv, datetime, json, os, sys, time import urllib.parse, urllib.request from collections import Counter, defaultdict UTC = datetime.timezone.utc LAYER = ("https://maps.nashville.gov/arcgis/rest/services" "/Cadastral/Parcels/MapServer/0") FIELDS = ["OBJECTID", "ParID", "TaxDist", "Council", "ParType", "PropCity", "PropAddr", "PropHouse", "PropStreet", "PropZip", "LUCode", "LUDesc", "OwnDate", "PropDate", "SaleCode", "SaleSrc", "ValidSale", "SalePrice", "OwnInstr", "OwnAddr1", "LandAppr", "ImprAppr", "TotlAppr", "LandAssd", "ImprAssd", "TotlAssd", "Acres", "Zoning", "AssessDate"] # Tennessee statutory assessment ratios, Tenn. Code Ann. s 67-5-801. RATIO_COMMERCIAL = 0.40 # commercial and industrial real property RATIO_RESIDENTIAL = 0.25 # residential and farm property RATIO_TOLERANCE = 0.005 # The separately incorporated municipalities inside Davidson County, by the # TaxDist code the roll uses for each. GSD and USD are Metro's own General and # Urban Services Districts; the BID codes are business improvement districts, # which are not municipalities and are counted separately. SATELLITES = {"BH": "Berry Hill", "BM": "Belle Meade", "FH": "Forest Hills", "OH": "Oak Hill", "GO": "Goodlettsville", "RT": "Ridgetop"} FIGURES = [] # (metric, value, share, unit, finding, note) PAGED_RAW = {} def fig(finding, metric, value, unit="", note="", share=None): sh = "" if share is None else f"{share:.1f}%" FIGURES.append((metric, value, sh, unit, finding, note)) val = f"{value:,}" if isinstance(value, int) else ( f"{value:,.1f}" if isinstance(value, float) else str(value)) print(f" {metric:<64} {val:>12} {unit} {sh}") return value # -------------------------------------------------------------------------- def _get(url, params, tries=4): q = urllib.parse.urlencode(params) last = None for i in range(tries): try: with urllib.request.urlopen(url.rstrip("/") + "/query?" + q, timeout=180) as r: d = json.load(r) if "error" in d: raise RuntimeError(d["error"]) return d except Exception as e: # noqa: BLE001 last = e if i < tries - 1: time.sleep(2 * (i + 1)) raise RuntimeError(f"{url}: {last}") def advertised(where="1=1"): return _get(LAYER, {"where": where, "returnCountOnly": "true", "f": "json"})["count"] def pull(cache=None, page=2000): """Page the layer, de-duplicate on OBJECTID, reconcile against the count. Study No. 05 found a county service that returns 57 rows which do not exist, WITH a stable sort order set. Setting orderByFields is not a defence; reconciling is. Davidson pages exactly, and we check anyway. """ label = "nashville" if cache: path = os.path.join(cache, label + ".json") if os.path.exists(path): raw = json.load(open(path)) PAGED_RAW[label] = len(raw) rows = list({r["OBJECTID"]: r for r in raw}.values()) print(f" {len(raw):,} rows from cache, {len(rows):,} distinct") return rows want, rows, off = advertised(), [], 0 while True: d = _get(LAYER, {"where": "1=1", "outFields": ",".join(FIELDS), "returnGeometry": "false", "orderByFields": "OBJECTID ASC", "resultOffset": off, "resultRecordCount": page, "f": "json"}) feats = d.get("features", []) if not feats: break rows += [f["attributes"] for f in feats] off += len(feats) if off % 50000 < page: print(f" ... {off:,}", flush=True) if len(feats) < page: break raw_rows = rows PAGED_RAW[label] = len(rows) rows = list({r["OBJECTID"]: r for r in rows}.values()) if len(raw_rows) != len(rows): print(f" paged {len(raw_rows):,} rows, " f"{len(raw_rows) - len(rows):,} duplicate OBJECTIDs") if len(rows) != want: raise SystemExit(f"reconciliation FAILED: {len(rows):,} distinct rows " f"against {want:,} advertised") print(f" {len(rows):,} rows, reconciled against returnCountOnly " f"({want:,}) OK") if cache: os.makedirs(cache, exist_ok=True) json.dump(raw_rows, open(os.path.join(cache, label + ".json"), "w")) return rows def year(ms): if not ms: return None return datetime.datetime.fromtimestamp(ms / 1000, UTC).year def s(v): return (v or "").strip() def pct(a, b): return 100.0 * a / b if b else 0.0 def ratio(r): a = r["TotlAppr"] or 0 return (r["TotlAssd"] or 0) / a if a > 0 else None def is_commercial(r): x = ratio(r) return x is not None and abs(x - RATIO_COMMERCIAL) < RATIO_TOLERANCE # -------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser() ap.add_argument("--csv") ap.add_argument("--cache") a = ap.parse_args() print(f"\ncount_nashville.py — run " f"{datetime.datetime.now(UTC):%Y-%m-%d %H:%M} UTC") print("\nPULLING Davidson County, Tennessee.") rows = pull(a.cache) n = len(rows) # ------------------------------------- 01 the address is not the government print("\nFINDING 01 — the address says Nashville, the government does not") fig("01", "Davidson County parcels", n, "parcels") in_sat = 0 for code, name in SATELLITES.items(): sub = [r for r in rows if s(r["TaxDist"]) == code] cities = Counter(s(r["PropCity"]).upper() for r in sub) nash = cities.get("NASHVILLE", 0) in_sat += len(sub) fig("01", f"{name} parcels", len(sub), "parcels") fig("01", f"{name} distinct property-city values", len(cities), "values") fig("01", f"{name} parcels with a NASHVILLE property address", nash, "parcels", "", share=pct(nash, len(sub))) top = cities.most_common(1)[0] FIGURES.append((f"{name} most common property city", top[0], "", "", "01", f"{top[1]} parcels")) fig("01", "Parcels inside a separately incorporated city", in_sat, "parcels", "", share=pct(in_sat, n)) addr_nash = sum(1 for r in rows if s(r["TaxDist"]) in SATELLITES and s(r["PropCity"]).upper() == "NASHVILLE") fig("01", "Parcels in a separate city but addressed NASHVILLE", addr_nash, "parcels", "", share=pct(addr_nash, in_sat)) # --------------------------------------------- 02 the whitespace that hides print("\nFINDING 02 — 15% of the county is invisible to an exact match") raw_vals = Counter(r["TaxDist"] or "" for r in rows) strip_vals = Counter(s(r["TaxDist"]) for r in rows) fig("02", "Distinct TaxDist values as the column stores them", len(raw_vals), "values") fig("02", "Distinct TaxDist values once trimmed", len(strip_vals), "values") padded = [r for r in rows if (r["TaxDist"] or "") != s(r["TaxDist"])] fig("02", "Parcels whose TaxDist carries stray whitespace", len(padded), "parcels", "", share=pct(len(padded), n)) by_code = Counter(s(r["TaxDist"]) for r in padded) for code, cnt in by_code.most_common(): whole = strip_vals[code] fig("02", f"TaxDist {code or '(blank)'} parcels missed by an exact match", cnt, "parcels", f"of {whole:,} in that district", share=pct(cnt, whole)) # ------------------------------------------------ 03 the label lies, twice print("\nFINDING 03 — the land use label splits codes and merges them") codes = {s(r["LUCode"]) for r in rows if s(r["LUCode"])} descs = {s(r["LUDesc"]) for r in rows if s(r["LUDesc"])} fig("03", "Distinct land use codes", len(codes), "codes") fig("03", "Distinct land use descriptions", len(descs), "descriptions") nonnum = sorted(c for c in codes if not c.isdigit()) fig("03", "Land use codes that are not numeric", len(nonnum), "codes", "values: " + ", ".join(nonnum)) code_to_desc = defaultdict(Counter) desc_to_code = defaultdict(set) for r in rows: c, d = s(r["LUCode"]), s(r["LUDesc"]) if c and d: code_to_desc[c][d] += 1 desc_to_code[d].add(c) split = {c: v for c, v in code_to_desc.items() if len(v) > 1} merged = {d: v for d, v in desc_to_code.items() if len(v) > 1} fig("03", "Codes written under more than one description", len(split), "codes") for c, v in sorted(split.items()): spellings = v.most_common() minority = sum(cnt for _, cnt in spellings[1:]) fig("03", f"Code {c} parcels under a minority spelling", minority, "parcels", "spellings: " + "; ".join( f"{d!r} {cnt:,}" for d, cnt in spellings)) # Publish every spelling of every split code by name, so the page can # quote a spelling count without deriving it, and add the misspellings # that do NOT split a code because they are the only spelling used. for c, v in sorted(split.items()): for d, cnt in v.most_common(): fig("03", f"Code {c} parcels spelled {d!r}", cnt, "parcels") MISSPELLINGS = {"VACANT RESIENTIAL LAND": "VACANT RESIDENTIAL LAND", "TERMINAL/DISTRIBUTION WARHOUSE": "TERMINAL/DISTRIBUTION WAREHOUSE", "RESTURANT/CAFETERIA": "RESTAURANT/CAFETERIA"} mis_total = 0 for bad, good in sorted(MISSPELLINGS.items()): cnt = sum(1 for r in rows if s(r["LUDesc"]) == bad) mis_total += cnt fig("03", f"Parcels described {bad!r}", cnt, "parcels", f"appears to be a misspelling of {good!r}") fig("03", "Parcels carrying a misspelled land use description", mis_total, "parcels", "", share=pct(mis_total, n)) fig("03", "Descriptions shared by more than one code", len(merged), "descriptions") # The 08x block is the RURAL twin of the 01x urban/suburban block, not a # greenbelt classification: greenbelt requires a 15-acre minimum and most # of these parcels are nowhere near it. We publish the check that killed # our own first hypothesis. rural = [r for r in rows if s(r["LUCode"]).startswith("08")] big = sum(1 for r in rural if (r["Acres"] or 0) >= 15) fig("03", "Parcels on an 08x rural land use code", len(rural), "parcels") fig("03", "08x parcels reaching the 15-acre greenbelt minimum", big, "parcels", "greenbelt requires 15 acres; most of the block cannot " "qualify, which is why 08x is not a greenbelt classification", share=pct(big, len(rural))) for d, cs in sorted(merged.items()): total = sum(code_to_desc[c][d] for c in cs) fig("03", f"Parcels described {d!r}", total, "parcels", "codes: " + ", ".join(sorted(cs))) # ------------------------------- 04 one code, two constitutional classes print("\nFINDING 04 — one code, two constitutional classifications") def band(r): x = ratio(r) if x is None: return "no appraised value" if abs(x - RATIO_COMMERCIAL) < RATIO_TOLERANCE: return "40% commercial/industrial" if abs(x - RATIO_RESIDENTIAL) < RATIO_TOLERANCE: return "25% residential/farm" if x == 0: return "assessed at zero" return "other ratio" whole = Counter(band(r) for r in rows) LABEL = {"40% commercial/industrial": "Parcels assessed at the 40% commercial and industrial ratio", "25% residential/farm": "Parcels assessed at the 25% residential and farm ratio", "assessed at zero": "Parcels carrying an appraised value but assessed at zero", "no appraised value": "Parcels carrying no appraised value", "other ratio": "Parcels assessed at neither statutory ratio"} for k, v in whole.most_common(): fig("04", LABEL[k], v, "parcels", "", share=pct(v, n)) # The constitutional rule is a count of RENTAL units, not of dwellings: # Tenn. Const. art. II, s 28(c) defines residential property containing # two or more rental units as industrial and commercial property. So the # 25%/40% split should appear at the duplex and essentially nowhere above # it, because a duplex is the only structure where owner-occupancy can # drop the rental count to one. We print the ladder and let it be checked. LADDER = [("011", "single family"), ("012", "duplex"), ("013", "triplex"), ("014", "quadplex")] for c, label in LADDER: sub = [r for r in rows if s(r["LUCode"]) == c] b = Counter(band(r) for r in sub) fig("04", f"Code {c} ({label}) assessed at the 25% residential ratio", b["25% residential/farm"], "parcels", f"of {len(sub):,} parcels", share=pct(b["25% residential/farm"], len(sub))) fig("04", f"Code {c} ({label}) assessed at the 40% commercial ratio", b["40% commercial/industrial"], "parcels", f"of {len(sub):,} parcels", share=pct(b["40% commercial/industrial"], len(sub))) # Owner-occupancy proxy: does the owner's mailing address match the # property address? A proxy, not a determination of occupancy, and the # page says so. def norm(v): # PropStreet stores INTERNAL double spaces ("GRACELAND DR") while # PropAddr and OwnAddr1 use single ones. Collapse runs of whitespace # on both sides or nothing ever matches. return " ".join((v or "").upper().split()) def owner_occupied(r): pa, oa = norm(r["PropAddr"]), norm(r["OwnAddr1"]) return bool(pa) and bool(oa) and pa == oa dup = [r for r in rows if s(r["LUCode"]) == "012" and s(r["OwnAddr1"]) and s(r["PropAddr"])] fig("04", "Duplex parcels with both a property and an owner address", len(dup), "parcels") for lab, key in (("25% residential", "25% residential/farm"), ("40% commercial", "40% commercial/industrial")): side = [r for r in dup if band(r) == key] oo = sum(1 for r in side if owner_occupied(r)) fig("04", f"Duplexes at {lab} whose owner address is the property address", oo, "parcels", f"of {len(side):,} on that side", share=pct(oo, len(side))) mixed = [] for c in sorted(codes): sub = [r for r in rows if s(r["LUCode"]) == c] b = Counter(band(r) for r in sub) comm, res = b["40% commercial/industrial"], b["25% residential/farm"] if comm >= 25 and res >= 25: mixed.append((c, code_to_desc[c].most_common(1)[0][0], comm, res, len(sub))) fig("04", "Land use codes split across both statutory ratios", len(mixed), "codes", "at least 25 parcels on each side") for c, d, comm, res, tot in sorted(mixed, key=lambda t: -(t[2] + t[3])): fig("04", f"Code {c} ({d}) assessed at 40%", comm, "parcels", f"of {tot:,} parcels on the code", share=pct(comm, tot)) fig("04", f"Code {c} ({d}) assessed at 25%", res, "parcels", f"of {tot:,} parcels on the code", share=pct(res, tot)) # ---------------------------------------------- 05 two dates, one is wrong print("\nFINDING 05 — two date columns, and they disagree on 91% of rows") both = [r for r in rows if r["PropDate"] and r["OwnDate"]] fig("05", "Parcels carrying both a PropDate and an OwnDate", len(both), "parcels") dis = sum(1 for r in both if year(r["PropDate"]) != year(r["OwnDate"])) fig("05", "Parcels where the two dates disagree on the year", dis, "parcels", "", share=pct(dis, len(both))) pyears = [year(r["PropDate"]) for r in rows if r["PropDate"]] oyears = [year(r["OwnDate"]) for r in rows if r["OwnDate"]] fig("05", "Earliest PropDate year", min(pyears), "") fig("05", "Earliest OwnDate year", min(oyears), "") fig("05", "Parcels whose PropDate falls before 1900", sum(1 for y in pyears if y < 1900), "parcels") # --------------------------------------------- 06 the sale record itself print("\nFINDING 06 — what the sale record does and does not carry") vs = Counter(s(r["ValidSale"]) for r in rows) populated = n - vs[""] fig("06", "Parcels where the field named ValidSale is populated", populated, "parcels", "of %d on the roll" % n, share=pct(populated, n)) fig("06", "Parcels where ValidSale is empty", vs[""], "parcels", "", share=pct(vs[""], n)) for k, v in sorted(vs.items()): if k: fig("06", f"ValidSale = {k!r}", v, "parcels") sc = Counter(s(r["SaleCode"]) for r in rows) fig("06", "Distinct sale codes on the roll", len([k for k in sc if k]), "codes", "meaning not published by the county") fig("06", "Parcels with no sale code at all", sc[""], "parcels", "", share=pct(sc[""], n)) comm = [r for r in rows if is_commercial(r)] fig("06", "Parcels classified commercial or industrial by the assessor", len(comm), "parcels", "assessed at 40% of appraised value", share=pct(len(comm), n)) nop = sum(1 for r in comm if not (r["SalePrice"] or 0)) fig("06", "Commercial parcels carrying no sale price at all", nop, "parcels", "", share=pct(nop, len(comm))) priced = [r for r in comm if (r["SalePrice"] or 0) > 0] fig("06", "Commercial parcels carrying a sale price", len(priced), "parcels") pc = Counter(s(r["SaleCode"]) for r in priced) fig("06", "Priced commercial parcels coded Q, the most common code", pc.get("Q", 0), "parcels", "meaning of Q not published", share=pct(pc.get("Q", 0), len(priced))) recent = [r for r in comm if (y := year(r["OwnDate"])) and y >= 2021] fig("06", "Commercial parcels last transferred in 2021 or later", len(recent), "parcels") rc = Counter(s(r["SaleCode"]) for r in recent) fig("06", "Commercial parcels transferred since 2021 and coded Q", rc.get("Q", 0), "parcels", "", share=pct(rc.get("Q", 0), len(recent))) for code, cnt in sc.most_common(12): if code: FIGURES.append((f"Sale code {code} / parcels", cnt, f"{pct(cnt, n):.1f}%", "parcels", "06", "meaning not published by the county")) # --------------------------------------------- 07 the address that is not print("\nFINDING 07 — addresses that are not addresses") house = Counter(s(r["PropHouse"]) for r in rows) fig("07", "Parcels whose house number is the placeholder 0", house.get("0", 0), "parcels", "", share=pct(house.get("0", 0), n)) fig("07", "Parcels with no house number at all", house.get("", 0), "parcels") addrs = Counter(s(r["PropAddr"]).upper() for r in rows if s(r["PropAddr"])) shared = sum(v for k, v in addrs.items() if v > 1) fig("07", "Parcels sharing a property address with another parcel", shared, "parcels", "", share=pct(shared, n)) fig("07", "Distinct property addresses on the roll", len(addrs), "addresses") # ------------------------------------------------------------------ out print(f"\n{len(FIGURES)} figures produced.") if a.csv: with open(a.csv, "w", newline="") as fh: w = csv.writer(fh) w.writerow(["metric", "value", "share", "unit", "finding", "note"]) for row in FIGURES: w.writerow(row) print(f"Wrote {a.csv}") if __name__ == "__main__": main()