How to Map Net Migration by County in Python

On this page

Problem statement

Net migration, people moving in minus people moving out, is the single number most often mapped from migration data. A choropleth of it shows at a glance which places are gaining people from elsewhere in the country and which are losing them. Three choices decide whether that map is honest. Net must be a rate, not a count, or the map just shows where the big counties are. The colour scale must diverge around zero. And for most counties the net is a small difference between two uncertain estimates, so many cannot be told apart from zero.

This page builds a national map of net domestic migration from the Census county-to-county flows for 2016–2020. It turns the counts into rates per 1,000 residents and greys out the counties whose net is not distinguishable from zero.

Quick answer

Read every state's sheet, sum the flows in and out for each county, divide by population, and map with a diverging scale centred on zero:

us = flows[flows.b_st.str.fullmatch(r"\d{3}")]                                # moves between US counties only
by = us.groupby("geoid").agg(inflow=("flow", "sum"), outflow=("counter", "sum"))
by["net"] = by.inflow - by.outflow
by = by.join(pop)
by["rate"] = 1000 * by.net / by.pop2020
print(f"{len(by):,} counties; net domestic migration sums to {by.net.sum():+,.0f} "
      f"(in {by.inflow.sum():,.0f}, out {by.outflow.sum():,.0f})")
print(by.rate.describe(percentiles=[0.05, 0.5, 0.95]).round(1).to_string())
3,221 counties; net domestic migration sums to +0 (in 18,061,885, out 18,061,885)
count    3135.0
mean       -0.4
std        37.8
min     -1047.6
5%        -44.0
50%         0.4
95%        45.0
max       207.6
Two maps of the contiguous United States coloured by net domestic migration per 1,000 residents from red losses to blue gains: on the left every county is coloured, giving a speckled pattern everywhere; on the right counties whose net is not distinguishable from zero are grey, leaving coloured clusters such as losses in Los Angeles and gains in Arizona, Florida and the Carolinas. Two maps of the contiguous United States coloured by net domestic migration per 1,000 residents from red losses to blue gains: on the left every county is coloured, giving a speckled pattern everywhere; on the right counties whose net is not distinguishable from zero are grey, leaving coloured clusters such as losses in Los Angeles and gains in Arizona, Florida and the Carolinas.
Net domestic migration, 2016–2020. Greying out the counties whose net is within its margin of error leaves 1,099 counties, home to 60% of the population, with a confident colour.

A zero national net, rates, significance, then the map

1. Check that the national net is zero

Every domestic move is an out-move for one county and an in-move for another, so the national sum of domestic net migration must be zero, or close to it after rounding and suppression. That is a quick check that in and out are read the right way round:

print(f"sum of all county nets: {by.net.sum():+,.0f} people, {by.net.sum() / by.inflow.sum():+.3%} of all in-moves")
sum of all county nets: +0 people, +0.000% of all in-moves

2. Use a rate, not a count

The counties with the largest net counts are simply big counties. Compare the top of the two rankings, using rates only for counties of more than 100,000 people, where the estimates are steadier:

named = by.join(NAMES)
print("largest net losses (count):", ", ".join(named.nsmallest(3, "net")["name"]))
print("largest net losses (rate): ", ", ".join(named[named.pop2020 > 100_000].nsmallest(3, "rate")["name"]))
print("largest net gains (rate):  ", ", ".join(named[named.pop2020 > 100_000].nlargest(3, "rate")["name"]))
largest net losses (count): Los Angeles County, California, Cook County, Illinois, Kings County, New York
largest net losses (rate):  Potter County, Texas, Anchorage Municipality, Alaska, Peoria County, Illinois
largest net gains (rate):   Monroe County, Indiana, Pitt County, North Carolina, Clarke County, Georgia

The rates point at different places. Among counties of more than 100,000 people, the largest gains are college towns: Bloomington (Monroe County, Indiana), Greenville (Pitt County, North Carolina) and Athens (Clarke County, Georgia). The ACS counts students where they live, so their arrival shows as domestic migration.

3. Test whether each net differs from zero

Each county's net has a margin of error. Sum the row-level net MOEs in quadrature (the Census approximation for sums), then compare the net with its MOE:

moe = us.assign(m2=us.net_moe.fillna(0) ** 2).groupby("geoid").m2.sum() ** 0.5
by["net_moe"] = moe
by["significant"] = by.net.abs() > by.net_moe
print(f"{by.significant.sum():,} of {len(by):,} counties ({by.significant.mean():.0%}) have a net "
      f"that differs from zero at the 90% level")
print(f"population in counties with a significant net: {by.loc[by.significant, 'pop2020'].sum() / by.pop2020.sum():.0%}")
1,099 of 3,221 counties (34%) have a net that differs from zero at the 90% level
population in counties with a significant net: 60%
How many US counties have a net domestic migration that differs from zero at the 90% level, 2016–2020. how many nets are real?counties with flows3,22134% of counties, 60% of people÷ 2.9net differs from 01,099Net MOE: row MOEs summed in quadrature spatialworkflow.io
Two thirds of counties have a net migration that cannot be told apart from zero.

4. Map it with a diverging scale

Fix the colour limits symmetrically around zero so gains and losses get the same visual weight, and draw insignificant counties in grey:

g = COUNTIES.join(by, on="GEOID", how="inner")
lim = float(np.nanpercentile(by.rate.abs(), 95))
fig, ax = plt.subplots(figsize=(10, 6))
g[~g.significant].plot(ax=ax, color="#d9d9d9", edgecolor="none")
g[g.significant].plot(ax=ax, column="rate", cmap="RdBu", vmin=-lim, vmax=lim, edgecolor="none", legend=True,
                      legend_kwds={"label": "net domestic migration per 1,000 residents", "shrink": 0.6})
ax.set_axis_off(); fig.savefig("data/net_migration.png", dpi=150)
print(f"colour limits ±{lim:.0f} per 1,000; {len(g):,} counties drawn")
colour limits ±62 per 1,000; 3,100 counties drawn

The counties that did not join

The data used on this page

The Census Bureau's county-to-county migration flows, 2016–2020 ACS ("ins, outs, nets, gross" workbook, all 52 sheets), the county population estimates (April 2020 base, vintage 2023), and 2023 county outlines projected to EPSG:5070.

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)
SRC = {"county-to-county-2016-2020-ins-outs-nets-gross.xlsx": "https://www2.census.gov/programs-surveys/demo/tables/geographic-mobility/2020/"
       "county-to-county-migration-2016-2020/county-to-county-migration-flows/county-to-county-2016-2020-ins-outs-nets-gross.xlsx",
       "co-est2023-alldata.csv": "https://www2.census.gov/programs-surveys/popest/datasets/2020-2023/counties/totals/co-est2023-alldata.csv",
       "cb_2023_us_county_20m.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=600).content)
COLS = ["a_st", "a_cty", "b_st", "b_cty", "a_state", "a_county", "b_state", "b_county",
        "flow", "flow_moe", "counter", "counter_moe", "net", "net_moe", "gross", "gross_moe"]
sheets = pd.read_excel("data/county-to-county-2016-2020-ins-outs-nets-gross.xlsx", sheet_name=None, header=None,
                       skiprows=3, names=COLS, dtype=str)
flows = pd.concat(sheets.values())
flows = flows[flows.a_cty.notna() & flows.b_st.notna()].copy()
for c in COLS[8:]:
    flows[c] = pd.to_numeric(flows[c], errors="coerce")
flows["geoid"] = flows.a_st.str[1:] + flows.a_cty
pe = pd.read_csv("data/co-est2023-alldata.csv", encoding="latin-1", dtype={"STATE": str, "COUNTY": str})
pe = pe[pe.SUMLEV == 50]
pop = pd.Series(pe.ESTIMATESBASE2020.to_numpy(), index=pe.STATE + pe.COUNTY, name="pop2020")
NAMES = pd.Series((pe.CTYNAME + ", " + pe.STNAME).to_numpy(), index=pe.STATE + pe.COUNTY, name="name")
COUNTIES = gpd.read_file("data/cb_2023_us_county_20m.zip").query("STUSPS not in ['AK', 'HI', 'PR']").to_crs(5070)
print(f"{len(sheets)} sheets, {len(flows):,} rows; {len(pop):,} county populations")
52 sheets, 416,203 rows; 3,144 county populations

Example 1: the counties that did not join

missing_pop = sorted(set(by.index[by.pop2020.isna()]))
print(f"{len(missing_pop)} flow counties without a 2020 population: {missing_pop[:10]}")
86 flow counties without a 2020 population: ['09001', '09003', '09005', '09007', '09009', '09011', '09013', '09015', '72001', '72003']

Two maps compressed into their difference

A net migration map compresses two maps, the in-moves and the out-moves, into their difference. That is its strength and its risk. The difference highlights where population is being redistributed. But the in- and out-flows are each estimates with margins of error, and their difference inherits both uncertainties while usually being much smaller than either. For many small counties the net is a few dozen people with a margin of several hundred.

The largest net domestic migration losses and gains by count and by rate, 2016–2020. count or rate?rankingtop threelosses, countLos Angeles · Cook · Kings (Brooklyn)losses, rate (>100k people)Potter TX · Anchorage AK · Peoria ILgains, rate (>100k people)Monroe IN · Pitt NC · Clarke GAThe gains by rate are college towns: students are counted where they live spatialworkflow.io
Counts and rates rank different places.

Greying out the insignificant counties changes the map's message. Without it, every county gets a confident colour and the map reads as a complete picture of gains and losses. With it, the clear patterns stand out. The largest significant losses are big-city cores: Los Angeles, Cook County (Chicago), Brooklyn, Queens, Miami-Dade and Manhattan. The gains are in their outer suburbs, such as Riverside and San Bernardino counties and Williamson County, Texas, and in Florida, Arizona, Texas and the Carolinas. The grey majority is where the data cannot say.

Connecticut, denominators and tiny counties

Net migration map problems and fixes. net migration map problemsSymptomFixmap shows where big counties arerate per 1,000 residentsgains look stronger than lossessymmetric limits around zeroevery county looks certaingrey out net within its MOEtiny county at −1,048 per 1,000Loving County, pop. 63: check theMOEACS county-to-county flows, 2016–2020 spatialworkflow.io
Net migration map problems and fixes.
  • Connecticut flows use the pre-2022 counties; the vintage 2023 population file and the 2023 outlines use planning regions, so those counties drop out.
  • Denominator: the April 2020 population is used for a 2016–2020 rate; a five-year average population would be more exact.
  • International moves are excluded here: the ACS sees moves from abroad but not moves out of the country, so a net including them would be biased.
  • Alaska and Hawaii are left off the projection for space; include them as insets for a national product.
  • Rates for tiny counties swing wildly: Loving County, Texas, with 63 residents, comes out at −1,048 per 1,000. Check the margin before reading anything into such a county.

FAQ

How do I calculate net migration by county?

Sum moves into the county from other counties and subtract moves out to them; divide by population for a rate.

Should I map net migration as a count or a rate?

As a rate, usually per 1,000 residents. Counts mostly show where the large counties are.

How do I know whether a county's net migration is real?

Combine the margins of error of its flows and check whether the net is larger than its margin. Grey out the ones that are not.

Why does national net migration not sum exactly to zero?

Suppressed and rounded flows; the sum should still be close to zero relative to the total number of moves.