How to Read a GBFS Bike-Share Feed in Python

On this page

Problem statement

Almost every bike-share system in the world publishes its live state in the General Bikeshare Feed Specification (GBFS): a set of small JSON files, refreshed every minute, that say where the stations are, how many bikes and free docks each has right now, and where the bikes outside a dock are parked. Apps read them; so can anyone with requests. The files are simple, but they are a set, and reading one alone gives wrong answers β€” the status file has no coordinates, the station file has no counts, and the ids in both differ from the ids in the same operator's trip files.

This page reads Capital Bikeshare's feed in Washington, D.C. from its discovery file down, joins stations to their status and regions, looks at the parked e-bikes, and connects the feed to the historical trip files.

Quick answer

Load station_information and station_status, merge them on station_id, keep the stations that are installed and renting, and count:

info = pd.DataFrame(load("station_information")["data"]["stations"])
status = pd.DataFrame(load("station_status")["data"]["stations"])
st = info.merge(status, on="station_id", validate="one_to_one")
live = st[(st.is_installed == 1) & (st.is_renting == 1)]
print(f"{len(st)} stations, {len(live)} renting; snapshot {stamp('station_status'):%Y-%m-%d %H:%M %Z}")
print(f"empty: {(live.num_bikes_available == 0).sum()}   full: {(live.num_docks_available == 0).sum()}   "
      f"bikes available: {live.num_bikes_available.sum():,} ({live.num_ebikes_available.sum():,} e-bikes)")
868 stations, 863 renting; snapshot 2026-10-15 08:03 EDT
empty: 41   full: 30   bikes available: 5,839 (1,823 e-bikes)

At 08:03 on a Thursday in October 2026, 41 of 863 renting stations had no bike to rent and 30 had no free dock to return one to. 5,839 bikes were waiting in docks, 1,823 of them e-bikes. The validate="one_to_one" in the merge is not decoration: if a station appeared twice in either file, the counts would double silently.

Map of the Capital Bikeshare system around Washington, D.C. at 08:03 on 15 October 2026: 792 stations with bikes and docks as open circles sized by bikes, 41 empty stations in red, 30 full stations in orange, and 946 parked e-bikes as small teal dots spread between the stations. Map of the Capital Bikeshare system around Washington, D.C. at 08:03 on 15 October 2026: 792 stations with bikes and docks as open circles sized by bikes, 41 empty stations in red, 30 full stations in orange, and 946 parked e-bikes as small teal dots spread between the stations.
One GBFS snapshot joined from station_information, station_status and free_bike_status. Empty and full stations are scattered through the suburbs as much as the centre.

From the discovery file to a joined snapshot

1. Start from the auto-discovery file

A GBFS system publishes one root file, gbfs.json, that lists every other feed and its URL. Read it rather than hard-coding the URLs; operators move them (Capital Bikeshare's root lives on its own domain, the feeds on Lyft's):

gbfs = load("gbfs")
print(f"version {gbfs['version']}, ttl {gbfs['ttl']} s")
for f in gbfs["data"]["en"]["feeds"]:
    print(f"  {f['name']:22s} {f['url'].rsplit('/', 1)[-1]}")
version 2.3, ttl 60 s
  gbfs                   gbfs.json
  system_information     system_information.json
  station_information    station_information.json
  station_status         station_status.json
  free_bike_status       free_bike_status.json
  system_hours           system_hours.json
  system_calendar        system_calendar.json
  system_regions         system_regions.json
  system_pricing_plans   system_pricing_plans.json
  system_alerts          system_alerts.json
  gbfs_versions          gbfs_versions.json
  vehicle_types          vehicle_types.json

Twelve feeds, each with a time to live of 60 seconds β€” a client should not ask again sooner. Version 2.3 is what this system serves; GBFS 3.0 renames several fields (free_bike_status becomes vehicle_status), so check version before parsing.

2. Stations and regions

regions = pd.DataFrame(load("system_regions")["data"]["regions"]).set_index("region_id").name
info["region"] = info.region_id.map(regions).fillna("no region")
print(info.groupby("region").agg(stations=("station_id", "size"), docks=("capacity", "sum"))
      .sort_values("stations", ascending=False).to_string())
print(f"station_id looks like {info.station_id.iloc[0]!r}; short_name like {info.short_name.iloc[0]!r}")
                               stations  docks
region                                        
Washington, DC                      397   8065
Arlington, VA                       111   1572
Fairfax, VA                         106   1343
Alexandria, VA                       71   1075
Montgomery County, MD (South)        56    873
no region                            56    799
Prince George's County               38    573
Montgomery County, MD (North)        23    364
Falls Church, VA                     10    119
station_id looks like '0824ea2c-1f3f-11e7-bf6b-3863bb334450'; short_name like '31014'

The system spans eight jurisdictions and three states. 397 of its 868 stations are in the District itself; 56, with 799 docks between them, carry no region at all, so any table by region needs that row. The station id is a UUID. The number riders and the trip files use is the short_name.

3. What a station's status says

st["fill"] = st.num_bikes_available / (st.num_bikes_available + st.num_docks_available)
age = (stamp("station_status") - pd.to_datetime(st.last_reported, unit="s", utc=True)).dt.total_seconds() / 60
print(f"not installed {(st.is_installed == 0).sum()}, installed but not renting {((st.is_installed == 1) & (st.is_renting == 0)).sum()}")
print(f"disabled bikes {st.num_bikes_disabled.sum():,}, disabled docks {st.num_docks_disabled.sum():,}")
print(f"last report older than an hour: {(age > 60).sum()} stations; median age {age.median():.1f} min")
gap = st.capacity - (st.num_bikes_available + st.num_docks_available + st.num_bikes_disabled + st.num_docks_disabled)
print("capacity minus everything counted, stations:", gap.value_counts().sort_index().to_dict())
not installed 0, installed but not renting 5
disabled bikes 573, disabled docks 42
last report older than an hour: 4 stations; median age 0.8 min
capacity minus everything counted, stations: {-2: 2, -1: 19, 0: 743, 1: 89, 2: 11, 3: 2, 4: 1, 7: 1}

A station that is listed is not necessarily usable. Five were installed but not renting. 573 bikes sat in docks marked disabled β€” broken or awaiting service, invisible to riders but taking up a dock. Four stations had not reported for over an hour, so their counts were stale; the median station had reported within the last minute. And capacity is not the sum of what a station reports: at 89 stations it is one more, at 21 it is one or two fewer. Use the reported counts, not capacity, when asking how full a station is.

4. Free-floating e-bikes

bikes = pd.DataFrame(load("free_bike_status")["data"]["bikes"])
types = pd.DataFrame(load("vehicle_types")["data"]["vehicle_types"]).set_index("vehicle_type_id")
print(bikes.vehicle_type_id.map(types.propulsion_type).value_counts().to_string())
print(f"{len(bikes)} bikes outside a dock; range: median {bikes.current_range_meters.median() / 1000:.1f} km, "
      f"under 5 km: {(bikes.current_range_meters < 5000).sum()}")
print(f"coordinate decimals, median: {bikes.lat.astype(str).str.split('.').str[1].str.len().median():.0f}")
vehicle_type_id
electric_assist    946
946 bikes outside a dock; range: median 28.5 km, under 5 km: 59
coordinate decimals, median: 9

Every one of the 946 bikes parked outside a dock is an e-bike, with a battery range attached: a median of 28.5 km, and 59 with under 5 km left. Their coordinates have nine decimal places β€” in the trip files the same kind of location is rounded to two.

Histogram of remaining battery range for the 946 parked e-bikes, spread fairly evenly from 0 to about 57 km with a median of 28.5 km; the bins under 5 km are red. Histogram of remaining battery range for the 946 parked e-bikes, spread fairly evenly from 0 to about 57 km with a median of 28.5 km; the bins under 5 km are red.
Battery range reported by free_bike_status for every e-bike parked outside a dock in the snapshot.

5. Join the feed to the trip files

The feed and the trip files describe the same stations, so a historical analysis can take station locations and capacities from the feed. The key has to be the right one:

trip_ids = pd.read_csv(zipfile.ZipFile("data/202512-capitalbikeshare-tripdata.zip").open("202512-capitalbikeshare-tripdata.csv"),
                       usecols=["start_station_id"]).start_station_id.dropna().astype(int).unique()
short = pd.to_numeric(info.short_name, errors="coerce")
print(f"trip-file station ids found as GBFS station_id: {np.isin(trip_ids.astype(str), info.station_id).sum()}, "
      f"as short_name: {np.isin(trip_ids, short).sum()} of {len(trip_ids)}")
trip-file station ids found as GBFS station_id: 0, as short_name: 812 of 815

None of December 2025's station ids appear as a GBFS station_id; 812 of 815 appear as short_name. The three left over are no longer in the feed, which is the other reason to keep a dated copy of station_information alongside any trip analysis.

Empty stations and parked e-bikes

The data used on this page

Capital Bikeshare's public GBFS feed (General Bikeshare Feed Specification, version 2.3, operated by Lyft), and the December 2025 trip file from the operator's system data. The feed changes every minute: the setup saves one copy of every file it lists, and every block on this page reads that copy, so the numbers describe one moment β€” the snapshot of 15 October 2026 shown in the output. A new run saves a new moment.

from pathlib import Path
import json
import time
import zipfile

import numpy as np
import pandas as pd
import requests

SNAP = Path("data/gbfs")
SNAP.mkdir(parents=True, exist_ok=True)
if not (SNAP / "gbfs.json").exists():
    root = requests.get("https://gbfs.capitalbikeshare.com/gbfs/2.3/gbfs.json", timeout=60).json()
    (SNAP / "gbfs.json").write_text(json.dumps(root))
    for feed in root["data"]["en"]["feeds"]:
        (SNAP / f"{feed['name']}.json").write_text(requests.get(feed["url"], timeout=60).text)
        time.sleep(0.5)
f = Path("data/202512-capitalbikeshare-tripdata.zip")
if not f.exists():
    f.write_bytes(requests.get(f"https://s3.amazonaws.com/capitalbikeshare-data/{f.name}", timeout=600).content)


def load(name):
    return json.loads((SNAP / f"{name}.json").read_text())


def stamp(name):
    return pd.Timestamp(load(name)["last_updated"], unit="s", tz="UTC").tz_convert("America/New_York")


print(sorted(p.stem for p in SNAP.glob("*.json")))
['free_bike_status', 'gbfs', 'gbfs_versions', 'station_information', 'station_status', 'system_alerts', 'system_calendar', 'system_hours', 'system_information', 'system_pricing_plans', 'system_regions', 'vehicle_types']

Example 1: which stations are empty, and where

live = st[(st.is_installed == 1) & (st.is_renting == 1)].copy()
live["region"] = live.region_id.map(regions).fillna("no region")
live["state"] = np.select([live.num_bikes_available == 0, live.num_docks_available == 0], ["empty", "full"], "in between")
print(pd.crosstab(live.region, live.state, margins=True).to_string())
state                          empty  full  in between  All
region                                                     
Alexandria, VA                     4     7          60   71
Arlington, VA                      3     2         105  110
Fairfax, VA                        8     9          86  103
Falls Church, VA                   1     1           8   10
Montgomery County, MD (North)      3     0          20   23
Montgomery County, MD (South)      8     1          47   56
Prince George's County             2     1          35   38
Washington, DC                     5     7         385  397
no region                          7     2          46   55
All                               41    30         792  863

The District, where rebalancing trucks are busiest, had 5 empty stations of 397. The suburbs fared worse on that morning: 8 of 56 stations in southern Montgomery County and 8 of 103 in Fairfax were empty. One snapshot is one moment β€” the same table at 6 p.m. would look different β€” but it is the moment a rider at 8 a.m. sees.

Example 2: how far parked e-bikes are from a dock

The parked e-bikes are where trips ended without a dock. Their distance from the nearest station shows how far they stray:

def metres(lat1, lon1, lat2, lon2):
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    return 2 * 6_371_008.8 * np.arcsin(np.sqrt(a))


d = metres(bikes.lat.values[:, None], bikes.lon.values[:, None], info.lat.values[None, :], info.lon.values[None, :])
bikes["to_dock_m"] = d.min(axis=1)
print(bikes.to_dock_m.describe(percentiles=[0.1, 0.25, 0.5, 0.75, 0.9]).round(0).to_string())
print(f"within 50 m of a station: {(bikes.to_dock_m <= 50).mean():.0%}")
count     946.0
mean      335.0
std       337.0
min         0.0
10%        10.0
25%        76.0
50%       247.0
75%       477.0
90%       794.0
max      2040.0
within 50 m of a station: 23%

A median parked e-bike is 247 m from the nearest station, and 23% are within 50 m of one β€” left beside a full or inconvenient dock rather than out in the neighbourhood. The farthest was 2 km from any station. Those first metres are exactly what the trip files cannot show, because they round the same positions to about a kilometre.

Cumulative distribution of the distance from each parked e-bike to the nearest station, on a log axis: 23 percent are within 50 m, the median is 247 m, and the farthest is about 2 km. Cumulative distribution of the distance from each parked e-bike to the nearest station, on a log axis: 23 percent are within 50 m, the median is 247 m, and the farthest is about 2 km.
Distance from parked e-bikes to the nearest station in the same snapshot. Nearly a quarter were left beside a dock.

A feed is a moment, not a record

GBFS is designed for an app deciding where to send a rider in the next minute. It holds no history: each file replaces the last, and nothing in the feed says what a station looked like an hour ago. Every analysis that needs change over time β€” how often a station runs empty, when rebalancing happens β€” has to poll the feed and keep what it reads. The setup on this page does the smallest version of that, saving one complete set of files with their last_updated stamps, and every number above is reproducible from that set even after the feed has moved on.

The split into files follows what changes how often. station_information changes when a station is built or moved; station_status every minute; free_bike_status with every parked bike. Joining them is the analyst's job, and the join keys are where the mistakes are: station_id between the GBFS files, short_name between the feed and the trip files. Since GBFS 2.0, a parked bike's bike_id also changes after every trip, for riders' privacy β€” so the free-bike feed cannot be polled into trips, only into counts and positions.

What the feed leaves out

  • Respect ttl. Polling faster than the time to live gets the same file and may get a client blocked.
  • last_reported can be old for a station whose counts look fine. Check it before trusting a zero.
  • Disabled bikes and docks are not in the available counts; a station can show free docks that are all broken.
  • capacity is a nominal number. It matched the reported counts at only 743 of 868 stations here.
  • Region can be missing. 56 stations had none.
  • Join to trip files on short_name, not station_id.

FAQ

What is the GBFS station_id for a Capital Bikeshare station?

A UUID-style string such as 0824ea2c-1f3f-11e7-bf6b-3863bb334450. The five-digit number on the station and in the trip files is the short_name field.

Can I get historical station status from GBFS?

No. The feed only ever shows the current minute; history has to be collected by polling and saving each file.