How to Map Commuting Flows from LEHD LODES in Python

On this page

Problem statement

The Census Bureau's LEHD Origin-Destination Employment Statistics (LODES) say where people live and where they work, for every pair of census blocks in a state. That makes it the most detailed open commuting dataset in the United States, and a natural source for commuting flow maps. Block-to-block pairs are far too fine to map directly, though. Rhode Island alone has tens of thousands of them, most holding one or two jobs. The work is to aggregate them to places people recognise, separate people who work where they live from those who commute out, and then draw the flows between places.

This page turns Rhode Island's 2021 LODES file into a town-to-town commuting table, measures how many people work in their home town, and maps the main flows.

Quick answer

Read the OD file with block codes as strings, attach each block's town from the crosswalk, and sum jobs by home town and work town:

od = pd.read_csv("data/ri_od_main_JT00_2021.csv.gz", dtype={"w_geocode": str, "h_geocode": str})
town = XWALK.set_index("tabblk2020").ctycsub
od["home"], od["work"] = od.h_geocode.map(town), od.w_geocode.map(town)
tt = od.groupby(["home", "work"], as_index=False).S000.sum().rename(columns={"S000": "jobs"})
print(f"{len(od):,} block pairs, {od.S000.sum():,} jobs -> {len(tt):,} town pairs; "
      f"median jobs per block pair {od.S000.median():.0f}")
360,924 block pairs, 380,586 jobs -> 1,466 town pairs; median jobs per block pair 1
Map of Rhode Island's towns with 40 curved red arrows for the largest commuting flows between towns, almost all converging on Providence, Cranston and Warwick in the north-east of the state, with one short flow on Aquidneck Island into Newport. Map of Rhode Island's towns with 40 curved red arrows for the largest commuting flows between towns, almost all converging on Providence, Cranston and Warwick in the north-east of the state, with one short flow on Aquidneck Island into Newport.
The 40 largest town-to-town commuting flows in Rhode Island, 2021, from LODES. They carry 37% of all commuting between towns, and nearly all of them end in the Providence–Cranston–Warwick core.

What a row is, home-town work against commuting out

1. Know what one row is

Each row is a pair of census blocks with counts of primary and secondary jobs (JT00 is all jobs) held by residents of the home block at the work block. S000 is the total; the other columns split it by age, earnings and industry. The main file covers people who live and work in the state; aux adds jobs in the state held by out-of-state residents:

print(od.columns.tolist())
print(f"block pairs with 1 job: {(od.S000 == 1).mean():.0%}; with 10 or more: {(od.S000 >= 10).mean():.2%}")
['w_geocode', 'h_geocode', 'S000', 'SA01', 'SA02', 'SA03', 'SE01', 'SE02', 'SE03', 'SI01', 'SI02', 'SI03', 'createdate', 'home', 'work']
block pairs with 1 job: 96%; with 10 or more: 0.01%

2. Separate working at home town from commuting out

The diagonal of the town-to-town table holds people who work in the town they live in. It is often the biggest single cell in a row, and it is not a flow between places:

same = tt.home == tt.work
print(f"jobs held in the home town: {tt.loc[same, 'jobs'].sum():,} of {tt.jobs.sum():,} ({tt.loc[same, 'jobs'].sum() / tt.jobs.sum():.0%})")
by_town = tt[same].set_index("home").jobs / tt.groupby("home").jobs.sum()
print("highest share working at home:", ", ".join(f"{NAME[t]} {v:.0%}" for t, v in by_town.nlargest(3).items()))
print("lowest:                       ", ", ".join(f"{NAME[t]} {v:.0%}" for t, v in by_town.nsmallest(3).items()))
top_work = tt.loc[tt.groupby("home").jobs.idxmax()].set_index("home").work
print(f"largest cell in the row is the home town for {(top_work == top_work.index).sum()} towns, "
      f"Providence for {((top_work == PROVIDENCE) & (top_work.index != PROVIDENCE)).sum()} others")
jobs held in the home town: 88,223 of 380,586 (23%)
highest share working at home: New Shoreham 76%, Westerly 46%, Providence 45%
lowest:                        Exeter 8%, West Greenwich 8%, Foster 8%
largest cell in the row is the home town for 15 towns, Providence for 16 others
Where Rhode Island jobs are held relative to the worker’s home town, LODES 2021. 380,586 jobs held by Rhode Islanders in the statein the home town88,223 (23%)in another town292,363…of which into Providence66,8440150k300kLODES8, all jobs (JT00), main file spatialworkflow.io
Most Rhode Island jobs are held outside the worker's home town.

3. Find the main flows between towns

Drop the diagonal and rank what is left. A handful of destinations dominate:

between = tt[~same].sort_values("jobs", ascending=False)
print(between.head(8).assign(home=lambda d: d.home.map(NAME), work=lambda d: d.work.map(NAME)).to_string(index=False))
to_prov = between[between.work == PROVIDENCE].jobs.sum()
print(f"commuters into Providence from other RI towns: {to_prov:,} ({to_prov / between.jobs.sum():.0%} of all inter-town commuting)")
            home       work  jobs
        Cranston Providence  9297
         Warwick Providence  7710
       Pawtucket Providence  6132
      Providence   Cranston  5601
 East Providence Providence  5145
      Providence    Warwick  4631
        Cranston    Warwick  4528
North Providence Providence  4239
commuters into Providence from other RI towns: 66,844 (23% of all inter-town commuting)
Map of Rhode Island's 39 towns coloured by the largest work destination of their residents: 16 towns in the north and west send most workers to Providence, 15 mostly coastal towns and Providence itself mostly work at home, and 8 towns in the south-west send most to another town. Map of Rhode Island's 39 towns coloured by the largest work destination of their residents: 16 towns in the north and west send most workers to Providence, 15 mostly coastal towns and Providence itself mostly work at home, and 8 towns in the south-west send most to another town.
Where each town's residents most often work. Providence is the largest destination for 16 other towns; for 15 towns it is the home town.

4. Map the flows

Draw each of the largest inter-town flows from the home town's centroid to the work town's, wider for more jobs, largest on top:

top = between.head(40).sort_values("jobs")
fig, ax = plt.subplots(figsize=(6, 8))
TOWNS.plot(ax=ax, color="#f2f2f2", edgecolor="#bbbbbb", lw=0.4)
for _, r in top.iterrows():
    (x0, y0), (x1, y1) = CENTROID[r.home], CENTROID[r.work]
    ax.annotate("", (x1, y1), (x0, y0), arrowprops=dict(arrowstyle="-|>", lw=0.3 + 4 * np.sqrt(r.jobs / top.jobs.max()),
                color="#c0392b", alpha=0.7, connectionstyle="arc3,rad=0.15", shrinkA=0, shrinkB=2))
ax.set_axis_off(); fig.savefig("data/ri_commutes.png", dpi=150)
print(f"40 flows drawn: {top.jobs.sum():,} jobs, {top.jobs.sum() / between.jobs.sum():.0%} of inter-town commuting")
40 flows drawn: 108,741 jobs, 37% of inter-town commuting

A town-to-town matrix for Rhode Island

The data used on this page

LEHD Origin-Destination Employment Statistics version 8 for Rhode Island, 2021: the main OD file for all jobs (JT00) and the block crosswalk; Rhode Island county subdivision (town) outlines from the 2023 cartographic boundary files, projected to Rhode Island State Plane (EPSG:3438).

from pathlib import Path

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
L = "https://lehd.ces.census.gov/data/lodes/LODES8/ri/"
SRC = {"ri_od_main_JT00_2021.csv.gz": L + "od/ri_od_main_JT00_2021.csv.gz", "ri_xwalk.csv.gz": L + "ri_xwalk.csv.gz",
       "cb_2023_44_cousub_500k.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_44_cousub_500k.zip"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=600).content)
XWALK = pd.read_csv("data/ri_xwalk.csv.gz", dtype=str, usecols=["tabblk2020", "ctycsub", "ctycsubname"])
TOWNS = gpd.read_file("data/cb_2023_44_cousub_500k.zip").to_crs(3438)
TOWNS = TOWNS[TOWNS.ALAND > 0]
NAME = dict(zip(TOWNS.GEOID, TOWNS.NAME))
CENTROID = {g: (p.x, p.y) for g, p in zip(TOWNS.GEOID, TOWNS.representative_point())}
PROVIDENCE = TOWNS.loc[TOWNS.NAME == "Providence", "GEOID"].iloc[0]
print(f"{len(XWALK):,} blocks in the crosswalk; {len(TOWNS)} towns")
25,649 blocks in the crosswalk; 39 towns

Example 1: a town-to-town matrix

mat = tt.pivot(index="home", columns="work", values="jobs").fillna(0).astype(int)
mat = mat.rename(index=NAME, columns=NAME)
print(mat.shape, "rows = home town, columns = work town")
print(mat.loc[["Providence", "Warwick", "Cranston"], ["Providence", "Warwick", "Cranston"]].to_string())
(39, 39) rows = home town, columns = work town
work        Providence  Warwick  Cranston
home                                     
Providence       24131     4631      5601
Warwick           7710     9808      3753
Cranston          9297     4528      6702

Administrative records, not a survey

LODES is built from administrative records (unemployment insurance wage records and federal employment data), not a survey. So it has no margins of error in the ACS sense. It does have noise added to protect confidentiality, and it counts jobs, not people: a person with two jobs appears twice. The block-to-block detail is real but mostly too sparse to read, so aggregating to towns or tracts is the normal first step, and the right unit depends on the question.

From Rhode Island LODES block pairs to town-to-town commuting flows, 2021. blocks to townsblock pairs360,924join the crosswalk, sum÷ 246town pairs1,466drop the diagonal, keep the top÷ 37flows mapped40band width is log-scaled96% of block pairs hold 1 job; the 40 flows carry 37% of inter-town commuting spatialworkflow.io
From block pairs to a readable map: aggregate, drop the diagonal, keep the largest flows.

The diagonal decides what the flow map shows. Working in your home town is the largest single destination for 15 of Rhode Island's 39 towns, and drawing it as a flow is meaningless: it would be a line of zero length. Dropping it shows what the map is for, the movement between places. In Rhode Island that movement converges on Providence, with secondary centres in Warwick and Cranston.

Leading zeros, aux files and versions

LODES commuting flow problems and fixes. LODES problemsSymptomFixblock codes lose leading zerosdtype=str for the geocodes360,924 pairs too fine to mapaggregate with the crosswalkzero-length “flows”drop the diagonal before drawingout-of-state commuters missingadd the aux fileLODES counts jobs, not people spatialworkflow.io
LODES problems and fixes.
  • Leading zeros: block codes are 15 digits starting with the state code; reading them as integers breaks states with codes below 10.
  • aux files: add them to count jobs in Rhode Island held by residents of Massachusetts or Connecticut.
  • Coverage: LODES comes from employment records, so self-employment and informal work are largely missing.
  • Year and version: LODES8 uses 2020 census blocks; do not mix with LODES7 (2010 blocks) without a crosswalk.
  • Noise: small cells are perturbed for confidentiality; aggregate before drawing conclusions about individual blocks.

FAQ

What is LODES data?

LEHD Origin-Destination Employment Statistics: Census Bureau counts of jobs by home block and work block, built from administrative employment records, published per state and year.

How do I aggregate LODES to towns or counties?

Join each block code to the LODES crosswalk file, which lists the county, tract, place and county subdivision of every block, and sum the jobs.

What is the difference between the main and aux LODES files?

Main covers jobs where both home and work are in the state; aux covers jobs in the state held by people living in other states.

Does LODES count people or jobs?

Jobs. A person with two jobs is counted twice in JT00 (all jobs); JT01 counts primary jobs only.