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, and requests installed:
text
pip install gtfs-realtime-bindings pandas requests
  • A GTFS-Realtime TripUpdates endpoint (a URL that returns a protobuf FeedMessage). 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 the gtfs_realtime_pb2 bindings this guide builds on.
  • The matching static GTFS feed for the same agency and service period, unpacked so that trips.txt and stop_times.txt are 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 time values in a StopTimeEvent are POSIX seconds (UTC); scheduled arrival_time strings 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 trip field — a TripDescriptor identifying which scheduled trip this update refers to, via trip_id, and optionally route_id, start_time, start_date, and a schedule_relationship;
  • an optional vehicle field describing the vehicle serving the trip;
  • a repeated stop_time_update list — the per-stop deviations;
  • an optional trip-level delay and timestamp.

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 delay is present, the predicted time is scheduled_time + delay.
  • If only time is present, use it directly; the delay is time − scheduled_time if you need it.
  • If both are present, the absolute time is authoritative and delay is informational — never assume they are consistent, because some producers compute them against different schedule versions.
  • If neither arrival nor departure is present on a SCHEDULED update, 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).

TripUpdate structure and delay propagation A TripUpdate contains a TripDescriptor and a sparse stop_time_update list. Stop 3 announces a delay of plus 360 seconds; stops 4 through 6 receive no explicit update, so the plus 360 delay is carried forward to each of them, while a NO_DATA stop resets to the schedule. TripUpdate → stop_time_update[] → propagated delay TripUpdate trip: TripDescriptor trip_id = "T_4821" schedule_relationship = SCHEDULED stop_time_update[] (sparse) StopTimeUpdate (announced) stop_sequence = 3 stop_id = "S_09" arrival.delay = +360 s schedule_relationship = SCHEDULED Applied to scheduled stops (stop_times.txt) seq 3 sched 08:10 delay +360 pred 08:16 announced seq 4 sched 08:17 delay +360 pred 08:23 propagated seq 5 sched 08:24 delay +360 pred 08:30 propagated seq 6 sched 08:31 NO_DATA pred 08:31 schedule only seq 7 sched 08:38 delay +360 pred 08:44 propagated The +360 s delay carries forward until NO_DATA resets to schedule; propagation then resumes.

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.

python
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.

python
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.

python
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.

python
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:

python
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 StopTimeUpdate carrying only a trip-level delay on the TripUpdate and empty stop events. Read TripUpdate.delay as the fallback and apply it to the whole trip when no per-stop delay exists.

  • stop_sequence absent, only stop_id given. Loop and out-and-back routes visit the same stop_id twice, so a stop_id-only update is ambiguous. When stop_sequence is missing, match on stop_id to 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_time values above 24:00:00. A trip that departs at 25: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:00 arithmetic against the correct start_date. This is where the timezone normalization rules become load-bearing.

  • Duplicate trips in the same feed. A producer occasionally emits two TripUpdate entities for one trip_id — typically an added trip plus its scheduled twin. Deduplicate on trip_id, preferring the entity with the newer timestamp, before flattening.

  • Cancelled trips. When TripDescriptor.schedule_relationship is CANCELED, 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.

python
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.

Up: GTFS-Realtime Integration | Home