How to Make a Flow Map in Python

On this page

Problem statement

A flow map draws movement between places as lines whose width shows how much moved. It is the natural map for migration, commuting, trade and trips. It is also easy to make unreadable. Draw every pair and the lines pile into a black tangle. Draw them straight and flows in opposite directions sit on top of each other. Scale width linearly and one big flow hides everything else. Draw small lines last and they cover the big ones.

This page draws the largest moves out of Los Angeles County, from the Census county-to-county migration flows. It shows the choices that make a flow map readable: which flows to draw, how to bend them, how to scale and order them.

Quick answer

Project the county centroids, keep the largest flows, bend each line into a curve, scale width by the square root of the flow, and draw the smallest first:

def bezier(p0, p1, bend=0.2, n=40):
    p0, p1 = np.asarray(p0), np.asarray(p1)
    mid, d = (p0 + p1) / 2, p1 - p0
    ctrl = mid + bend * np.array([d[1], -d[0]])                        # perpendicular, to the right of travel
    t = np.linspace(0, 1, n)[:, None]
    return (1 - t) ** 2 * p0 + 2 * (1 - t) * t * ctrl + t ** 2 * p1

top = flows.nlargest(40, "movers").sort_values("movers")              # small first, so big flows sit on top
o = CENTROID["06037"]
fig, ax = plt.subplots(figsize=(10, 6))
STATES.plot(ax=ax, color="#f2f2f2", edgecolor="#bbbbbb", lw=0.4)
for _, r in top.iterrows():
    d = CENTROID[r.dest]
    ax.plot(*bezier(o, d, bend=0.2).T, color="#c0392b", lw=0.3 + 5 * np.sqrt(r.movers / top.movers.max()),
            alpha=0.75, solid_capstyle="round")
ax.set_axis_off()
fig.savefig("data/la_flows.png", dpi=150)
print(f"{len(top)} flows drawn, {top.movers.sum():,.0f} of {flows.movers.sum():,.0f} movers ({top.movers.sum() / flows.movers.sum():.0%})")
40 flows drawn, 227,761 of 321,009 movers (71%)
Flow map of the contiguous United States with 40 curved red lines leaving Los Angeles County: a dense fan of short thick lines to neighbouring Californian counties, longer lines to Las Vegas, Phoenix, Texas, Seattle, Chicago and New York, and a legend for 1,000, 10,000 and 38,761 people. Flow map of the contiguous United States with 40 curved red lines leaving Los Angeles County: a dense fan of short thick lines to neighbouring Californian counties, longer lines to Las Vegas, Phoenix, Texas, Seattle, Chicago and New York, and a legend for 1,000, 10,000 and 38,761 people.
The 40 largest flows out of Los Angeles County, 71% of everyone who moved to another contiguous US county. Width is proportional to the square root of the flow; the largest flows are the short ones to Orange, San Bernardino and Riverside counties.

How many flows, bent lines, scaled widths, draw order

1. Decide how many flows to draw

Los Angeles sent people to more than a thousand counties. A few carry most of the volume, so a cut-off loses little:

s = flows.sort_values("movers", ascending=False).movers
share = s.cumsum() / s.sum()
for n in (10, 40, 100, 400):
    print(f"top {n:>3} flows: {share.iloc[n - 1]:.0%} of movers")
print(f"flows needed for 80% of movers: {int((share < 0.8).sum()) + 1} of {len(s):,}")
top  10 flows: 50% of movers
top  40 flows: 71% of movers
top 100 flows: 83% of movers
top 400 flows: 97% of movers
flows needed for 80% of movers: 78 of 1,023

2. Bend the lines

A straight line from Los Angeles to Las Vegas and one from Las Vegas to Los Angeles would overlap exactly. Bending every line the same way, here to the right of its direction of travel, separates the two directions and makes the map easier to read where lines fan out. The quick answer's bezier is a quadratic Bézier curve: one control point, offset sideways from the midpoint. Check that it separates the two directions:

a, b = CENTROID["06037"], CENTROID["32003"]                             # Los Angeles and Clark County, Nevada
out, back = bezier(a, b), bezier(b, a)
print(f"LA -> Clark and Clark -> LA midpoints are {np.linalg.norm(out[20] - back[20]) / 1000:.0f} km apart")
LA -> Clark and Clark -> LA midpoints are 64 km apart

3. Scale the width

Flows span four orders of magnitude. With linear width, a 1,000-person flow becomes a hairline next to the 38,761 who moved to Orange County; square-root width keeps it visible:

big, small = top.movers.max(), 1000
for name, f in (("linear", lambda v: v / big), ("square root", lambda v: np.sqrt(v / big))):
    print(f"{name:12s}: widest {0.3 + 5 * f(big):.1f} pt, a 1,000-person flow {0.3 + 5 * f(small):.2f} pt")
linear      : widest 5.3 pt, a 1,000-person flow 0.43 pt
square root : widest 5.3 pt, a 1,000-person flow 1.10 pt
Two versions of the same flow map: on the left with line width proportional to the flow, most lines are hairlines; on the right with width proportional to the square root, the long-distance flows to Texas, Washington and New York are clearly visible. Two versions of the same flow map: on the left with line width proportional to the flow, most lines are hairlines; on the right with width proportional to the square root, the long-distance flows to Texas, Washington and New York are clearly visible.
The same 40 flows with linear and square-root widths.

4. Order the drawing

Matplotlib draws in call order, so the last line is on top. Sorting ascending puts the widest flows on top, where they cannot be hidden. The reverse order buries them under a mesh of thin lines.

Moves in and out of Los Angeles on one map

The data used on this page

County-to-county migration flows, 2016–2020 ACS (the "ins, outs, nets, gross" workbook) and the Census Bureau's 2023 cartographic boundary files for counties and states, projected to CONUS Albers (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)
GEO = "https://www2.census.gov/geo/tiger/GENZ2023/shp/"
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",
       "cb_2023_us_county_20m.zip": GEO + "cb_2023_us_county_20m.zip", "cb_2023_us_state_20m.zip": GEO + "cb_2023_us_state_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"]
ca = pd.read_excel("data/county-to-county-2016-2020-ins-outs-nets-gross.xlsx", sheet_name="California",
                   header=None, skiprows=3, names=COLS, dtype=str)
la = ca[(ca.a_cty == "037") & ca.b_st.str.fullmatch(r"\d{3}", na=False)]
flows = pd.DataFrame({"dest": la.b_st.str[1:] + la.b_cty, "movers": pd.to_numeric(la.counter, errors="coerce")})
counties = gpd.read_file("data/cb_2023_us_county_20m.zip").query("STUSPS not in ['AK', 'HI', 'PR']").to_crs(5070)
flows = flows[flows.dest.isin(counties.GEOID) & (flows.movers > 0)]
CENTROID = {g: (p.x, p.y) for g, p in zip(counties.GEOID, counties.representative_point())}
STATES = gpd.read_file("data/cb_2023_us_state_20m.zip").query("STUSPS not in ['AK', 'HI', 'PR']").to_crs(5070)
print(f"{len(flows):,} flows out of Los Angeles County to contiguous US counties, {flows.movers.sum():,.0f} movers")
1,023 flows out of Los Angeles County to contiguous US counties, 321,009 movers

Example 1: in and out on one map

inflow = pd.DataFrame({"dest": la.b_st.str[1:] + la.b_cty, "movers": pd.to_numeric(la.flow, errors="coerce")})
inflow = inflow[inflow.dest.isin(counties.GEOID)].nlargest(15, "movers")
outflow = flows.nlargest(15, "movers")
print("both directions in the top 15:", sorted(set(inflow.dest) & set(outflow.dest)))
print(f"top-15 out {outflow.movers.sum():,.0f}, top-15 in {inflow.movers.sum():,.0f}")
both directions in the top 15: ['04013', '06001', '06029', '06059', '06065', '06071', '06073', '06075', '06085', '06111', '32003', '36061', '53033']
top-15 out 182,404, top-15 in 105,444

Every visual choice changes what is seen first

A flow map turns a table of pairs into lines, and every visual choice changes what the reader sees first. Cutting to the top flows keeps the picture readable and loses surprisingly little. Curving separates directions and stops lines crossing at the origin. Square-root widths compress the range so small flows stay visible. Drawing order puts the largest flows on top.

Design choices for a readable flow map, with the values used for the Los Angeles map. five choices before drawing a linekeep the largest flows40 of 1,023 carry 71% of moverscurve every line the same waydirections 64 km apartwidth ∝ √flow1,000 people: 1.10 pt, not 0.43draw smallest firstlargest flows on topread length as importancelong lines are not big flowsCensus county-to-county flows, Los Angeles County, 2016–2020 spatialworkflow.io
Five choices made before any line is drawn.

Two things are easy to forget. Line length is not data: a long line to New York draws far more ink than a short line to Orange County, though the Orange County flow is several times larger. The eye reads ink as importance, so width must carry the message, and the legend should say what width means. And a flow map from one origin is a different object from a map of all flows between all places. The second needs aggregation or filtering before any line is drawn.

Projection, centroids and short flows

Flow map problems and fixes. flow map problemsSymptomFixa black tangle of lineskeep the flows that carry mostvolumeopposite directions overlapcurve to the right of travelone flow hides the restsquare-root widthsbig flows buriedsort ascending before drawinglines kink when projectedproject centroids firstmatplotlib, EPSG:5070 spatialworkflow.io
Flow map problems and fixes.
  • Projection: draw in a projected CRS (here EPSG:5070); straight lines in longitude and latitude bend unpredictably once projected.
  • Centroids: use representative_point() so the line ends inside the county, even for odd shapes.
  • Short flows between neighbours are hard to see as lines; a choropleth of the same values complements them.
  • Legend: show two or three line widths with their values.
  • Uncertainty: small ACS flows have large margins of error; drawing only the top flows also avoids drawing noise.

FAQ

How do I make a flow map in Python?

Get origin and destination coordinates in a projected CRS, then draw a line for each flow with matplotlib, with width scaled to the flow, sorted so the largest lines are drawn last.

Why curve the lines on a flow map?

Curves separate opposite directions between the same two places and reduce overlap where many lines leave one origin.

How should line width scale with flow size?

Usually with the square root of the flow, so that one very large flow does not make all the others invisible.

How many flows should a flow map show?

Enough to carry most of the volume. Here, 40 of more than a thousand flows carry most of the movers.