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.

From a realtime feed URL to a table of positions and delays. feed to delaysfetch bytesAPI key if neededdecode +flattenHasField foroptionalsjoin staticfeedtrip, route, stopanchorschedulenoon βˆ’ 12 h, localdelay = pred βˆ’schedper stopThe two amber steps are silent: skip either and the table still fills spatialworkflow.io
Decoding is one line; the joins and the time zone are where the work is.

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.

Next-stop delay quantiles in the MBTA snapshot, and the median if the schedule is read as UTC. next-stop delay, 1,477 MBTA trips, 21:16 UTC10th percentile0.0 minmedian0.0 min75th percentile4.8 min90th percentile19.7 minread as UTC240 min (off scale)010 min20 minThe time-zone error is twelve times the 90th-percentile delay spatialworkflow.io
Most trips are close to schedule; the time-zone error is larger than any real delay.

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.

Realtime fields that mislead when read naively, and what to do instead. wrong without an errorread naivelyread insteadspeed without HasField β†’ 0.0None when absent (732 of 791)every position treated as currentdrop if > 300 s old (5 here)unmatched trips droppedkeep ADDED trips (37 here)schedule read as UTCnoon βˆ’ 12 h in agency time zoneEach row is a real count from the 28 September 2026 MBTA snapshot spatialworkflow.io
Four ways a realtime table is wrong without raising an error.

Sequences versus ids, start dates and cancellations

  • Stop sequence vs stop ID. Join predictions on stop_sequence where present; a stop can appear twice in a loop trip.
  • start_date is 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.

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.