Migration Flows¶
The ACS Migration Flows dataset tracks county-to-county and metro-to-metro
migration patterns across the United States. PyPUMS wraps the Census Bureau’s
/acs/flows endpoint with get_flows(), giving you tidy DataFrames of
who moved where — and how many.
Function signature¶
from pypums import get_flows
df = get_flows(
geography, # "county" or "metropolitan statistical area"
*,
variables=None, # additional variable IDs to request
breakdown=None, # breakdown dimensions (e.g. "AGE_GROUP")
breakdown_labels=False, # add human-readable label columns
year=2019, # data year (default 2019)
output="tidy", # "tidy" or "wide"
state=None, # state FIPS, abbreviation, or name
county=None, # county FIPS code
msa=None, # metropolitan statistical area code
geometry=False, # attach cartographic boundary shapes
moe_level=90, # confidence level: 90, 95, or 99
cache_table=False, # cache API response on disk
show_call=False, # print the API URL
key=None, # Census API key override
)
Supported geographies
Migration flows are only available at two geography levels:
"county"— county-to-county flows"metropolitan statistical area"— MSA-to-MSA flows
Passing any other geography raises a ValueError.
Default columns¶
Every call to get_flows() returns these columns automatically:
| Column | Description |
|---|---|
FULL1_NAME |
Origin geography name |
FULL2_NAME |
Destination geography name |
MOVEDIN |
Number of people who moved into the origin from the destination |
MOVEDIN_M |
Margin of error for MOVEDIN |
MOVEDOUT |
Number of people who moved out of the origin to the destination |
MOVEDOUT_M |
Margin of error for MOVEDOUT |
MOVEDNET |
Net migration (MOVEDIN - MOVEDOUT) |
MOVEDNET_M |
Margin of error for MOVEDNET |
Basic example: county flows for California¶
FULL1_NAME FULL2_NAME state ... variable estimate moe
0 Alameda County, California Africa 06 … MOVEDIN 1256.0 481.0 1 Alameda County, California Asia 06 … MOVEDIN 12615.0 998.0 2 Alameda County, California Central America 06 … MOVEDIN 2779.0 700.0 3 Alameda County, California Caribbean 06 … MOVEDIN 145.0 101.0 4 Alameda County, California Europe 06 … MOVEDIN 2870.0 529.0
[5 rows x 7 columns]
In tidy output (the default), the DataFrame has variable, estimate, and moe
columns instead of separate MOVEDIN/MOVEDOUT/MOVEDNET columns:
Wide output
Pass output="wide" to keep MOVEDIN, MOVEDOUT, and MOVEDNET as separate
columns rather than melting into tidy format:
MSA-level flows¶
Metropolitan Statistical Area flows capture migration between metro regions, regardless of state boundaries:
```python exec=”on” source=”tabbed-left” session=”flows” msa_flows = get_flows( “metropolitan statistical area”, year=2019, )
Filter to a specific MSA by code¶
la_metro = get_flows( “metropolitan statistical area”, msa=”31080”, # Los Angeles-Long Beach-Anaheim year=2019, ) print(f”{len(la_metro)} flow records for LA metro”) print(la_metro.head(3)) ```
Breakdowns by demographic characteristics¶
The flows API supports several breakdown dimensions that let you slice migration by age, race, sex, and Hispanic origin:
| Breakdown | Values |
|---|---|
MOTEFG |
Metro/micro/noncore origin-destination type |
AGE_GROUP |
Age groups (all ages, 1-4, 5-17, 18-24, …, 75+) |
RACE_GROUP |
Race categories (White alone, Black alone, Asian alone, …) |
SEX_GROUP |
Male, Female, Both sexes |
HISP_GROUP |
Hispanic or Latino, Non-Hispanic, Both |
```python exec=”on” source=”tabbed-left” session=”flows”
County flows broken down by age group¶
age_flows = get_flows( “county”, state=”CA”, breakdown=”AGE_GROUP”, year=2019, ) print(f”{len(age_flows)} flow records with age breakdowns”) ```
Adding human-readable labels¶
By default, breakdown columns contain numeric codes (e.g., "004" for the
18-24 age group). Set breakdown_labels=True to add a *_label column
with the decoded description:
python exec="on" source="tabbed-left" session="flows"
age_flows = get_flows(
"county",
state="CA",
breakdown="AGE_GROUP",
breakdown_labels=True,
year=2019,
)
print(age_flows[["FULL1_NAME", "AGE_GROUP", "AGE_GROUP_label", "variable"]].head(4))
The label mapping comes from pypums.datasets.mig_recodes. You can inspect
the full lookup table directly:
from pypums.datasets import mig_recodes
print(mig_recodes)
# dimension code label
# 0 MOTEFG 00 Not identified
# 1 MOTEFG 01 Metro to metro
# 2 MOTEFG 02 Metro to micro
# ...
MOE scaling¶
The Census API returns margins of error at the 90% confidence level. You can
rescale to 95% or 99% confidence using the moe_level parameter:
The scaling formula is:
$$ \text{MOE}{\text{new}} = \text{MOE} $$} \times \frac{z_{\text{new}}}{z_{90}
where z-scores are 1.645 (90%), 1.960 (95%), and 2.576 (99%).
Geometry support¶
Set geometry=True to attach cartographic boundary shapes to the origin
geography. This returns a GeoDataFrame you can plot directly:
ca_flows_geo = get_flows(
"county",
state="CA",
year=2019,
geometry=True,
)
# ca_flows_geo is a GeoDataFrame with a 'geometry' column
import altair as alt
alt.Chart(ca_flows_geo).mark_geoshape(stroke="white", strokeWidth=0.5).encode(
color=alt.Color("estimate:Q", legend=alt.Legend(title="Estimate")),
tooltip=["FULL1_NAME:N", alt.Tooltip("estimate:Q", format=",")],
).project("albersUsa").properties(width=500, height=400)
Origin geography only
Shapes are attached to the origin geography (the first geography in
each flow pair). If you need shapes for destinations, join them separately
using pypums.spatial.attach_geometry.
Requires geopandas
The geometry=True option requires geopandas to be installed:
Caching¶
For repeated analysis, cache the API response to avoid redundant network calls:
flows = get_flows(
"county",
state="CA",
year=2019,
cache_table=True, # saves to ~/.pypums/cache/api/
)
Cached results expire after 24 hours. Subsequent calls with identical parameters return the cached DataFrame instantly.
Debugging API calls¶
Set show_call=True to print the URL and parameters sent to the Census API:
flows = get_flows("county", state="CA", year=2019, show_call=True)
# Census API call: https://api.census.gov/data/2019/acs/flows
# Parameters: {'get': 'FULL1_NAME,...', 'for': 'county:*', ...}
Interpreting flow data¶
Large MOEs for small geographies
Migration flow estimates for small counties can have very large margins of error — sometimes larger than the estimate itself. This is inherent to the ACS sample design: migration is a rare event, and the ACS samples only a fraction of the population each year.
Before drawing conclusions from flow data:
- Check the MOE relative to the estimate (coefficient of variation)
- Aggregate small counties into larger regions when possible
- Focus on flows with larger magnitudes where the signal-to-noise ratio is better
- Use
pypums.significance()to test whether a net flow is meaningfully different from zero
Example: is net migration significant?¶
The MOE (1,200) swamps the estimate (500), so the net inflow is not statistically significant.
Complete example¶
Putting it all together — county-level flows for California, broken down by age, with labels and geometry:
```python exec=”on” source=”tabbed-left” session=”flows” from pypums import get_flows
ca_age_flows = get_flows( “county”, state=”California”, breakdown=”AGE_GROUP”, breakdown_labels=True, year=2019, output=”wide”, geometry=True, moe_level=95, cache_table=True, )
Filter to working-age adults (25-34)¶
young_workers = ca_age_flows[ ca_age_flows[“AGE_GROUP_label”] == “25 to 34 years” ] print(f”{len(young_workers)} counties with 25-34 age group flows”) print(young_workers[[“FULL1_NAME”, “MOVEDNET”]].head(3)) ```
# Map net migration
import altair as alt
alt.Chart(young_workers).mark_geoshape(stroke="white", strokeWidth=0.5).encode(
color=alt.Color("MOVEDNET:Q", scale=alt.Scale(scheme="redblue"), legend=alt.Legend(title="Net Migration")),
tooltip=["FULL1_NAME:N", alt.Tooltip("MOVEDNET:Q", format=",")],
).project("albersUsa").properties(width=500, height=400)
See Also¶
- API Reference — Full
get_flows()function signature - Margins of Error — Understanding and propagating flow MOEs
- Datasets Reference — The
mig_recodeslookup table for breakdown labels