How to Download Census Boundaries in Python
Problem statement
Every census map and every census join needs polygons, and the Census Bureau publishes the same boundaries in two families at several resolutions. The files look interchangeable. They are not.
Measured on California's census tracts for 2023:
features vertices polygon area zip
TIGER/Line tl_2023_06_tract 9,129 3,192,661 423,965 kmยฒ 32.5 MB
cartographic cb_2023 500k 9,109 355,076 409,819 kmยฒ
cartographic cb_2023 5m 9,109 93,721 409,834 kmยฒ
The TIGER/Line polygons carry nine times the vertices and extend into lakes, bays and the sea. One San Francisco tract's TIGER/Line polygon covers 247.9 kmยฒ, of which 0.4 kmยฒ is land; its cartographic boundary polygon covers 0.9 kmยฒ.
Two more traps catch people who have picked the right file. Reading a boundary zip straight from its URL downloads it again on every run โ 4.5 s against 0.09 s from disk for the county file. And a where filter on a column you did not ask for returns zero rows without an error.
Quick answer
For maps and for joining ACS tables, read a cartographic boundary (cb_) file:
import time
import geopandas as gpd
url = "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_500k.zip"
t = time.perf_counter()
counties = gpd.read_file(url)
print(f"{len(counties):,} counties in {time.perf_counter()-t:.1f} s, CRS {counties.crs.to_epsg()}")
3,235 counties in 4.5 s, CRS 4269
GeoPandas reads the zipped shapefile directly, with no unzipping. Then cache the file locally, read only the state you need, and reproject before measuring anything โ the files arrive in NAD83 longitude and latitude (EPSG:4269).
Use TIGER/Line (tl_) files instead when you need full detail: legal boundaries into water, topology for editing, or features the cartographic files leave out.
Step-by-step solution
1. Choose the family
Cartographic boundary (cb_) |
TIGER/Line (tl_) |
|
|---|---|---|
| Shoreline | clipped to the coast | extends into water |
| Detail | generalised: 500k, 5m, 20m | full |
| Tracts, block groups | national file, and per state | per state |
| Counties, states | national | national |
| Best for | thematic maps, joins, overviews | editing, water, precise overlays |
For a choropleth of ACS data the cartographic file is almost always right: fewer vertices, no water polygons dominating coastal counties, and faster to read and draw. The national cb_2023_us_tract_500k file read in 0.83 s; its 5m version, with 4.5 times fewer vertices, in 0.55 s.
2. Build the URL from the naming pattern
Both families follow fixed patterns, so the URL can be generated:
BASE = "https://www2.census.gov/geo/tiger"
def census_boundary_url(level, year=2023, state=None, source="cb", resolution="500k"):
"""Build a TIGER/Line ('tl') or cartographic boundary ('cb') download URL."""
level = level.lower()
if source == "tl":
folder = {"tract": "TRACT", "bg": "BG", "county": "COUNTY", "state": "STATE",
"tabblock20": "TABBLOCK20"}[level]
area = "us" if level in ("county", "state") else state
if area is None:
raise ValueError(f"TIGER/Line {level} files are published per state")
return f"{BASE}/TIGER{year}/{folder}/tl_{year}_{area}_{level}.zip"
area = state or "us"
return f"{BASE}/GENZ{year}/shp/cb_{year}_{area}_{level}_{resolution}.zip"
Checked against the server with a HEAD request for each:
200 32.5 MB /TIGER2023/TRACT/tl_2023_06_tract.zip
200 83.5 MB /TIGER2023/COUNTY/tl_2023_us_county.zip
200 1.8 MB /TIGER2023/BG/tl_2023_10_bg.zip
200 57.9 MB /GENZ2023/shp/cb_2023_us_tract_500k.zip
200 4.3 MB /GENZ2023/shp/cb_2023_06_tract_500k.zip
200 17.5 MB /GENZ2023/shp/cb_2023_us_tract_5m.zip
200 0.9 MB /GENZ2023/shp/cb_2023_us_county_20m.zip
200 62.4 MB /GENZ2020/shp/cb_2020_us_tract_500k.zip
200 59.3 MB /GENZ2025/shp/cb_2025_us_tract_500k.zip
The same pattern held for every year from 2019 to 2025. Note the size spread for counties: 83.5 MB for TIGER/Line against 0.9 MB for the 20m cartographic file.
3. Download once and cache
from pathlib import Path
import requests
def download_once(url, cache_dir="census_cache", chunk=1 << 20):
"""Download a file once, atomically, and return its local path."""
cache = Path(cache_dir)
cache.mkdir(parents=True, exist_ok=True)
target = cache / url.rsplit("/", 1)[-1]
if target.exists():
return target
partial = target.with_suffix(target.suffix + ".part")
with requests.get(url, stream=True, timeout=120) as response:
response.raise_for_status()
with open(partial, "wb") as fh:
for block in response.iter_content(chunk):
fh.write(block)
partial.replace(target)
return target
For Delaware's TIGER/Line tracts the first call took 0.93 s and the second 0.06 ms. Writing to a .part file and renaming it means an interrupted download never leaves a truncated zip that looks complete.
4. Read only the state you need
There are two ways to get one state's tracts: download the per-state cartographic file (4.3 MB for California, against 57.9 MB national), or filter the national file as it is read. The filter has a trap:
p = "cb_2023_us_tract_500k.zip"
for cols in [None, ["GEOID", "STATEFP"], ["GEOID", "NAMELSADCO", "ALAND"]]:
t = time.perf_counter()
g = gpd.read_file(p, where="STATEFP = '06'", columns=cols)
print(f"columns={cols}: {len(g):,} rows in {time.perf_counter()-t:.2f} s")
columns=None: 9,109 rows in 0.55 s
columns=['GEOID', 'STATEFP']: 9,109 rows in 0.42 s
columns=['GEOID', 'NAMELSADCO', 'ALAND']: 0 rows in 0.41 s
With pyogrio 0.13.0 and GDAL 3.12.4, a where clause on a column left out of columns matched nothing and raised nothing. Always include the filter column. A bbox filter is no substitute: a box around California returned 10,025 tracts from five states, because it selects on bounding boxes, not on membership.
5. Reproject before measuring
ca = gpd.read_file(p, where="STATEFP = '06'", columns=["GEOID", "ALAND", "STATEFP"])
ca = ca.to_crs("EPSG:3310") # NAD83 / California Albers, equal area
print(round(ca.area.sum() / 1e6))
409819
EPSG:4269 is a geographic CRS, so areas and lengths in it are in square degrees. Project to an equal-area CRS for the state, or use the ALAND attribute: California's tracts have 403,673 kmยฒ of land, and the 6,146 kmยฒ gap to the polygon area is inland water the cartographic file still contains.
6. Check what arrived
Before using the file, confirm the four things that most often differ from what you assumed:
- The row count. 9,109 California tracts in the cartographic file, 9,129 in TIGER/Line; the 20 extra are water-only tracts with
ALANDof zero. - The CRS. EPSG:4269 in every file here.
- The vintage. It is in the file name. Joining a table to boundaries from a different year is the next failure mode.
- The geometry detail. Count vertices with
shapely.get_num_coordinatesif drawing is slow.
7. Outside the US
The ONS publishes Output Area, LSOA and MSOA boundaries in the same two families, distinguished by a suffix in the layer name: BFE (full resolution, extent of the realm, into the sea), BFC (full resolution, clipped to the coastline), BGC (generalised, clipped) and BSC (super-generalised, clipped). Eurostat's GISCO service does the same for NUTS: the 2024 NUTS 3 GeoJSON is 27.6 MB at 1:1 million and 1.6 MB at 1:20 million.
Code examples
Example 1 โ a reader that cannot fall into the filter trap
import geopandas as gpd
def read_boundaries(path, state=None, columns=None, crs=None):
"""Read a census boundary zip, optionally one state, optionally reprojected."""
where = None
if state:
where = f"STATEFP = '{state}'"
if columns is not None and "STATEFP" not in columns:
columns = [*columns, "STATEFP"] # a where on an unread column matches nothing
gdf = gpd.read_file(path, where=where, columns=columns)
if crs:
gdf = gdf.to_crs(crs)
return gdf
ca = read_boundaries("cb_2023_us_tract_500k.zip", state="06",
columns=["GEOID", "NAMELSADCO", "ALAND"], crs="EPSG:3310")
print(f"{len(ca):,} tracts, {ca.crs.name}, columns {list(ca.columns)}")
9,109 tracts, NAD83 / California Albers, columns ['STATEFP', 'GEOID', 'NAMELSADCO', 'ALAND', 'geometry']
It took 0.49 s. The columns come back in file order, not in the order requested.
Example 2 โ fetch a boundary file by description
def get_boundaries(level, year=2023, state=None, source="cb", resolution="500k",
cache_dir="census_cache", columns=None, crs=None):
"""URL, cached download and filtered read in one call."""
url = census_boundary_url(level, year, state, source, resolution) # a per-state file when state is given
path = download_once(url, cache_dir)
gdf = read_boundaries(path, columns=columns, crs=crs)
print(f"{path.name}: {len(gdf):,} features, CRS {gdf.crs.to_epsg()}")
return gdf
de = get_boundaries("tract", state="10", source="tl")
tl_2023_10_tract.zip: 262 features, CRS 4269
Because both families publish per-state tract files, passing state downloads the small file rather than filtering the national one.
Example 3 โ measure what a file will cost before using it
import os
import time
import shapely
def boundary_cost(path, **read_kwargs):
"""Features, vertices, land and polygon area, size and read time for one file."""
t = time.perf_counter()
gdf = gpd.read_file(path, **read_kwargs)
seconds = time.perf_counter() - t
vertices = int(shapely.get_num_coordinates(gdf.geometry.values).sum())
polygon_km2 = gdf.to_crs("EPSG:6933").area.sum() / 1e6
land_km2 = gdf["ALAND"].sum() / 1e6
print(f"{os.path.basename(path)}: {len(gdf):,} features, {vertices:,} vertices, "
f"polygons {polygon_km2:,.0f} kmยฒ vs land {land_km2:,.0f} kmยฒ, "
f"{os.path.getsize(path) / 1e6:.1f} MB, read {seconds:.2f} s")
return gdf
tl_2023_06_tract.zip: 9,129 features, 3,192,661 vertices, polygons 423,965 kmยฒ vs land 403,673 kmยฒ, 32.5 MB, read 0.30 s
cb_2023_06_tract_500k.zip: 9,109 features, 355,076 vertices, polygons 409,819 kmยฒ vs land 403,673 kmยฒ, 4.3 MB, read 0.10 s
The gap between polygon area and land area is the quickest way to tell which family a file belongs to.
Explanation
Why there are two families
TIGER/Line files are extracts of the Census Bureau's geographic database. Their polygons follow legal and statistical boundaries wherever those run, and for coastal and lakeside areas that is often well out into the water; the AWATER attribute records how much. California's TIGER/Line tract polygons total 423,965 kmยฒ, which is ALAND plus AWATER almost exactly.
Cartographic boundary files are derived from the same database for small-scale mapping: clipped to the shoreline, generalised to a stated scale, and stripped of polygons with nothing left to draw. The 20 California tracts missing from them are all water-only.
Why vertices matter more than megabytes
Reading, reprojecting, overlaying and drawing all scale with the number of coordinates, not with the zip size. TIGER/Line's 3,192,661 vertices for one state are nine times the cartographic 500k file and 34 times the 5m file, for maps that look the same at state scale.
The generalisation costs little in area: the median tract's TIGER/Line polygon is 1.0001 times the size of its cartographic one. The large differences are concentrated in the tracts that touch water.
Why the water matters for maps
A choropleth fills polygons, so a tract whose polygon is 247 kmยฒ of bay and 0.4 kmยฒ of land is drawn as a dominant shape. Measured, 39 California tracts have TIGER/Line polygons more than twice the size of their clipped versions, and the largest ratio is 287.5 times.
For densities the same problem is numerical: divide by polygon area from a TIGER/Line file and coastal tracts come out far too sparse. Divide by ALAND whichever family you map with.
Why reading from a URL is only for exploration
gpd.read_file(url) downloads the whole zip into memory each time. The county file took 4.5 s from the URL and 0.09 s from a local copy โ and every repeat of a notebook cell repeats the download, which also puts load on a public server. A local cache makes runs reproducible as well: the file you analysed yesterday is the file you analyse today.
Edge cases or notes
- Tract and block group TIGER/Line files are per state; counties and states are national. A national tract layer means 56 downloads or the cartographic file.
- The 5m national tract file has 85,194 features against 85,186 at 500k. Generalisation does not only remove detail, so compare counts when switching resolution.
- NAD83 is not WGS84. EPSG:4269 and EPSG:4326 differ by around a metre in the conterminous US; reproject explicitly rather than relabelling.
- The boundary year must match the table. 2023 ACS tables join cleanly to 2023 boundaries; against 2020 boundaries, 884 Connecticut table rows fail to match.
bboxselects neighbours. A box around one state returned tracts from five.- Water-only tracts have codes from 990000 upwards and
ALANDof zero; drop or keep them deliberately. - Block files are large. Delaware alone has 20,198 blocks in a 21.5 MB file; plan before fetching a big state.
- Keep the zip. The shapefile's
.prjinside it is the only record of the CRS.
Internal links
- Census geographies explained: blocks, tracts, output areas and why they nest โ which level to download
- How to download census tables from an API in Python โ the data that goes onto these polygons
- How to join a census table to its boundaries without losing rows โ the next step
- Fixing census data that does not match the boundary file year โ when the vintage is wrong
- How to normalise census counts into rates and densities โ why ALAND beats polygon area
- How to cache downloaded GIS data so you fetch it once โ caching beyond one file
- How to download administrative boundaries in Python โ boundaries from other sources
- How to reduce GIS file size in Python โ when even the 500k file is too heavy
FAQ
Should I use TIGER/Line or cartographic boundary files?
Cartographic boundary files for maps and for joining ACS tables: they are clipped to the shoreline and generalised. For California's tracts they have 355,076 vertices against 3,192,661 in TIGER/Line.
Can GeoPandas read the Census zip file without unzipping it?
Yes. gpd.read_file accepts the zip path or its URL directly and reads the shapefile inside.
Why are my coastal tracts so large on the map?
You are probably drawing TIGER/Line polygons, which extend into water. One San Francisco tract covers 247.9 kmยฒ in TIGER/Line and 0.9 kmยฒ in the cartographic file.
What coordinate system are Census boundary files in?
NAD83 longitude and latitude, EPSG:4269. Reproject to an equal-area CRS such as EPSG:3310 for California, or EPSG:5070 for the conterminous US, before measuring areas.
Why did my filtered read return no rows?
If you passed columns without the column used in where, the filter matched nothing. Measured with pyogrio 0.13.0, where STATEFP = '06' returned 9,109 tracts with STATEFP selected and 0 without it.
Where are UK and EU boundaries published?
The ONS Open Geography Portal publishes Output Areas, LSOAs and MSOAs in full-resolution and generalised versions, and Eurostat's GISCO service publishes NUTS regions at 1:1 million to 1:60 million.