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.

Bar chart of vertex counts for California tracts: 3,192,661 in TIGER/Line, 355,076 in cb 500k and 93,721 in cb 5m.
The tract count barely changes between the three files; the detail, and the water, change a great deal.

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 ALAND of 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_coordinates if 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.

Decision diagram choosing between cartographic 500k, cartographic 5m or 20m, and TIGER/Line boundary files by purpose.
A map of ACS estimates needs the clipped file; a question about water needs the one that includes it.

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.

Two panels contrasting a where filter on an unread column that returns zero rows with one that includes the filter column.
An empty result from a filtered read is worth an assertion โ€” zero rows is never the right answer for a state.

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.
  • bbox selects neighbours. A box around one state returned tracts from five.
  • Water-only tracts have codes from 990000 upwards and ALAND of 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 .prj inside it is the only record of the CRS.

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.