Origin–Destination Data Explained

On this page

Problem statement

Origin–destination (OD) data records movement as pairs: from here, to there, how many. It is behind every flow map, transport model and migration study. It comes from very different sources: national surveys, administrative records and the logs of a bike-share system. They share a shape but not their properties. Some are samples with margins of error; others are complete counts with noise added for privacy. Some are tallies of people, others of jobs or trips. And almost all are extremely sparse, with most possible pairs empty. How concentrated the movement is depends on the zones: at county level a few pairs carry most of it, at block level almost none do.

This page sets out what an OD dataset is and how to describe one. It measures three real datasets used in this topic's guides: Census migration flows, LODES commuting, and Citi Bike trips.

Quick answer

Describe any OD dataset by its unit, its zones, its direction and how it was collected. Then measure two things before mapping it: how many of the possible pairs have any flow (sparsity), and how much of the volume the largest pairs carry (concentration):

def describe(name, o, d, v):
    pairs = pd.DataFrame({"o": o, "d": d, "v": v}).groupby(["o", "d"]).v.sum()
    zones = len(set(o) | set(d))
    s = pairs.sort_values(ascending=False)
    top1 = s.iloc[: max(1, len(s) // 100)].sum() / s.sum()
    print(f"{name:26s}: {zones:>6,} zones, {len(pairs):>9,} non-zero pairs = {len(pairs) / zones ** 2:.4%} of possible; "
          f"top 1% of pairs carry {top1:.0%}")

for name, (o, d, v) in DATA.items():
    describe(name, o, d, v)
ACS county migration      :  3,221 zones,   254,348 non-zero pairs = 2.4516% of possible; top 1% of pairs carry 33%
LODES RI blocks           : 21,890 zones,   360,924 non-zero pairs = 0.0753% of possible; top 1% of pairs carry 3%
Citi Bike JC stations     :    273 zones,     5,802 non-zero pairs = 7.7849% of possible; top 1% of pairs carry 15%
Concentration curves for three OD datasets, share of volume against share of pairs from largest to smallest: ACS county migration and Citi Bike station trips rise steeply, reaching half the volume within the first 4% and 8% of pairs; LODES block pairs follow almost the diagonal, needing 47% of pairs for half the jobs. Concentration curves for three OD datasets, share of volume against share of pairs from largest to smallest: ACS county migration and Citi Bike station trips rise steeply, reaching half the volume within the first 4% and 8% of pairs; LODES block pairs follow almost the diagonal, needing 47% of pairs for half the jobs.
How much of each dataset's volume its largest pairs carry. County flows and station trips are concentrated; block-level commuting is almost evenly spread over pairs holding one job each.

The unit, the direction, the diagonal and collection

1. Know the unit

The same "flow" means different things. ACS migration flows count people who moved, estimated from a sample. LODES counts jobs, so a person with two jobs appears twice. A bike-share log counts trips, so one person can appear dozens of times a month:

for name, (o, d, v) in DATA.items():
    print(f"{name:26s}: total {v.sum():>12,.0f} {UNIT[name]}")
ACS county migration      : total   18,061,885 movers (5 years)
LODES RI blocks           : total      380,586 jobs
Citi Bike JC stations     : total      110,673 trips (1 month)

2. Know the direction and the diagonal

All three are directed: A → B and B → A are different rows. All three can have a diagonal, flows from a zone to itself, but they treat it differently. The ACS county file leaves out moves within a county, LODES includes people working in their home block, and bike share records round trips:

for name, (o, d, v) in DATA.items():
    diag = v[np.asarray(o) == np.asarray(d)].sum()
    print(f"{name:26s}: diagonal {diag / v.sum():.1%} of the total")
ACS county migration      : diagonal 0.0% of the total
LODES RI blocks           : diagonal 0.1% of the total
Citi Bike JC stations     : diagonal 5.1% of the total
Three origin–destination datasets compared by unit, zones, collection and diagonal. same shape, different dataACS migrationLODESCiti Bikeunitmovers, 5 yearsjobstrips, 1 monthzones3,221 counties21,890 blocks273 stationscollected bysurvey (MOEs)records (+ noise)system logdiagonal0% (excluded)0.1%5.1%pairs for half thevolume3.7%47.3%8.4%All three are directed and sparse spatialworkflow.io
Same shape, different data.

3. Know how it was collected

The collection method decides which numbers can be trusted. A survey such as the ACS has sampling error, published as margins of error, which make small flows unreliable. Administrative data such as LODES is a near-complete count with deliberate noise in small cells. A system log such as bike-share trips is exact but covers only its own system's users. Each needs a different check before mapping: margins of error, aggregation, or a statement of coverage.

A concentration curve of county flows

The data used on this page

The Census Bureau's county-to-county migration flows, 2016–2020 ACS; LODES version 8 commuting for Rhode Island, 2021 (main, all jobs, block to block); and Citi Bike trips in Jersey City and Hoboken, June 2024 (station to station).

import zipfile
from pathlib import Path

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",
       "ri_od_main_JT00_2021.csv.gz": "https://lehd.ces.census.gov/data/lodes/LODES8/ri/od/ri_od_main_JT00_2021.csv.gz",
       "JC-202406-citibike-tripdata.csv.zip": "https://s3.amazonaws.com/tripdata/JC-202406-citibike-tripdata.csv.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"]
mig = pd.concat(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).values())
mig = mig[mig.a_cty.notna() & mig.b_st.str.fullmatch(r"\d{3}", na=False)]
mig_v = pd.to_numeric(mig.flow, errors="coerce").fillna(0)
mig = mig[mig_v > 0]; mig_v = mig_v[mig_v > 0]
lodes = pd.read_csv("data/ri_od_main_JT00_2021.csv.gz", dtype={"w_geocode": str, "h_geocode": str})
with zipfile.ZipFile("data/JC-202406-citibike-tripdata.csv.zip") as z:
    bike = pd.read_csv(z.open("JC-202406-citibike-tripdata.csv"), dtype={"start_station_id": str, "end_station_id": str},
                       usecols=["start_station_id", "end_station_id"]).dropna()
DATA = {"ACS county migration": ((mig.b_st.str[1:] + mig.b_cty).to_numpy(), (mig.a_st.str[1:] + mig.a_cty).to_numpy(), mig_v.to_numpy()),
        "LODES RI blocks": (lodes.h_geocode.to_numpy(), lodes.w_geocode.to_numpy(), lodes.S000.to_numpy()),
        "Citi Bike JC stations": (bike.start_station_id.to_numpy(), bike.end_station_id.to_numpy(), np.ones(len(bike)))}
UNIT = {"ACS county migration": "movers (5 years)", "LODES RI blocks": "jobs", "Citi Bike JC stations": "trips (1 month)"}
print({k: f"{len(v[2]):,} rows" for k, v in DATA.items()})
{'ACS county migration': '254,348 rows', 'LODES RI blocks': '360,924 rows', 'Citi Bike JC stations': '110,673 rows'}

Example 1: a concentration curve

curves = {}
for name, (o, d, v) in DATA.items():
    s = pd.DataFrame({"o": o, "d": d, "v": v}).groupby(["o", "d"]).v.sum().sort_values(ascending=False)
    curves[name] = (np.arange(1, len(s) + 1) / len(s), s.cumsum().to_numpy() / s.sum())
for name, (x, y) in curves.items():
    print(f"{name:26s}: pairs needed for half the volume: {x[np.searchsorted(y, 0.5)]:.1%}")
ACS county migration      : pairs needed for half the volume: 3.7%
LODES RI blocks           : pairs needed for half the volume: 47.3%
Citi Bike JC stations     : pairs needed for half the volume: 8.4%

A sparse matrix stored as pairs

Every OD dataset is a sparse matrix stored as a list of pairs. The sparsity is extreme: only 2.5% of possible county pairs in the US exchanged any movers in five years, and 0.08% of Rhode Island block pairs share a commuter. Concentration is a different matter and depends on the zones. Half of all county-to-county movers are in 3.7% of the county pairs, so a few dozen lines can tell most of the story. Half of Rhode Island's jobs are spread over 47% of the block pairs, nearly all of them holding a single job. At that level no flow map works until the blocks are aggregated.

What to establish about an origin–destination dataset before mapping it. before mapping OD dataunitpeople, jobs, trips?zones and periodcounties over 5 years? stations over a month?direction and diagonalis A → A included?collection and errorMOEs, noise, coveragesparsity and concentrationhow many pairs carry the volumeThese decide which aggregation and filtering steps apply spatialworkflow.io
Five things to establish about any OD dataset.

The differences matter more than the shape. ACS flows are estimates with margins of error, so their small pairs are noise. LODES block pairs are near-complete but deliberately noisy in small cells, so they need aggregating. Citi Bike trips are exact counts of one system's users. A careful OD analysis starts by naming the unit, the zones, the direction, the period and the collection method, because those decide which of the steps in this topic's guides apply.

Zero pairs, symmetric storage and privacy

Origin–destination data problems and fixes. OD data problemsSymptomFixtotals twice too largepairs listed from both sides: sumonejobs reported as peopleLODES counts jobsblock flows look like noiseaggregate: half the jobs in 47% ofpairsmissing pairs read as unknownabsent pairs are usually zeroACS, LODES and Citi Bike spatialworkflow.io
OD data problems and fixes.
  • Zero pairs are usually not stored; a missing pair means zero only if the source lists every non-zero pair.
  • Symmetric storage: some files list each pair from both sides (the ACS workbook does); sum one direction only.
  • Zone systems change over time (new counties, renumbered stations); OD tables from different years need a crosswalk.
  • Privacy: fine-grained OD data can identify people; publishers suppress, perturb or synthesise small cells.
  • Time: an OD table for a month, a year or five years describes a different process; state the period.

FAQ

What is origin–destination data?

Counts of movement between pairs of places: from an origin zone, to a destination zone, how many people, jobs, trips or goods.

Where does OD data come from?

Surveys (such as the ACS migration flows), administrative records (such as LODES commuting), and system logs (such as bike-share trips or transit smart cards).

Why is OD data so sparse?

Most pairs of places never exchange anyone in a given period; the number of possible pairs grows with the square of the number of zones.

What should I check before mapping OD data?

The unit, direction and diagonal, the collection method and its uncertainty, sparsity, and how much of the volume the largest pairs carry.