GTFS Realtime explained: trip updates, vehicle positions and alerts

On this page

Problem statement

A static GTFS feed says what should happen. GTFS Realtime says what is happening, as a stream of small binary messages that refer back to the static feed by trip, route and stop identifiers. That dependency is the whole difficulty: a realtime message is not self-describing, it is a set of corrections to a timetable you are expected to already hold, and it only makes sense joined to the right version of that timetable.

The messages are also less complete than the specification allows. In a snapshot of the MBTA's Boston feed saved at 21:16 UTC on 28 September 2026, 732 of 791 vehicle positions had no speed, 81 vehicles were on trips that do not exist in the static feed, and none of 32,481 predicted departure times carried a delay value — only absolute times, which have to be compared with the schedule by hand.

Quick answer

A realtime feed is a Protocol Buffers FeedMessage with a header and a list of entities; each entity is a trip update, a vehicle position or an alert:

from google.transit import gtfs_realtime_pb2
import datetime as dt

feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(open("mbta_vp.pb", "rb").read())

print("spec version", feed.header.gtfs_realtime_version,
      "| snapshot", dt.datetime.fromtimestamp(feed.header.timestamp, dt.timezone.utc))
kinds = {"trip_update": 0, "vehicle": 0, "alert": 0}
for e in feed.entity:
    for k in kinds:
        kinds[k] += e.HasField(k)
print(len(feed.entity), "entities", kinds)
spec version 2.0 | snapshot 2026-09-28 21:16:49+00:00
791 entities {'trip_update': 0, 'vehicle': 791, 'alert': 0}

The parser is gtfs-realtime-bindings (pip install gtfs-realtime-bindings). Agencies usually publish the three entity types as separate URLs, as the MBTA does.

The three GTFS Realtime entity types, what each carries, and the static keys each depends on. three message types, one timetable underneathentitycarrieskeyed onTripUpdatepredicted times per stoptrip_id, stop_idVehiclePositionlat/lon, status, timetrip_id, route_idAlerttext, active periodroute, stop or tripMBTA publishes each type at its own URL; some agencies combine themNone of the three is readable without the static feed it refers to spatialworkflow.io
Each message type answers a different question, and all three are keyed on the static feed.

Hold the static feed, read every timestamp, expect gaps

1. Hold the matching static feed

Realtime trip IDs are the static feed's trip IDs. A static feed from last month will fail to match trips that were renumbered since, and the failure is silent — the rows simply do not join.

2. Read the header timestamp, and each entity's own

The header timestamp is when the feed was assembled. Each vehicle position carries its own timestamp, which can be much older: in the MBTA snapshot the median position was 9 seconds old, the 99th percentile 200 seconds, and the oldest 1,459 seconds — about 24 minutes.

3. Handle trips that are not in the timetable

schedule_relationship says how a trip relates to the static feed. SCHEDULED (0) matches a static trip; ADDED (1) is extra service with no static counterpart — replacement shuttles, extra trains. The 81 unmatched MBTA vehicles were all ADDED, among them 43 on the route Shuttle-Generic, which is how that feed labels replacement buses, and 18 extra Green Line trips on the D and C branches.

4. Expect absolute times rather than delays

A StopTimeUpdate can carry a delay in seconds, an absolute time, or both. Many producers send only time. Computing a delay then means reconstructing the scheduled time from the static feed, the service date and the agency time zone.

5. Treat optional fields as optional

Speed, bearing, occupancy and congestion are all optional. A pipeline that assumes speed is present will either fail or silently average over the 59 of 791 MBTA vehicles that happened to send one.

6. Store snapshots if you want history

A realtime feed has no memory. Delay statistics, bunching analysis and reliability measures all require polling on a schedule and keeping what you receive.

From a realtime prediction to a delay: the static joins and the context a delay needs. what a delay needs1predicted departure (POSIX time)realtime2trip_id + stop_sequencejoin to stop_times3scheduled HH:MM:SScan exceed 24:004service date + agency time zonestart_date, agency.txt5delay = predicted − scheduledin secondsRead the schedule as UTC and every Boston delay comes out four hours wrong spatialworkflow.io
A delay is a realtime time minus a scheduled time, and the scheduled time needs three things the realtime message does not carry.

What MBTA vehicle positions contain

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 — what the vehicle positions actually contain

import pandas as pd

rows = []
for e in feed.entity:
    v = e.vehicle
    rows.append({"trip_id": v.trip.trip_id, "route_id": v.trip.route_id,
                 "relationship": v.trip.schedule_relationship,
                 "has_speed": v.position.HasField("speed"),
                 "age_s": feed.header.timestamp - v.timestamp})
vp = pd.DataFrame(rows)
print("with speed:", int(vp.has_speed.sum()), "of", len(vp))
print("position age (s):", vp.age_s.quantile([0.5, 0.9, 0.99]).round(0).to_dict(),
      "max", int(vp.age_s.max()))
print("schedule_relationship:", vp.relationship.value_counts().to_dict())
print("positions under 60 s old:", int((vp.age_s < 60).sum()), "of", len(vp))
with speed: 59 of 791
position age (s): {0.5: 9.0, 0.9: 31.0, 0.99: 200.0} max 1459
schedule_relationship: {0: 710, 1: 81}
positions under 60 s old: 753 of 791

Example 2 — trips the static feed does not know about

trips = pd.read_csv("mbta_gtfs/trips.txt", dtype=str, usecols=["trip_id"])
unknown = vp[~vp.trip_id.isin(trips.trip_id)]
print(len(unknown), "unknown trips; relationships:",
      unknown.relationship.value_counts().to_dict())
print(unknown.route_id.value_counts().head(3).to_dict())
81 unknown trips; relationships: {1: 81}
{'Shuttle-Generic': 43, 'Green-D': 11, 'Green-C': 7}

Example 3 — predictions without delays

tu = gtfs_realtime_pb2.FeedMessage()
tu.ParseFromString(open("mbta_tu.pb", "rb").read())
with_delay = with_time = 0
for e in tu.entity:
    for s in e.trip_update.stop_time_update:
        if s.HasField("departure"):
            with_delay += s.departure.HasField("delay")
            with_time += s.departure.HasField("time")
print(len(tu.entity), "trip updates;", with_time, "departures with a time,",
      with_delay, "with a delay")
2312 trip updates; 32481 departures with a time, 0 with a delay

Every prediction is an absolute time. The realtime how-to turns them into delays.

Realtime depends on static

Why realtime depends on static

Sending the whole timetable every thirty seconds would be wasteful. GTFS Realtime sends only what changed and what is moving, and names everything by the static feed's keys. The receiver holds the timetable and applies the corrections. That is efficient on the wire and puts the burden of version matching on whoever consumes the feed.

Why positions and predictions disagree

Vehicle positions come from on-board GPS at whatever rate the vehicle reports. Trip updates come from a prediction model that uses those positions plus history. A vehicle can be reported at a stop while its trip update still predicts arrival a minute later; they are different products with different latencies.

Why a missing field is not a zero

A position without a speed does not mean the vehicle is stationary; it means the producer did not send one. Treating missing speed as zero would suggest most of the MBTA's fleet was parked at 16:09 on a Tuesday afternoon. Keep missing values missing, and compute speed from successive positions if you need it.

Completeness of the MBTA realtime snapshot of 28 September 2026. what the snapshot actually containedvehicles with speed59 of 791 (7%)trips in static feed710 of 791 (90%)predictions with delay0 of 32,481positions < 60 s old753 of 791 (95%)050%100%Every field the specification makes optional, this producer mostly left out spatialworkflow.io
The specification allows much more than a real feed sends; measure what yours actually contains.

Keys, rate limits, cancellations and alerts

  • Authentication. Many agencies require an API key; the MBTA's feeds are open.
  • Rate limits apply to polling; thirty seconds is a common interval.
  • CANCELED trips appear as trip updates with that relationship and no stop times.
  • Alerts reference routes, stops or trips through informed_entity, with active periods.
  • JSON variants exist for some agencies, but the protobuf is the specification.
  • Service date is trip.start_date, which matters for trips that run past midnight.

FAQ

What is GTFS Realtime?

A Protocol Buffers format for live transit data — trip updates, vehicle positions and service alerts — that references a static GTFS feed by its identifiers.

Do I need the static feed?

Yes. Realtime messages name trips, routes and stops by the static feed's IDs and cannot be interpreted without it.

How do I get delays if the feed has no delay field?

Subtract the scheduled time, reconstructed from the static feed, the service date and the agency time zone, from the predicted time.

Why do some trips not match the static feed?

They are usually ADDED trips — extra service or replacement shuttles — which have no static counterpart by design.

Is speed always present?

No. It is optional and often missing; compute it from successive positions if you need it.

How do I build a history of delays?

Poll the feed on a schedule and store each snapshot. The feed itself only describes the present.