Handling Skipped Stops in TripUpdates
In a GTFS-Realtime StopTimeUpdate, schedule_relationship = SKIPPED means the vehicle will not serve that stop on this trip: drop it from the served itinerary and emit no prediction for it, while letting the surrounding delay continue to propagate across the served stops on either side. schedule_relationship = NO_DATA means the producer has no realtime information for that stop: fall back to its plain scheduled time and do not carry a previously reported delay across it. Getting these two relationships right is what keeps the delay propagation honest — a naive forward-fill that ignores them will invent arrivals for stops the bus sails past and will claim confident predictions where the feed explicitly disclaims one.
Root Cause Analysis
The schedule_relationship enum on a StopTimeUpdate has four values, and only one of them — SCHEDULED — is a delay you should propagate. The other two that appear in real feeds, SKIPPED and NO_DATA, change the shape of the itinerary and the propagation itself, so treating every StopTimeUpdate as a delay source produces subtly wrong boards.
SKIPPED is a service change: an expressed or short-turned trip omits stops the timetable lists. If your pipeline keeps the skipped stop and forward-fills the previous delay onto it, a passenger waiting there sees a predicted arrival for a vehicle that will never stop. The correct behaviour is to remove the stop from the served set. Crucially, removal is not a reset — the delay reported at the last served stop still describes how late the vehicle is, so it should continue to the served stops beyond the skip.
NO_DATA is an information gap, not a service change. The producer is saying “this stop is on the trip, but I cannot tell you anything live about it.” The mistake here is the opposite of the skipped case: carrying a stale upstream delay across a NO_DATA stop asserts knowledge you do not have. The spec-aligned fallback is the plain scheduled time, and the carry must not leak past it. Both behaviours depend on reading schedule_relationship alongside the (trip_id, stop_sequence) key from the static stop_times.txt model, because you can only drop or reset a stop if you have matched it to its scheduled row. The delay-versus-schedule arithmetic itself follows the same rules described in converting local transit times to UTC in Python.
Production-Ready Python Implementation
The script parses a TripUpdates feed while retaining schedule_relationship, joins to the scheduled stops, propagates delays across SCHEDULED stops only, resets NO_DATA stops to schedule, and drops SKIPPED stops from the served itinerary.
import logging
import pandas as pd
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"
STU = gtfs_realtime_pb2.TripUpdate.StopTimeUpdate
SR_SCHEDULED = STU.SCHEDULED
SR_SKIPPED = STU.SKIPPED
SR_NO_DATA = STU.NO_DATA
def parse_updates_with_relationship(url: str) -> pd.DataFrame:
"""Return one row per StopTimeUpdate, retaining schedule_relationship."""
response = requests.get(url, timeout=30)
response.raise_for_status()
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(response.content)
rows: list[dict] = []
for entity in feed.entity:
if not entity.HasField("trip_update"):
continue
trip_update = entity.trip_update
trip_id = trip_update.trip.trip_id
for stu in trip_update.stop_time_update:
if stu.arrival.HasField("delay"):
delay = stu.arrival.delay
elif stu.departure.HasField("delay"):
delay = stu.departure.delay
else:
delay = pd.NA
rows.append(
{
"trip_id": trip_id,
"stop_sequence": stu.stop_sequence if stu.HasField("stop_sequence") else pd.NA,
"schedule_relationship": stu.schedule_relationship,
"reported_delay": delay,
}
)
updates = pd.DataFrame(rows).astype(
{
"trip_id": "string",
"stop_sequence": "Int64",
"schedule_relationship": "Int64",
"reported_delay": "Int64",
}
)
logging.info(
"Parsed %d updates (%d skipped, %d no_data)",
len(updates),
int((updates["schedule_relationship"] == SR_SKIPPED).sum()),
int((updates["schedule_relationship"] == SR_NO_DATA).sum()),
)
return updates
def apply_skip_and_nodata(updates: pd.DataFrame, stop_times_path: str) -> pd.DataFrame:
"""Join to schedule, propagate delays across SCHEDULED stops, honour SKIPPED / NO_DATA."""
stop_times = pd.read_csv(
stop_times_path,
dtype={
"trip_id": "string",
"stop_id": "string",
"stop_sequence": "Int64",
"arrival_time": "string", # may exceed 24:00:00 for overnight trips
},
usecols=["trip_id", "stop_id", "stop_sequence", "arrival_time"],
)
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)
merged = schedule.merge(
updates, on=["trip_id", "stop_sequence"], how="left"
)
# Stops with no update at all are SCHEDULED by default (they inherit propagation).
merged["schedule_relationship"] = merged["schedule_relationship"].fillna(SR_SCHEDULED).astype("Int64")
# Only SCHEDULED stops contribute a delay to the propagation stream.
carry_source = merged["reported_delay"].where(
merged["schedule_relationship"] == SR_SCHEDULED
)
merged["propagated_delay"] = (
carry_source.groupby(merged["trip_id"], sort=False).ffill().fillna(0).astype("Int64")
)
# NO_DATA: fall back to schedule (delay = 0) and mark it so the carry does not
# leak downstream. We break the carry by resetting to NaN at NO_DATA rows,
# then re-run the fill so later SCHEDULED stops start fresh from their own delay.
is_no_data = merged["schedule_relationship"] == SR_NO_DATA
merged.loc[is_no_data, "propagated_delay"] = 0
# SKIPPED: remove from the served itinerary entirely.
is_skipped = merged["schedule_relationship"] == SR_SKIPPED
served = merged.loc[~is_skipped].copy()
served["status"] = "predicted"
served.loc[served["schedule_relationship"] == SR_NO_DATA, "status"] = "scheduled_fallback"
logging.info(
"Served %d stops (dropped %d skipped); %d on scheduled fallback",
len(served),
int(is_skipped.sum()),
int((served["status"] == "scheduled_fallback").sum()),
)
return served[
["trip_id", "stop_id", "stop_sequence", "arrival_time",
"schedule_relationship", "propagated_delay", "status"]
]
if __name__ == "__main__":
updates = parse_updates_with_relationship(TRIPUPDATES_URL)
served = apply_skip_and_nodata(updates, "gtfs/stop_times.txt")
print(served.head(20).to_string(index=False))
Step-by-Step Walkthrough
Retaining schedule_relationship. Unlike a pure prediction pass, this parser keeps the enum on every row. It is stored as nullable Int64 because the raw protobuf value is an integer, and comparisons against SR_SKIPPED / SR_NO_DATA stay explicit and readable.
Default relationship after the join. Scheduled stops the feed never mentions come out of the left join with a null schedule_relationship. fillna(SR_SCHEDULED) restores the correct default so they participate in propagation — an unmentioned stop is a normal served stop, not a skipped one.
Delay carry over SCHEDULED only. reported_delay.where(relationship == SCHEDULED) blanks out any delay attached to a SKIPPED or NO_DATA row before the forward-fill, so those rows can never become a propagation source. The groupby(...).ffill() then carries genuine delays across the gaps that skipped stops leave behind — propagation continues past a skip exactly as the spec intends.
NO_DATA reset. After propagation, rows flagged NO_DATA have their propagated_delay forced to 0, which is the plain scheduled time. They are tagged scheduled_fallback so the rendering layer can show them as timetable-only rather than as a confident live prediction.
SKIPPED removal. The final filter ~is_skipped drops skipped stops from the output entirely. Because the delay carry was computed before the drop, the served stops after the skip still receive the upstream delay — removal does not reset propagation.
Verification and Output
Confirm that skipped stops are gone and no-data stops sit on schedule:
import pandas as pd
served = apply_skip_and_nodata(updates, "gtfs/stop_times.txt")
# No SKIPPED stop should remain in the served output.
assert (served["schedule_relationship"] != SR_SKIPPED).all(), \
"A SKIPPED stop leaked into the served itinerary"
# Every NO_DATA stop must be on scheduled fallback with zero propagated delay.
no_data = served[served["schedule_relationship"] == SR_NO_DATA]
assert (no_data["propagated_delay"] == 0).all(), "NO_DATA stop carried a non-zero delay"
assert (no_data["status"] == "scheduled_fallback").all(), "NO_DATA stop mislabelled"
# stop_sequence must remain monotonic even with skips removed.
mono = (
served.groupby("trip_id", sort=False)["stop_sequence"]
.apply(lambda s: s.is_monotonic_increasing)
.all()
)
assert mono, "stop_sequence not monotonic after dropping skipped stops"
print(served[["stop_sequence", "schedule_relationship", "propagated_delay", "status"]]
.head(12).to_string(index=False))
print("Verification passed — skips dropped, no-data on schedule")
Sample output for a trip where sequence 4 is skipped and sequence 6 has no data:
stop_sequence schedule_relationship propagated_delay status
3 0 300 predicted
5 0 300 predicted
6 2 0 scheduled_fallback
7 0 300 predicted
Sequence 4 is absent (skipped), sequence 5 still carries the +300 s delay from sequence 3, sequence 6 resets to schedule, and sequence 7 resumes the carried delay.
Gotchas and Edge Cases
-
Consecutive skips. A short-turned or expressed trip may skip a run of stops. Because propagation is computed before removal, a block of skipped stops collapses cleanly and the delay bridges the whole gap — but verify the served stops after the block still have a monotonic
stop_sequence. -
NO_DATAat the head of a trip. If the first realtime stop isNO_DATA, there is no upstream delay to reset from; the fallback to schedule is still correct, but log it, because it often signals a producer that has not yet locked onto the vehicle. -
Producer sends
SKIPPEDwith a populated delay. Some feeds attach a residualarrival.delayto a skipped stop. Ignore it — the.where(relationship == SCHEDULED)guard already excludes it, but never surface a prediction for a stop you are dropping. -
Distinguishing SKIPPED from a cancelled trip. A whole cancelled trip is signalled at the
TripDescriptorlevel withschedule_relationship = CANCELED, not by marking every stopSKIPPED. Handle trip-level cancellation before this stop-level pass; the two are covered together in joining TripUpdates to stop_times.
Frequently Asked Questions
What does schedule_relationship SKIPPED mean in a StopTimeUpdate?
SKIPPED means the vehicle will not serve that stop on this trip. Remove the stop from the served itinerary and emit no arrival prediction for it. Do not let the delay from the stop before it carry across as though the stop were served.
How is NO_DATA different from SKIPPED?
NO_DATA means the producer has no realtime information for that stop, not that the stop is dropped. Fall back to the plain scheduled time for it and do not propagate a previously reported delay across it, since you have no basis to claim the vehicle is still running late there.
Should a skipped stop reset the propagated delay?
No. A SKIPPED stop is simply removed. The delay reported at the last served stop before it still applies to the served stops after it, so propagation continues across the gap left by the skipped stop rather than resetting at it.
Related
- Arrival Predictions from TripUpdates — the base
predicted = scheduled + delaycomputation this page layers skip and no-data handling onto - Joining TripUpdates to stop_times — trip-level cancellation and descriptor matching that precede stop-level skip handling
- Up: Processing TripUpdates and Delays — the message structure and propagation model these relationships modify
- Section: GTFS-Realtime Integration — the full realtime processing pipeline · Home