Enrich a CSV of addresses with Census demographics (Python)
Turn a plain list of US addresses into an enriched table with rooftop coordinates, the 15-digit Census block, and official Census demographics — in about 40 lines of Python, using the AddressCanvas API.
This is the exact workflow behind demographic enrichment, HMDA/fair-lending prep, and market analysis: geocode each address, get its Census geography, then pull the numbers for that geography.
What you'll need
-
A free account at addresscanvas.com and an API key (create one on your dashboard). New accounts get 5,000 geocodes/month plus 150 free credits on first sign-in.
-
Python 3.9+ and
requests(pip install requests). -
A CSV with an
addresscolumn, e.g.:address "1600 Pennsylvania Ave NW, Washington, DC 20500" "782 N Lakewood Ave, Ocoee, FL 34761"
The two calls
Both endpoints are POST /api/v1/..., take {"address": "..."}, and
authenticate with an X-API-Key: gck_... header.
1. Geocode — resolve the address to a point and its Census block:
curl -X POST "https://addresscanvas.com/api/v1/geocode" \
-H "X-API-Key: gck_your_api_key" \
-H "Content-Type: application/json" \
-d '{"address": "1600 Pennsylvania Ave NW, Washington, DC 20500"}'
{
"matchCode": "BUILDING_PARCEL",
"locationCode": "ROOFTOP",
"lat": 38.89594,
"lon": -77.037419,
"fips": "11001",
"censusBlock": "110019800001034"
}
That censusBlock is a 2020 GEOID: state (2) + county (3) + tract (6) + block
(4). Slice it for tract or block group.
2. Demographics — pull Census/ACS data for that address (20 credits per successful report; nothing is charged when data isn't available):
curl -X POST "https://addresscanvas.com/api/v1/demographics" \
-H "X-API-Key: gck_your_api_key" \
-H "Content-Type: application/json" \
-d '{"address": "1600 Pennsylvania Ave NW, Washington, DC 20500"}'
The script
Save this as enrich.py:
import csv
import os
import sys
import requests
BASE = "https://addresscanvas.com"
HEADERS = {"X-API-Key": os.environ["ADDRESSCANVAS_API_KEY"],
"Content-Type": "application/json"}
def post(path, address):
r = requests.post(f"{BASE}{path}", headers=HEADERS,
json={"address": address}, timeout=30)
r.raise_for_status()
return r.json()
def enrich(address):
geo = post("/api/v1/geocode", address)
row = {"lat": geo.get("lat"), "lon": geo.get("lon"),
"census_block": geo.get("censusBlock"), "fips": geo.get("fips")}
# Only spend credits on demographics if the address actually matched.
if geo.get("matchCode") not in (None, "NO_MATCH"):
demo = post("/api/v1/demographics", address)
if demo.get("available"):
row["population"] = demo["population"]["total"]
row["median_household_income"] = demo["income"]["medianHouseholdIncome"]
return row
def main(in_path, out_path):
with open(in_path, newline="", encoding="utf-8") as src:
rows = list(csv.DictReader(src))
extra = ["lat", "lon", "census_block", "fips",
"population", "median_household_income"]
with open(out_path, "w", newline="", encoding="utf-8") as dst:
writer = csv.DictWriter(dst, fieldnames=list(rows[0]) + extra)
writer.writeheader()
for row in rows:
row.update(enrich(row["address"]))
writer.writerow(row)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
The demographics response nests its figures by topic: the population is
population.total and the income is income.medianHouseholdIncome. The same
response carries education, housing, raceEthnicity, and a block group /
tract / county comparison.
Run it over a file:
export ADDRESSCANVAS_API_KEY=gck_your_api_key
python enrich.py addresses.csv enriched.csv
Notes & good habits
- A no-match is an HTTP 200, not an error — check
matchCode == "NO_MATCH"(with anoMatchReason) rather than relying on the status code. - Guard your credits. The script only calls
/demographicswhen the geocode matched, so you never spend credits on an address that didn't resolve. - You own the output. Unlike Google Maps, there's no restriction on storing the coordinates or GEOIDs you get back — write them to your warehouse and keep them.
- Larger files. For thousands of rows, geocode 100 addresses per call with
POST /api/v1/geocode/batch; Geocode a large address list shows the pattern. Reports are still one request per address.
Where to go next
- Swap
/demographicsfor/demographic-trend,/schools,/hazards,/crime, or/property-intelto enrich with other datasets on the same address. - See per-state rooftop coverage at
/coverage/states.