How to read GTFS Realtime vehicle positions and delays in Python
On this page
Problem statement
Boston's MBTA publishes where each of its buses, trains and trams is and when it is predicted at its next stop; one evening snapshot held 791 vehicles. Turning that into a table of which route each vehicle is on and how late it is takes four steps: decode the Protocol Buffers message, flatten its entities, join them to the static GTFS feed by trip, and subtract the scheduled time from the predicted one β with the schedule anchored in the agency's own time zone.
In the MBTA snapshot used here, taken at 21:16 UTC on 28 September 2026 β the evening rush in Boston β reading the timetable as if it were UTC gives a median delay of 240 minutes. Anchored correctly, the median next-stop delay is zero, with 24% of trips more than five minutes late.
Quick answer
Decode the feed, flatten the entities into a table, and join to the static feed:
import pandas as pd
import geopandas as gpd
from google.transit import gtfs_realtime_pb2
def vehicle_positions(pb_bytes):
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(pb_bytes)
rows = []
for e in feed.entity:
if not e.HasField("vehicle"):
continue
v = e.vehicle
rows.append({
"vehicle_id": v.vehicle.id, "trip_id": v.trip.trip_id, "route_id": v.trip.route_id,
"start_date": v.trip.start_date, "relationship": v.trip.schedule_relationship,
"lat": v.position.latitude, "lon": v.position.longitude,
"bearing": v.position.bearing if v.position.HasField("bearing") else None,
"speed": v.position.speed if v.position.HasField("speed") else None,
"status": gtfs_realtime_pb2.VehiclePosition.VehicleStopStatus.Name(v.current_status),
"stop_id": v.stop_id, "timestamp": v.timestamp,
})
df = pd.DataFrame(rows)
df["feed_time"] = feed.header.timestamp
return gpd.GeoDataFrame(df, crs=4326, geometry=gpd.points_from_xy(df.lon, df.lat))
vp = vehicle_positions(open("mbta_vp.pb", "rb").read()) # or requests.get(url).content
routes = pd.read_csv("mbta_gtfs/routes.txt", dtype=str)
vp = vp.merge(routes[["route_id", "route_type", "route_long_name"]], on="route_id", how="left")
print(len(vp), "vehicles;", vp.route_type.map({"0": "light rail", "1": "subway",
"2": "rail", "3": "bus"}).value_counts(dropna=False).to_dict())
791 vehicles; {'bus': 646, 'subway': 50, 'rail': 49, 'light rail': 35, nan: 11}
HasField separates a real zero from a field that was never sent. Protocol Buffers returns 0 for missing numbers, so reading v.position.speed directly reports every vehicle without a speed as stationary.
Fetch, decode, check freshness, join, compute delays
1. Fetch the feed
requests.get(url, timeout=30).content. Many agencies need an API key in a header or query string; the MBTA's feeds are open.
2. Decode and flatten
Parse into a FeedMessage and turn each entity into a row. Use HasField for optional fields so missing values stay missing.
3. Check freshness
Compare each vehicle's timestamp with the header's. Drop or flag positions older than a few minutes β a stale position is a vehicle that stopped reporting, not one that stopped moving.
4. Join to the static feed
trip_id joins to trips.txt, route_id to routes.txt, stop_id to stops.txt. Keep unmatched rows: ADDED trips have no static counterpart by design.
5. Compute delays from predictions
For each trip update, take the predicted departure at a stop, find the scheduled departure for that trip and stop sequence, anchor it to noon minus twelve hours on the service date in the agency's time zone, and subtract.
6. Store snapshots
Poll every 30 seconds or so, keep each snapshot with its feed timestamp, and compute reliability over days, not over one moment.
Delays from MBTA predictions
The data used on this page
The MBTA's open feeds: the static timetable (MBTA_GTFS.zip) unpacked into mbta_gtfs/, and one snapshot each of its GTFS Realtime VehiclePositions and TripUpdates feeds, saved as mbta_vp.pb and mbta_tu.pb. A live feed is different every time it is fetched: the numbers on this page are from the snapshot whose header time the setup prints, and a new download will give different ones.
import io
import os
import zipfile
from datetime import datetime, timezone
import requests
from google.transit import gtfs_realtime_pb2
if not os.path.isdir("mbta_gtfs"):
zipfile.ZipFile(io.BytesIO(requests.get("https://cdn.mbta.com/MBTA_GTFS.zip", timeout=300).content)) \
.extractall("mbta_gtfs")
for name, url in (("mbta_vp.pb", "https://cdn.mbta.com/realtime/VehiclePositions.pb"),
("mbta_tu.pb", "https://cdn.mbta.com/realtime/TripUpdates.pb")):
if not os.path.exists(name):
with open(name, "wb") as out:
out.write(requests.get(url, timeout=60).content)
header = gtfs_realtime_pb2.FeedMessage.FromString(open(name, "rb").read()).header
print(name, "snapshot", datetime.fromtimestamp(header.timestamp, timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
mbta_vp.pb snapshot 2026-09-28 21:16 UTC
mbta_tu.pb snapshot 2026-09-28 21:16 UTC
Example 1 β freshness and missing fields
vp["age_s"] = vp.feed_time - vp.timestamp
print("median age", vp.age_s.median(), "s | older than 5 min:", int((vp.age_s > 300).sum()))
print("speed present:", int(vp.speed.notna().sum()), "of", len(vp))
print(vp.status.value_counts().to_dict())
median age 9.0 s | older than 5 min: 5
speed present: 59 of 791
{'IN_TRANSIT_TO': 396, 'STOPPED_AT': 370, 'INCOMING_AT': 25}
Example 2 β delays from predicted times
from zoneinfo import ZoneInfo
tz = ZoneInfo(pd.read_csv("mbta_gtfs/agency.txt", dtype=str).agency_timezone.iloc[0])
tu = gtfs_realtime_pb2.FeedMessage()
tu.ParseFromString(open("mbta_tu.pb", "rb").read())
rows = []
for e in tu.entity:
t = e.trip_update
if t.trip.schedule_relationship != 0: # scheduled trips only
continue
for s in t.stop_time_update:
if s.HasField("departure") and s.departure.HasField("time"):
rows.append((t.trip.trip_id, t.trip.start_date, s.stop_sequence, s.departure.time))
break # the next stop only
pred = pd.DataFrame(rows, columns=["trip_id", "start_date", "stop_sequence", "predicted"])
st = pd.read_csv("mbta_gtfs/stop_times.txt", usecols=["trip_id", "stop_sequence", "departure_time"],
dtype={"trip_id": str, "stop_sequence": int, "departure_time": str})
m = pred.merge(st, on=["trip_id", "stop_sequence"], how="inner")
h, mi, se = m.departure_time.str.split(":", expand=True).astype(int).T.to_numpy()
noon = pd.to_datetime(m.start_date, format="%Y%m%d") + pd.Timedelta(hours=12)
anchor = noon.dt.tz_localize(tz) - pd.Timedelta(hours=12)
scheduled = anchor + pd.to_timedelta(h * 3600 + mi * 60 + se, unit="s")
predicted = pd.to_datetime(m.predicted, unit="s", utc=True)
m["delay_min"] = (predicted - scheduled).dt.total_seconds() / 60
added = sum(e.trip_update.trip.schedule_relationship == 1 for e in tu.entity if e.HasField("trip_update"))
print(len(m), "trips matched to the timetable;", added, "ADDED trips with no timetable entry")
print("delay quantiles (min):", m.delay_min.quantile([0.1, 0.25, 0.5, 0.75, 0.9]).round(1).to_dict())
print(f"more than 5 min late: {(m.delay_min > 5).mean():.0%}")
1477 trips matched to the timetable; 37 ADDED trips with no timetable entry
delay quantiles (min): {0.1: 0.0, 0.25: 0.0, 0.5: 0.0, 0.75: 4.8, 0.9: 19.7}
more than 5 min late: 24%
Example 3 β the time-zone mistake, measured
wrong = (pd.to_datetime(m.start_date, format="%Y%m%d").dt.tz_localize("UTC")
+ pd.to_timedelta(h * 3600 + mi * 60 + se, unit="s"))
print("median 'delay' with the schedule read as UTC:",
round(((predicted - wrong).dt.total_seconds() / 60).median(), 1), "min")
median 'delay' with the schedule read as UTC: 240.0 min
Four hours is Boston's offset from UTC in September. Every delay is wrong by exactly that much, so the distribution looks tidy and completely implausible β which is the only reason it gets caught.
Example 4 β a polling loop to build history
import time, pathlib, requests
def poll(url, out_dir, every_s=30, n=120):
out = pathlib.Path(out_dir); out.mkdir(exist_ok=True)
for _ in range(n):
body = requests.get(url, timeout=30).content
feed = gtfs_realtime_pb2.FeedMessage(); feed.ParseFromString(body)
(out / f"{feed.header.timestamp}.pb").write_bytes(body)
time.sleep(every_s)
Naming snapshots by the feed's own timestamp, not the local clock, keeps them in the producer's order and makes duplicates obvious: two identical names mean the feed did not change between polls.
HasField, noon anchors and snapshots
Why HasField matters
Protocol Buffers in proto2 returns the default value β zero β for a field that was not set. A position without a speed therefore reads as speed 0.0 unless you ask whether the field is present. In this snapshot that would have reported 732 of 791 MBTA vehicles as stationary in the evening rush.
Why the anchor is noon minus twelve hours
GTFS defines service-day times relative to noon minus twelve hours in the agency's time zone. On most days that is midnight; on the two daylight-saving change days it is not, and midnight plus the seconds is an hour out for the whole day. Localising noon rather than midnight gives the right answer on every date.
Why one snapshot is not a reliability measure
A snapshot shows the delays at one moment on one afternoon. Reliability is a distribution over days and times of day, and it needs thousands of snapshots. The polling loop is short; the storage and the patience are the real cost.
Sequences versus ids, start dates and cancellations
- Stop sequence vs stop ID. Join predictions on
stop_sequencewhere present; a stop can appear twice in a loop trip. start_dateis the service date, not the clock date, for trips past midnight.- Canceled trips carry no stop times; count them separately.
- Rate limits vary; respect the agency's terms.
- Feed versions. Refresh the static feed when the agency publishes a new one, or trip IDs stop matching.
- Speed from positions needs two snapshots and a projected CRS.
Internal links
- GTFS Realtime explained β what the messages contain
- How to find the trips running on a given date in GTFS β service dates and time zones
- GTFS times after 24:00 break time parsing β the service-day clock
- Transit frequency and headways explained β scheduled against delivered headways
- How to calculate speed, distance and direction along a track β speed from successive positions
- My time column sorts or joins wrongly β time zones in pandas
FAQ
How do I read GTFS Realtime in Python?
Install gtfs-realtime-bindings, parse the bytes into a FeedMessage, and flatten each entity into a row.
How do I get the route for a vehicle?
Join its trip_id to the static trips.txt, or use the route_id in the message where the producer supplies it.
Why are my delays hours out?
The scheduled time was not anchored in the agency's time zone. Localise noon on the service date, subtract twelve hours, then add the GTFS time.
Why is every speed zero?
Missing fields read as zero in Protocol Buffers. Use HasField("speed").
How often should I poll?
About every 30 seconds, within the agency's rate limits.
What about trips not in the static feed?
They are usually ADDED trips. Keep them, but they cannot have a scheduled delay.