Processing TripUpdates and Delays
A GTFS-Realtime TripUpdate is the message type that tells you when a vehicle will actually reach each of its remaining stops, expressed as a deviation from the published timetable. The GTFS-Realtime Integration overview treats the transport and encoding layer common to every realtime feed; this guide narrows to the TripUpdate entity itself — how the stop_time_update[] list is structured, why producers send it sparsely, how the delay and time fields differ, and how to carry the last announced delay forward across the trip’s remaining scheduled stop_times.txt rows so that every stop has a usable prediction. The workflow here is production-grade for transit platform teams building arrival boards, delay analytics, or feed-quality monitors.
The Prediction Problem in Realtime Feeds
A static timetable states when a trip is scheduled to serve each stop. A TripUpdate states how far the vehicle currently deviates from that timetable. Neither is complete on its own: the timetable has no idea the vehicle is running six minutes late, and the TripUpdate frequently omits the scheduled clock time entirely, sending only a signed delay in seconds. Producing a live arrival prediction therefore means fusing two data sources — the realtime deviation and the static schedule — and doing so correctly for stops the producer never explicitly mentioned.
That last point is the crux. Real-agency feeds almost never enumerate every downstream stop in every message. A producer typically sends one StopTimeUpdate for the next stop the vehicle will reach and expects consumers to apply that same delay to the rest of the trip. Treating the absent stops as “no data” instead of “same delay as the last announced stop” leaves most of the itinerary blank on a passenger’s screen. The specification is explicit that the most recent delay propagates forward, and getting that propagation right — while respecting stops flagged as skipped or as carrying no data — is what separates a correct integration from a plausible-looking but wrong one.
Prerequisites
Before running the code in this guide, ensure the following are in place:
- Python 3.9+ with
gtfs-realtime-bindings,pandas, andrequestsinstalled:
pip install gtfs-realtime-bindings pandas requests
- A GTFS-Realtime
TripUpdatesendpoint (a URL that returns a protobufFeedMessage). If you have not yet decoded a realtime feed into Python objects, start with parsing GTFS-RT protobuf with Python, which covers the wire format and thegtfs_realtime_pb2bindings this guide builds on. - The matching static GTFS feed for the same agency and service period, unpacked so that
trips.txtandstop_times.txtare readable. Delay-based updates are uninterpretable without the scheduled times they reference; review the stops.txt and stop_times.txt relationship model if the composite(trip_id, stop_sequence)key is unfamiliar. - A convention for time zones. Absolute
timevalues in aStopTimeEventare POSIX seconds (UTC); scheduledarrival_timestrings are local wall-clock offsets from noon. See converting local transit times to UTC in Python before comparing the two.
Concept and Spec Background
The TripUpdate message
A realtime feed is a single FeedMessage containing a header and a repeated list of FeedEntity. Any entity whose trip_update field is set carries a TripUpdate. The TripUpdate has four parts that matter here:
- a
tripfield — aTripDescriptoridentifying which scheduled trip this update refers to, viatrip_id, and optionallyroute_id,start_time,start_date, and aschedule_relationship; - an optional
vehiclefield describing the vehicle serving the trip; - a repeated
stop_time_updatelist — the per-stop deviations; - an optional trip-level
delayandtimestamp.
The TripDescriptor.trip_id is the join key back to the static feed. When it is present and the trip’s schedule_relationship is SCHEDULED, the update amends a trip that exists in trips.txt. Matching that descriptor to the correct static trip — including the awkward cases where trip_id is absent and you must fall back to route_id plus start_time — is covered in depth in joining TripUpdates to stop_times.
StopTimeUpdate and StopTimeEvent
Each StopTimeUpdate locates a stop and describes the deviation there:
| Field | Type | Meaning |
|---|---|---|
stop_sequence |
uint32 | Position of the stop within the trip; matches stop_times.txt.stop_sequence |
stop_id |
string | Identifier of the stop; matches stops.txt.stop_id |
arrival |
StopTimeEvent | Predicted arrival deviation (see below) |
departure |
StopTimeEvent | Predicted departure deviation |
schedule_relationship |
enum | SCHEDULED, SKIPPED, NO_DATA, or UNSCHEDULED |
A StopTimeEvent (the arrival or departure payload) has three fields:
| Field | Type | Meaning |
|---|---|---|
delay |
int32 | Seconds relative to the scheduled time; positive is late, negative is early |
time |
int64 | Absolute arrival/departure time as a POSIX timestamp (UTC seconds) |
uncertainty |
int32 | Confidence in seconds; 0 means fully certain, omitted means unknown |
You should reference a StopTimeUpdate by stop_sequence when it is present because stop_id can repeat within a loop route, making it ambiguous on its own.
delay versus time semantics
The single most important rule when reading a StopTimeEvent: delay and time express the same prediction two different ways. delay is relative to the static schedule and requires the schedule to interpret. time is absolute and stands alone. A producer may send either, both, or neither for a given event.
- If only
delayis present, the predicted time isscheduled_time + delay. - If only
timeis present, use it directly; the delay istime − scheduled_timeif you need it. - If both are present, the absolute
timeis authoritative anddelayis informational — never assume they are consistent, because some producers compute them against different schedule versions. - If neither
arrivalnordepartureis present on aSCHEDULEDupdate, the stop inherits the propagated delay (below), not a null.
Forward propagation of the last known delay
Producers send stop_time_update sparsely. The specification states that a StopTimeUpdate applies to its own stop and to all subsequent stops on the trip until a later StopTimeUpdate supersedes it. In practice a feed may contain one update for the next stop and nothing else; the consumer is responsible for applying that delay to every remaining scheduled stop. This “carry the last delay forward” rule is what fills an arrival board. Two schedule relationships break the carry: SKIPPED (the stop is not served, so it produces no prediction) and NO_DATA (the producer explicitly disclaims a prediction, so you fall back to the bare schedule and do not let a stale delay leak past it).
Step-by-Step Implementation
Step 1: Decode the FeedMessage and iterate TripUpdate entities
Fetch the protobuf payload and parse it with the official bindings. Only entities that actually set trip_update are relevant here; vehicle-position and alert entities share the same feed and must be skipped.
import logging
import requests
from google.transit import gtfs_realtime_pb2
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
TRIPUPDATES_URL = "https://api.example-agency.gov/gtfs-rt/trip-updates.pb"
def fetch_trip_updates(url: str) -> gtfs_realtime_pb2.FeedMessage:
"""Fetch and parse a GTFS-Realtime TripUpdates FeedMessage."""
response = requests.get(url, timeout=30)
response.raise_for_status()
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(response.content)
logging.info(
"Feed version %s, %d entities, header timestamp %s",
feed.header.gtfs_realtime_version,
len(feed.entity),
feed.header.timestamp,
)
return feed
feed = fetch_trip_updates(TRIPUPDATES_URL)
trip_updates = [entity.trip_update for entity in feed.entity if entity.HasField("trip_update")]
logging.info("Extracted %d TripUpdate entities", len(trip_updates))
Always guard with HasField("trip_update"). Protobuf returns a default-constructed empty message for unset fields rather than raising, so an unguarded loop would silently process blank updates.
Step 2: Flatten StopTimeUpdates into rows
Convert the nested protobuf into a flat table keyed by (trip_id, stop_sequence). Read schedule_relationship as an integer enum and record which event fields were actually present, because an absent arrival is semantically different from an arrival with delay = 0.
import pandas as pd
# StopTimeUpdate.ScheduleRelationship enum values
SR_SCHEDULED = gtfs_realtime_pb2.TripUpdate.StopTimeUpdate.SCHEDULED
SR_SKIPPED = gtfs_realtime_pb2.TripUpdate.StopTimeUpdate.SKIPPED
SR_NO_DATA = gtfs_realtime_pb2.TripUpdate.StopTimeUpdate.NO_DATA
def flatten_trip_updates(trip_updates) -> pd.DataFrame:
"""Flatten nested TripUpdate/StopTimeUpdate messages into one row per stop update."""
rows: list[dict] = []
for trip_update in trip_updates:
trip_id = trip_update.trip.trip_id
route_id = trip_update.trip.route_id
for stu in trip_update.stop_time_update:
rows.append(
{
"trip_id": trip_id,
"route_id": route_id,
"stop_sequence": stu.stop_sequence if stu.HasField("stop_sequence") else pd.NA,
"stop_id": stu.stop_id or pd.NA,
"schedule_relationship": stu.schedule_relationship,
"arrival_delay": stu.arrival.delay if stu.arrival.HasField("delay") else pd.NA,
"arrival_time": stu.arrival.time if stu.arrival.HasField("time") else pd.NA,
"departure_delay": stu.departure.delay if stu.departure.HasField("delay") else pd.NA,
"departure_time": stu.departure.time if stu.departure.HasField("time") else pd.NA,
}
)
updates = pd.DataFrame(rows).astype(
{
"trip_id": "string",
"route_id": "string",
"stop_sequence": "Int64",
"stop_id": "string",
"schedule_relationship": "Int64",
"arrival_delay": "Int64",
"arrival_time": "Int64",
"departure_delay": "Int64",
"departure_time": "Int64",
}
)
logging.info("Flattened to %d StopTimeUpdate rows", len(updates))
return updates
updates = flatten_trip_updates(trip_updates)
The nullable Int64 dtype is essential: a delay of 0 and an absent delay must remain distinguishable, and int64 cannot represent the missing case.
Step 3: Resolve delay versus time for each event
Reduce each announced StopTimeUpdate to a single effective delay. Where an absolute time is present it wins over delay; where only delay is present it passes through; where neither is present the row is left null so that propagation, not this step, supplies the value.
def effective_delay(row: pd.Series, kind: str, scheduled_epoch: pd.Series) -> "pd.Int64Dtype":
"""
Resolve the effective delay in seconds for 'arrival' or 'departure'.
Absolute time overrides delay; scheduled_epoch is the POSIX time of the
scheduled event for this stop, used to convert an absolute time to a delay.
"""
time_val = row[f"{kind}_time"]
delay_val = row[f"{kind}_delay"]
if pd.notna(time_val) and pd.notna(scheduled_epoch):
return pd.Int64Dtype().type(int(time_val) - int(scheduled_epoch))
if pd.notna(delay_val):
return delay_val
return pd.NA
This helper is applied after the schedule join in the next step, because converting an absolute time into a delay requires the scheduled epoch of that specific stop.
Step 4: Join to scheduled stop_times and propagate the last delay
Load the static stop_times.txt, keep it as the authoritative row set for each trip, left-join the announced updates onto it, then forward-fill the effective delay along stop_sequence. The forward fill is the propagation rule made concrete: each unannounced stop inherits the delay of the most recent announced stop before it.
def build_predictions(updates: pd.DataFrame, stop_times_path: str) -> pd.DataFrame:
"""Join announced updates to scheduled stop_times and propagate delays forward."""
stop_times = pd.read_csv(
stop_times_path,
dtype={
"trip_id": "string",
"stop_id": "string",
"stop_sequence": "Int64",
"arrival_time": "string", # GTFS times may exceed 24:00:00
"departure_time": "string",
},
usecols=["trip_id", "stop_id", "stop_sequence", "arrival_time", "departure_time"],
)
# Only carry updates for trips we actually have realtime data for.
live_trip_ids = set(updates["trip_id"].dropna())
schedule = stop_times[stop_times["trip_id"].isin(live_trip_ids)].copy()
schedule = schedule.sort_values(["trip_id", "stop_sequence"], ignore_index=True)
announced = updates[updates["schedule_relationship"] == SR_SCHEDULED][
["trip_id", "stop_sequence", "arrival_delay", "departure_delay", "schedule_relationship"]
]
merged = schedule.merge(announced, on=["trip_id", "stop_sequence"], how="left")
# Forward-propagate the last announced delay within each trip.
merged["propagated_arrival_delay"] = (
merged.groupby("trip_id", sort=False)["arrival_delay"].ffill().fillna(0).astype("Int64")
)
merged["propagated_departure_delay"] = (
merged.groupby("trip_id", sort=False)["departure_delay"]
.ffill()
.fillna(merged["propagated_arrival_delay"])
.astype("Int64")
)
logging.info(
"Built predictions for %d stops across %d trips",
len(merged),
merged["trip_id"].nunique(),
)
return merged
predictions = build_predictions(updates, "gtfs/stop_times.txt")
Departure delay falls back to the propagated arrival delay when a producer omits it, which is the common case for feeds that report a single deviation per stop. The fillna(0) at the head of the trip means stops before the first announced update are assumed on time — a defensible default, though some teams prefer to leave them null and render them as “scheduled”.
Validation and Verification
Run these checks after every propagation pass, ideally as a gate in the polling loop:
def validate_predictions(predictions: pd.DataFrame, feed_timestamp: int) -> dict:
"""Sanity-check propagated delays before publishing them."""
# No trip should have a stop with a null propagated delay after ffill+fillna.
assert predictions["propagated_arrival_delay"].notna().all(), \
"Null propagated delay survived — check groupby key alignment"
# stop_sequence must be strictly increasing within each trip.
seq_ok = (
predictions.groupby("trip_id", sort=False)["stop_sequence"]
.apply(lambda s: s.is_monotonic_increasing)
.all()
)
assert seq_ok, "stop_sequence not monotonic within a trip — bad join or unsorted input"
# Flag implausible delays for review rather than trusting them blindly.
extreme = predictions[predictions["propagated_arrival_delay"].abs() > 7200]
results = {
"stops": len(predictions),
"trips": int(predictions["trip_id"].nunique()),
"median_delay_s": int(predictions["propagated_arrival_delay"].median()),
"extreme_delay_rows": len(extreme),
"feed_age_s": None,
}
if feed_timestamp:
import time
results["feed_age_s"] = int(time.time()) - feed_timestamp
logging.info("Validation summary: %s", results)
return results
Delays beyond two hours are almost always a producer computing against the wrong service day rather than a genuinely late vehicle; surface them, do not silently publish them.
Failure Modes and Edge Cases
-
A single StopTimeUpdate with no arrival or departure. Some producers send a
StopTimeUpdatecarrying only a trip-leveldelayon theTripUpdateand empty stop events. ReadTripUpdate.delayas the fallback and apply it to the whole trip when no per-stop delay exists. -
stop_sequenceabsent, onlystop_idgiven. Loop and out-and-back routes visit the samestop_idtwice, so astop_id-only update is ambiguous. Whenstop_sequenceis missing, match onstop_idto the first not-yet-passed occurrence in the schedule, using the feed timestamp to decide which visits are still ahead. -
Delays applied to overnight
arrival_timevalues above 24:00:00. A trip that departs at25:10:00(1:10 AM next service day) still receives delays in plain seconds. Keep the scheduled times as strings until you convert them to absolute epochs, and do the>24:00arithmetic against the correctstart_date. This is where the timezone normalization rules become load-bearing. -
Duplicate trips in the same feed. A producer occasionally emits two
TripUpdateentities for onetrip_id— typically an added trip plus its scheduled twin. Deduplicate ontrip_id, preferring the entity with the newertimestamp, before flattening. -
Cancelled trips. When
TripDescriptor.schedule_relationshipisCANCELED, the trip is not running at all; drop its stops from the prediction set entirely rather than propagating a delay across them.
Performance and Scale Notes
Large multi-line agencies emit TripUpdates feeds with tens of thousands of active trips, refreshed every 10–30 seconds. Two costs dominate: re-reading the static stop_times.txt on every poll, and the per-trip groupby propagation.
import pandas as pd
# Load the static schedule ONCE at startup and index it for fast trip slicing.
STOP_TIMES = (
pd.read_csv(
"gtfs/stop_times.txt",
dtype={
"trip_id": "string",
"stop_id": "string",
"stop_sequence": "Int64",
"arrival_time": "string",
"departure_time": "string",
},
usecols=["trip_id", "stop_id", "stop_sequence", "arrival_time", "departure_time"],
)
.sort_values(["trip_id", "stop_sequence"])
.set_index("trip_id")
)
# On each poll, slice only the trips that appear in the realtime feed.
def schedule_for(live_trip_ids: set) -> pd.DataFrame:
present = [t for t in live_trip_ids if t in STOP_TIMES.index]
return STOP_TIMES.loc[present].reset_index()
For feeds above 500 MB of static stop_times.txt, persist the sorted, indexed schedule as Parquet once and memory-map it, rather than re-parsing CSV on restart. Batching the propagation across all live trips in a single sorted groupby().ffill() — as in Step 4 — is far faster than iterating trip by trip in Python. For the broader memory strategy behind indexed schedule tables and Parquet caching, see memory-efficient processing for large feeds.
Frequently Asked Questions
What is the difference between the delay and time fields in a StopTimeEvent?
delay is a signed integer of seconds relative to the static schedule — positive means late, negative means early. time is an absolute POSIX timestamp in seconds. A StopTimeEvent may carry either or both; when both are present the absolute time is authoritative and delay is treated as informational.
Why do TripUpdate messages only include a few stop_time_update entries?
GTFS-Realtime allows producers to send a sparse list. A single StopTimeUpdate applies to itself and to every later stop until the next StopTimeUpdate overrides it. Consumers are required to propagate the most recent delay forward across the remaining scheduled stops.
Do I need the static GTFS feed to interpret TripUpdates?
Yes for delay-based updates. A delay of 120 seconds is meaningless without the scheduled arrival_time it is measured against, so you must join each TripUpdate’s trip_id to stop_times.txt. Only updates that carry absolute time values for every stop can be interpreted without the schedule.
How should I handle a stop flagged SKIPPED or NO_DATA?
SKIPPED means the vehicle will not serve that stop — drop it from the served itinerary and do not emit a prediction for it. NO_DATA means the producer has no prediction — fall back to the plain schedule for that stop and do not let a previously propagated delay carry across it. See the dedicated guide on handling skipped stops for the exact logic.
Related
- Arrival Predictions from TripUpdates — the complete script that turns propagated delays into wall-clock predictions, including GTFS times past 24:00
- Handling Skipped Stops in TripUpdates — correct
SKIPPEDandNO_DATAhandling so propagation never crosses a dropped or unknown stop - Joining TripUpdates to stop_times — resolving the
TripDescriptorback to the right static trip whentrip_idis missing or ambiguous - Converting Local Transit Times to UTC in Python — turning scheduled
arrival_timestrings into the epochs a delay is measured against - Mastering stops.txt and stop_times.txt Relationships — the composite
(trip_id, stop_sequence)key that the realtime join depends on