How to Map Bike-Share Trips as Flows in Python

On this page

Problem statement

Bike-share systems publish every trip: where it started, where it ended, when, and on what kind of bike. A month of trips is a dense origin–destination dataset at street scale, and a flow map of it shows how a city moves on two wheels. The trip files need cleaning first. Electric bikes report a slightly different coordinate on every trip, some trips have no end station, and a share of rides end where they began. Flows also change direction through the day, and one map of all trips averages the morning and evening patterns into nothing.

This page turns a month of Citi Bike trips in Jersey City and Hoboken into station-to-station flows. It maps the busiest routes and the morning imbalance that the operator has to rebalance by truck.

Quick answer

Give each station one location, count trips by start and end station, drop round trips, and draw the largest flows:

trips = trips.dropna(subset=["start_station_id", "end_station_id"])
pairs = trips.groupby(["start_station_id", "end_station_id"]).size().rename("trips").reset_index()
pairs = pairs[pairs.start_station_id != pairs.end_station_id]
top = pairs.nlargest(60, "trips").sort_values("trips")
fig, ax = plt.subplots(figsize=(7, 8))
TRACTS.plot(ax=ax, color="#f2f2f2", edgecolor="#d0d0d0", lw=0.3)
for _, r in top.iterrows():
    (x0, y0), (x1, y1) = XY[r.start_station_id], XY[r.end_station_id]
    ax.annotate("", (x1, y1), (x0, y0), arrowprops=dict(arrowstyle="-|>", color="#c0392b", alpha=0.7,
                lw=0.3 + 3 * np.sqrt(r.trips / top.trips.max()), connectionstyle="arc3,rad=0.2", shrinkA=0, shrinkB=1))
ax.set_xlim(*XLIM); ax.set_ylim(*YLIM); ax.set_axis_off()
fig.savefig("data/citibike_flows.png", dpi=150)
print(f"{len(pairs):,} station pairs; top 60 carry {top.trips.sum():,} of {pairs.trips.sum():,} trips ({top.trips.sum() / pairs.trips.sum():.0%})")
5,718 station pairs; top 60 carry 15,990 of 105,047 trips (15%)
Map of Jersey City and Hoboken census tracts with grey dots for 157 Citi Bike stations and red curved arrows for the 60 busiest station-to-station routes, clustered in three groups: around Hoboken Terminal and the Hoboken waterfront, around Grove Street and downtown Jersey City, and around Journal Square. Map of Jersey City and Hoboken census tracts with grey dots for 157 Citi Bike stations and red curved arrows for the 60 busiest station-to-station routes, clustered in three groups: around Hoboken Terminal and the Hoboken waterfront, around Grove Street and downtown Jersey City, and around Journal Square.
The 60 busiest routes in June 2024. Together they carry 15% of the 105,047 trips between stations; the busiest run to and from the Hoboken Terminal, Grove Street PATH and the light rail stations.

One location per station, non-flows counted, time of day

1. Give each station one location

Docked trips repeat the station's coordinates exactly; electric bikes report wherever the bike was. Take the median per station and check how far individual trips stray from it:

starts = trips[["start_station_id", "start_lat", "start_lng"]].set_axis(["sid", "lat", "lng"], axis=1)
loc = starts.groupby("sid")[["lat", "lng"]].median()
dev = starts.join(loc, on="sid", rsuffix="_m")
dist_m = np.hypot((dev.lat - dev.lat_m) * 111_320, (dev.lng - dev.lng_m) * 111_320 * np.cos(np.radians(40.73)))
print(f"{len(loc)} stations; trip start within 20 m of its station median: {(dist_m < 20).mean():.0%}; "
      f"by bike type: " + ", ".join(f"{k} {(dist_m[trips.rideable_type == k] < 20).mean():.0%}" for k in sorted(trips.rideable_type.unique())))
157 stations; trip start within 20 m of its station median: 91%; by bike type: classic_bike 99%, electric_bike 84%

2. Count what cannot be a flow

Trips with no end station cannot be placed; round trips start and end at the same dock and would be zero-length lines:

raw = len(RAW)
no_end = RAW.end_station_id.isna().sum()
rt = (trips.start_station_id == trips.end_station_id).sum()
print(f"{raw:,} trips: {no_end:,} without an end station ({no_end / raw:.1%}), {rt:,} round trips ({rt / len(trips):.1%} of the rest)")
111,115 trips: 441 without an end station (0.4%), 5,626 round trips (5.1% of the rest)

3. Split by time of day

Morning and evening flows run in opposite directions. Net departures per station (trips out minus trips in) show which docks empty in the morning:

hour = trips.started_at.dt.hour
def net(mask):
    out = trips[mask].groupby("start_station_id").size()
    inn = trips[mask].groupby("end_station_id").size()
    return out.sub(inn, fill_value=0)

am, pm = net(hour.between(7, 9)), net(hour.between(16, 18))
both = pd.concat([am.rename("am"), pm.rename("pm")], axis=1).fillna(0)
print(f"stations that lose bikes in the morning peak: {(both.am > 0).sum()}; correlation of morning and evening net: {both.am.corr(both.pm):+.2f}")
print("biggest morning net loss:", NAMES[both.am.idxmax()], f"({both.am.max():+.0f} over the month)")
stations that lose bikes in the morning peak: 62; correlation of morning and evening net: -0.97
biggest morning net loss: Liberty Light Rail (+239 over the month)
Two maps of Citi Bike stations sized and coloured by net departures: in the morning peak a few stations near rail stops gain many bikes (blue) and many residential stations lose a few (red); in the evening peak the same stations reverse. Two maps of Citi Bike stations sized and coloured by net departures: in the morning peak a few stations near rail stops gain many bikes (blue) and many residential stations lose a few (red); in the evening peak the same stations reverse.
Net departures per station in the morning and evening peaks over June 2024. The evening almost exactly undoes the morning, station by station: the correlation is −0.97.

The busiest routes by name

The data used on this page

Citi Bike system data, Jersey City and Hoboken trips for June 2024 (JC-202406-citibike-tripdata), and 2023 census tract outlines for New Jersey from the cartographic boundary files, projected to New Jersey State Plane (EPSG:3424).

import zipfile
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 = {"JC-202406-citibike-tripdata.csv.zip": "https://s3.amazonaws.com/tripdata/JC-202406-citibike-tripdata.csv.zip",
       "cb_2023_34_tract_500k.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_34_tract_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)
with zipfile.ZipFile("data/JC-202406-citibike-tripdata.csv.zip") as z:
    RAW = pd.read_csv(z.open("JC-202406-citibike-tripdata.csv"), parse_dates=["started_at", "ended_at"],
                      dtype={"start_station_id": str, "end_station_id": str})
trips = RAW.dropna(subset=["start_station_id", "end_station_id"]).copy()
st = trips.groupby("start_station_id")[["start_lat", "start_lng"]].median()
pts = gpd.GeoSeries(gpd.points_from_xy(st.start_lng, st.start_lat), crs=4326).to_crs(3424)
XY = dict(zip(st.index, zip(pts.x, pts.y)))
NAMES = trips.groupby("start_station_id").start_station_name.first().to_dict()
x0, y0, x1, y1 = pts.total_bounds
XLIM, YLIM = (x0 - 1500, x1 + 1500), (y0 - 1500, y1 + 1500)
TRACTS = gpd.read_file("data/cb_2023_34_tract_500k.zip").to_crs(3424).cx[XLIM[0]:XLIM[1], YLIM[0]:YLIM[1]]
print(f"{len(RAW):,} trips in June 2024, {len(XY)} start stations, {len(TRACTS)} tracts in view")
111,115 trips in June 2024, 157 start stations, 149 tracts in view

Example 1: the busiest routes by name

named = pairs.nlargest(5, "trips").assign(start=lambda d: d.start_station_id.map(NAMES), end=lambda d: d.end_station_id.map(NAMES))
print(named[["start", "end", "trips"]].to_string(index=False))
                                       start                                          end  trips
    Hoboken Terminal - Hudson St & Hudson Pl                   Hoboken Ave at Monmouth St    551
                               Grove St PATH                             Marin Light Rail    463
                        12 St & Sinatra Dr N South Waterfront Walkway - Sinatra Dr & 1 St    440
                            Marin Light Rail                                Grove St PATH    398
South Waterfront Walkway - Sinatra Dr & 1 St                         12 St & Sinatra Dr N    388

A trip file is the finest origin-destination table

A bike-share trip file is an origin–destination table at the finest grain. Each row is one trip, so the flow between two stations is a count of rows. Three cleaning steps come before any line is drawn: one coordinate per station, no trips without an end, no round trips. After that the main decision is time. A month of trips summed over all hours mixes the commute in with the evening return and the weekend leisure ride, and their directions cancel.

From a month of Citi Bike trips in Jersey City and Hoboken to station-to-station flows, June 2024. trips to flowstrips111,115441 without an end, 1 without a startwith both stations110,6735,626 round trips droppedbetween two stations105,0475,718 station pairs; the top 60 carry 15% of trips spatialworkflow.io
Three cleaning steps before any flow is drawn.

The morning imbalance is the operational story behind a bike-share flow map. Stations where people start more trips than they end run empty, and stations that receive more fill up. If the evening exactly reversed the morning, station by station, the correlation between morning and evening net departures would be −1. In Jersey City in June 2024 it was −0.97: the evening almost exactly undoes the morning over the month. The imbalance still has to be managed within each peak, while docks empty and fill. The busiest routes and the biggest morning loss, Liberty Light Rail, are rail stations: bike share here is largely a link to the train.

Hudson crossings, station ids and electric bikes

Bike-share flow problems and fixes. bike-share flow problemsSymptomFixone station, many coordinatesmedian per station ID (e-bikes 84%within 20 m)trips that end nowheredrop 441 with no end stationzero-length linesdrop round tripsflows cancel outsplit morning and evening (r =−0.97)Citi Bike Jersey City and Hoboken, June 2024 spatialworkflow.io
Bike-share flow problems and fixes.
  • Trips across the Hudson: 465 trips in this file (0.4%) end at New York stations, whose IDs are numeric rather than JC/HB. Their coordinates come only from the end of the trip, so take station locations from both start and end columns if you map them.
  • Station IDs change when stations move or are renumbered; check a station's coordinates over several months before joining.
  • Electric bikes report GPS positions; use the station ID, not the coordinates, to assign trips.
  • Short trips (under a minute, same station) are often failed undocks; filter by duration if they matter.
  • Privacy: individual trip records with exact times can identify people in some cases; publish aggregates.

FAQ

Where can I get bike-share trip data?

Many systems publish it: Citi Bike (New York and Jersey City), Divvy (Chicago), Capital Bikeshare (Washington) and others release monthly CSV files of anonymised trips.

How do I turn bike trips into flows?

Group trips by start and end station and count them; drop round trips and trips without an end station; map the largest pairs.

Why do electric bike trips have different coordinates for the same station?

They report GPS positions rather than the dock's fixed location. Assign trips by station ID and use one coordinate per station.

What does net departures per station show?

Where bikes accumulate or run out: stations with more starts than ends in a period empty out and need rebalancing.