Fixing Census Permit Files With Two Header Rows
On this page
Problem statement
The Census Bureau's building permit files look like CSV and are not quite. The first header row names the size classes, the second names the columns under them, the third line is blank, and the data rows have one more field than the header. pd.read_csv with its defaults reads the first row as the header and the second as data, turns every count into text, quietly moves the survey date into the index, and drops the leading zeros of the state and county codes. Nothing raises an error. This page shows each of those on the 2025 county file, the fix, and two further traps in older files and long names.
Quick answer
Skip the first three lines, supply the 30 column names, and read the codes as strings:
d = pd.read_csv(Z.open("co2025a.txt"), skiprows=3, header=None, names=COLS, skipinitialspace=True,
dtype={"survey": str, "state": str, "county": str, "name": str})
print(d.shape, d.est1_units.dtype, d[["state", "county", "name"]].iloc[0].tolist())
(3029, 30) int64 ['01', '001', 'Autauga County ']
3,029 rows, 30 columns, integer counts, and the codes 01 and 001 intact. The setup below wraps this in permits(), which also strips the 30-character padding from names.
What a plain read_csv does
1. The default read
naive = pd.read_csv(Z.open("co2025a.txt"))
print(naive.shape)
print(list(naive.columns[:10]))
print(naive.iloc[:2, :8].to_string())
(3030, 29)
['Survey', 'FIPS', 'FIPS.1', 'Region', 'Division', 'County', 'Unnamed: 6', '1-unit', 'Unnamed: 8', 'Unnamed: 9']
Survey FIPS FIPS.1 Region Division County Unnamed: 6 1-unit
Date State County Code Code Name Bldgs Units Value
2025 01 001 3 6 Autauga County 200 200 82131895
The frame has 3,030 rows and 29 columns: one row too many (the second header line) and one column too few. The header has 29 fields and the data rows 30, so pandas uses the first field of each row, the survey date, as the index: Date and 2025 sit where row numbers should be, and every column label is shifted one place from its data.
2. Every count becomes text
print(naive.dtypes.value_counts().to_string())
print(f"'1-unit' column summed: {str(naive['1-unit'].sum())[:40]}...")
str 29
'1-unit' column summed: Value82131895848168534402180015172153347...
All 29 columns are text, because the second header line put words like Units into every column. Summing the 1-unit column concatenates strings instead of adding: the result starts Value82131895....
3. Codes lose their zeros
plain = pd.read_csv(Z.open("co2025a.txt"), skiprows=3, header=None)
print(plain.iloc[:2, :3].to_string(), "\n", plain[1].dtype)
0 1 2
0 2025 1 1
1 2025 1 3
int64
With header=None the counts become numbers, but so do the codes: Alabama's 01 becomes 1 and Autauga's 001 becomes 1, so a five-digit GEOID can no longer be built and the join to boundaries fails.
Older files, names and odd rows
The data used on this page
The US Census Bureau's Building Permits Survey county files (annual 1990β2025 and year-to-date 2025β2026), its Northeast place files for 2020β2025, the Vintage 2025 county population estimates and the 2024 cartographic boundary counties, all public domain. They were downloaded on 16 October 2026 and saved on this site in one archive, because Census revises recent months as late reports arrive.
from pathlib import Path
import zipfile
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
# US Census Bureau Building Permits Survey: annual county files co1990a.txt-co2025a.txt, year-to-date county files
# co2501y.txt-co2512y.txt and co2601y.txt-co2608y.txt, Northeast place files ne2020a.txt-ne2025a.txt; the Vintage 2025
# county population estimates; 2024 cartographic boundary counties. Downloaded 16 October 2026, saved here unchanged.
# Live: https://www2.census.gov/econ/bps/County/co2025a.txt (and the same folder for the others),
# https://www2.census.gov/econ/bps/Place/Northeast%20Region/ne2025a.txt,
# https://www2.census.gov/programs-surveys/popest/datasets/2020-2025/counties/totals/co-est2025-alldata.csv,
# https://www2.census.gov/geo/tiger/GENZ2024/shp/cb_2024_us_county_20m.zip
ARCHIVE = "census-building-permits-2026-10-16.zip"
if not Path(f"data/{ARCHIVE}").exists():
Path(f"data/{ARCHIVE}").write_bytes(requests.get(f"https://data.spatialworkflow.io/{ARCHIVE}", timeout=600).content)
Z = zipfile.ZipFile(f"data/{ARCHIVE}")
SIZES = ["1", "2", "34", "5"] # buildings with 1 unit, 2 units, 3-4 units, 5 or more units
COLS = (["survey", "state", "county", "region", "division", "name"]
+ [f"{k}{s}_{m}" for k in ("est", "rep") for s in SIZES for m in ("bldgs", "units", "value")])
def permits(name):
"""One county file: two header rows and a blank line, then a row per county. est = estimate with
imputation for places that did not report, rep = reported only."""
d = pd.read_csv(Z.open(name), skiprows=3, header=None, names=COLS, skipinitialspace=True,
dtype={"survey": str, "state": str, "county": str, "name": str})
d["survey"], d["name"] = d.survey.str.strip(), d.name.str.strip()
d["state"], d["county"] = d.state.str.strip().str.zfill(2), d.county.str.strip().str.zfill(3)
return d.assign(GEOID=d.state + d.county,
units=d[[f"est{s}_units" for s in SIZES]].sum(axis=1),
reported=d[[f"rep{s}_units" for s in SIZES]].sum(axis=1),
value=d[[f"est{s}_value" for s in SIZES]].sum(axis=1))
pop = pd.read_csv(Z.open("co-est2025-alldata.csv"), encoding="latin-1", dtype={"STATE": str, "COUNTY": str})
pop = pop[pop.SUMLEV == 50].assign(GEOID=lambda p: p.STATE + p.COUNTY)
Path("data/cb_2024_us_county_20m.zip").write_bytes(Z.read("cb_2024_us_county_20m.zip"))
counties = gpd.read_file("data/cb_2024_us_county_20m.zip")
counties = counties[~counties.STUSPS.isin(["AK", "HI", "PR"])].to_crs(5070)
y25 = permits("co2025a.txt")
print(f"2025 county file: {len(y25):,} rows, {y25.units.sum():,} units permitted; {len(pop):,} counties in the estimates")
2025 county file: 3,029 rows, 1,431,616 units permitted; 3,144 counties in the estimates
Example 1: the 1990s files
for line in Z.open("co1990a.txt").read().decode().splitlines()[:4]:
print(line[:96])
old = permits("co1990a.txt")
print(old[["survey", "GEOID", "name", "units"]].head(2).to_string(index=False), "\n", old.units.dtype)
Survey , FIPS , FIPS , Region , Division , County , , 1-unit , , , 2-unit , , , 34unit
Date , State , County , Code , Code , Name , Bldgs , Units , Value , Bldgs , Units , Value ,
9099 , 01 , 001 , 3 , 6 , Autauga County , 87 , 87 , 4491772 , 0 , 0 , 0 , 0 ,
survey GEOID name units
9099 01001 Autauga County 87
9099 01003 Baldwin County 328
int64
The 1990s files have spaces around every comma and a survey code of 9099 for 1990. skipinitialspace=True and stripping the text columns handle the spaces, and the counts still come out as integers; take the year from the file name.
Example 2: names cut at 30 characters
ct = permits("co2025a.txt").query("state == '09'")
full = pop[pop.STATE == "09"].set_index("GEOID").CTYNAME
print(pd.DataFrame({"file": ct.set_index("GEOID").name, "estimates": full}).dropna().head(4).to_string())
print(f"names that would not match by text: {(ct.set_index('GEOID').name != full.reindex(ct.GEOID)).sum()} of {len(ct)}")
file estimates
GEOID
09110 Capitol Planning Region Capitol Planning Region
09120 Greater Bridgeport Planning Re Greater Bridgeport Planning Region
09130 Lower Connecticut River Valley Lower Connecticut River Valley Planning Region
09140 Naugatuck Valley Planning Regi Naugatuck Valley Planning Region
names that would not match by text: 8 of 9
Names are cut at 30 characters: "Greater Bridgeport Planning Re" for Greater Bridgeport Planning Region. 8 of Connecticut's 9 region names in the 2025 file do not match the population estimates' names. Join on GEOID, never on the name.
Internal links
- Read the permit files β the full reader and what the columns hold
- Land Registry CSVs with no header β the opposite problem
- Units, buildings and value β the 24 numeric columns named here
- Connecticut's planning regions β the region names this cut shortens