Handling GTFS Service Alerts

Service alerts are the human-readable layer of GTFS-Realtime: elevator outages, detours, suspended segments, and reduced-frequency notices that a trip planner must show riders alongside live predictions. Unlike vehicle positions or TripUpdates, an Alert does not carry a schedule delta — it carries free text plus a set of selectors that say which parts of the network this text is about. Getting alerts right means correctly interpreting those selectors, resolving multilingual text, and respecting the time windows during which each alert is live. This guide, part of the GTFS-Realtime Integration overview, walks through the Alert message end to end and shows how to map every alert onto the routes, stops, and trips it touches.

The Problem With Alerts

Alerts look simple — a headline and a description — but they are the most structurally irregular message in the GTFS-Realtime specification. A single Alert can name a dozen informed_entity selectors, each at a different granularity: one selector might target an entire agency_id, another a single route_id, another one specific (route_id, stop_id) pairing, and another an individual trip on a given service date. The text fields are not plain strings but TranslatedString messages holding parallel translations in unpredictable order. And every alert is bounded by zero or more active_period windows expressed as POSIX timestamps, so an alert present in the feed is not necessarily an alert you should be displaying right now.

Consumers that ignore this structure produce visible bugs: alerts shown for the wrong stop, French text served to English riders, or a planned-works notice displayed three weeks before it takes effect. Because alerts are what riders actually read, these errors are far more visible than a mis-parsed byte in a protobuf frame. The workflow below is production-grade for a single agency feed or a merged multi-agency aggregator.

Prerequisites

Before running the code in this guide, confirm the following:

  • Python 3.9+ with the official bindings and pandas installed:
text
pip install gtfs-realtime-bindings pandas requests

Concept and Spec Background

The Alert message

In the GTFS-Realtime schema an Alert lives inside a FeedEntity. A FeedMessage is a list of FeedEntity objects, and any entity whose alert field is populated is a service alert. The Alert itself is a container of five field groups that a consumer must read together.

Field Type Meaning
active_period[] list of TimeRange Windows (POSIX start/end) during which the alert applies; empty means “always active while present”
informed_entity[] list of EntitySelector Which parts of the network the alert is about (at least one required)
cause Cause enum Why service is affected (e.g. CONSTRUCTION, WEATHER, MEDICAL_EMERGENCY)
effect Effect enum What the rider experiences (e.g. DETOUR, NO_SERVICE, SIGNIFICANT_DELAYS)
url / header_text / description_text TranslatedString Multilingual link, short title, and full body

Two newer optional fields, severity_level and tts_header_text / tts_description_text (spoken-word variants), appear in some feeds; treat them as optional and fall back to the standard text when absent.

The EntitySelector

Each entry in informed_entity[] is an EntitySelector, and this is where the real logic lives. A selector can set any combination of these fields, and the combination determines the scope:

Selector field Scopes the alert to
agency_id every route and stop operated by that agency
route_id all trips on that route
route_type all routes of that mode (e.g. all light rail)
trip (a TripDescriptor) one specific trip instance
stop_id one stop, across all routes serving it
route_id + stop_id that stop, but only for that route
direction_id (with route_id) one direction of a route

The rule to internalise: fields within one selector are combined with AND; separate selectors in the list are combined with OR. A selector holding route_id="RED" and stop_id="S1" means “the Red line at stop S1”, whereas two selectors — one route_id="RED", one stop_id="S1" — mean “anything on the Red line, or anything at stop S1”.

Cause and effect enums

cause and effect are transmitted as integers. The default cause is UNKNOWN_CAUSE (1) and the default effect is UNKNOWN_EFFECT (8). Because the wire format is numeric, you must map integers back to names; the protobuf bindings expose the enum, so resolve through it rather than hard-coding numbers that shift between spec revisions.

TranslatedString

header_text, description_text, and url are TranslatedString values. A TranslatedString holds a translation[] list, and each Translation has a text field and an optional language (a BCP-47 tag such as en or fr-CA). The order is not defined by the spec, so language selection must be an explicit search, never an index into position zero.


Service Alert fan-out through informed_entity selectors One Alert message on the left carries active_period, cause, effect, and translated text, plus a list of informed_entity selectors. Each selector resolves by its identifier fields to matching rows in the static routes.txt, stops.txt, and trips.txt tables on the right. Alert → informed_entity[] → static tables Alert active_period[] cause / effect header_text description_text selector: route_id selector: route+stop selector: trip informed_entity[] routes.txt route_id → all trips stops.txt stop_id → one stop trips.txt trip_id → one trip

Step-by-Step Implementation

Step 1: Isolate Alert Entities From the FeedMessage

A single realtime endpoint can carry mixed entity types. Read the FeedMessage and keep only entities whose alert field is set, capturing the stable entity.id for deduplication.

python
import logging
from google.transit import gtfs_realtime_pb2

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


def load_alert_entities(raw_protobuf: bytes) -> list:
    """Return the FeedEntity objects that carry a service alert."""
    feed = gtfs_realtime_pb2.FeedMessage()
    feed.ParseFromString(raw_protobuf)

    alert_entities = [e for e in feed.entity if e.HasField("alert")]
    logging.info(
        "Feed timestamp %s carries %d entities, %d of them alerts",
        feed.header.timestamp,
        len(feed.entity),
        len(alert_entities),
    )
    return alert_entities

Using HasField("alert") rather than truthiness matters: an alert with all-default fields is still a valid, present alert, and a truthiness test would discard it.

Step 2: Flatten informed_entity Into Structured Rows

Explode each selector into one record so the alert becomes tabular and joinable. Keep the entity.id on every row so the parent alert can be reassembled and deduplicated later.

python
def flatten_selectors(alert_entities: list) -> list[dict]:
    """One row per (alert, informed_entity) selector."""
    rows: list[dict] = []
    for entity in alert_entities:
        alert = entity.alert
        if not alert.informed_entity:
            logging.warning("Alert %s has no informed_entity — treating as agency-wide", entity.id)
        for selector in alert.informed_entity:
            trip = selector.trip
            rows.append(
                {
                    "alert_id": entity.id,
                    "agency_id": selector.agency_id or None,
                    "route_id": selector.route_id or None,
                    "route_type": selector.route_type if selector.HasField("route_type") else None,
                    "direction_id": selector.direction_id if selector.HasField("direction_id") else None,
                    "stop_id": selector.stop_id or None,
                    "trip_id": trip.trip_id or None if selector.HasField("trip") else None,
                    "cause": alert.cause,
                    "effect": alert.effect,
                }
            )
    return rows

Protobuf scalar strings default to "" rather than null, so the or None idiom converts absent identifiers to a clean missing value that pandas can treat as NaN.

Step 3: Resolve Enums and Pick a Language

Convert the numeric cause/effect back to names through the generated enum, and select translated text by an explicit language search.

python
from google.transit import gtfs_realtime_pb2

Alert = gtfs_realtime_pb2.Alert


def cause_name(cause_value: int) -> str:
    return Alert.Cause.Name(cause_value)


def effect_name(effect_value: int) -> str:
    return Alert.Effect.Name(effect_value)


def pick_translation(translated_string, language: str = "en") -> str | None:
    """Choose text matching `language`, else the untagged entry, else the first."""
    translations = list(translated_string.translation)
    if not translations:
        return None
    exact = [t for t in translations if t.language == language]
    if exact:
        return exact[0].text
    untagged = [t for t in translations if not t.language]
    if untagged:
        return untagged[0].text
    return translations[0].text

Alert.Cause.Name(...) maps 2 to "OTHER_CAUSE", 9 to "CONSTRUCTION", and so on, insulating you from the raw integers. pick_translation degrades gracefully: requested language, then untagged, then first available.

Step 4: Filter by active_period

An alert present in the feed is only live if the current instant falls inside one of its active_period windows — or if it has none, in which case it is active for its whole presence in the feed.

python
import time


def is_active(alert, now_epoch: int | None = None) -> bool:
    """True if `now` falls inside any active_period, or there are none."""
    now_epoch = now_epoch if now_epoch is not None else int(time.time())
    periods = alert.active_period
    if not periods:
        return True
    for window in periods:
        start_ok = (not window.HasField("start")) or window.start <= now_epoch
        end_ok = (not window.HasField("end")) or now_epoch <= window.end
        if start_ok and end_ok:
            return True
    return False

Both start and end are individually optional: an open-ended window with only a start means “from this moment onward”, and one with only an end means “until this moment”. Treat a missing bound as unbounded on that side rather than as zero.

Step 5: Join Selectors to Static Routes, Stops, and Trips

With the selectors flattened, expand each one to the concrete set of affected trips using the static feed. A route_id-only selector fans out to every trip on that route; a trip_id selector resolves directly.

python
import pandas as pd

selectors = pd.DataFrame(flatten_selectors(load_alert_entities(raw_protobuf)))

trips = pd.read_csv(
    "gtfs/trips.txt",
    dtype={"route_id": str, "service_id": str, "trip_id": str, "direction_id": "Int64"},
    usecols=["route_id", "service_id", "trip_id", "direction_id"],
)

# Route-scoped selectors → every trip on the named route
route_scoped = selectors[selectors["route_id"].notna() & selectors["trip_id"].isna()]
route_affected = route_scoped.merge(
    trips[["route_id", "trip_id"]], on="route_id", how="inner", suffixes=("", "_static")
)

# Trip-scoped selectors already name their trip_id
trip_scoped = selectors[selectors["trip_id"].notna()][["alert_id", "trip_id", "cause", "effect"]]

affected_trips = pd.concat(
    [route_affected[["alert_id", "trip_id", "cause", "effect"]], trip_scoped],
    ignore_index=True,
)
logging.info("Expanded selectors to %d (alert, trip) pairs", len(affected_trips))

Stop-scoped selectors are handled the same way against stops.txt, and agency_id or route_type selectors expand by first resolving the matching route_id set from routes.txt. Because different selectors on the same alert can name the same trip, the concatenated result needs deduplication in the next section.

Validation and Verification

Before publishing alerts to riders, gate the pipeline on structural invariants:

python
def validate_alerts(selectors: pd.DataFrame, affected_trips: pd.DataFrame) -> dict:
    """Assert alert-processing invariants; return summary counts."""
    # Every alert must contribute at least one selector
    assert not selectors.empty, "No selectors extracted — feed decode or flatten failed"

    # A selector must carry at least one identifying field
    id_cols = ["agency_id", "route_id", "route_type", "stop_id", "trip_id"]
    empty_selectors = selectors[selectors[id_cols].isna().all(axis=1)]
    assert empty_selectors.empty, (
        f"{len(empty_selectors)} selectors have no identifier: {empty_selectors['alert_id'].tolist()}"
    )

    # Dedupe (alert_id, trip_id) so one alert never lists a trip twice
    deduped = affected_trips.drop_duplicates(subset=["alert_id", "trip_id"])

    return {
        "alert_count": selectors["alert_id"].nunique(),
        "selector_count": len(selectors),
        "affected_trip_pairs": len(deduped),
        "duplicate_pairs_removed": len(affected_trips) - len(deduped),
    }

The deduplication on (alert_id, trip_id) is the crucial step: a real alert frequently lists both a route_id selector and a narrower route_id+stop_id selector, and both expand to overlapping trips. Without the dedupe, a rider display would show the same detour notice twice on one trip.

Failure Modes and Edge Cases

  • Selectors that resolve to nothing. A route_id or stop_id in a selector may not exist in the current static feed — the realtime and static feeds can drift out of sync between publishes. An inner join silently drops these; run a left join in an audit pass and log unmatched identifiers rather than losing the alert entirely. This is the same identifier-drift problem covered in depth by matching realtime to static schedules.

  • trip selectors without a trip_id. A TripDescriptor inside a selector can identify a trip by route_id + start_time + start_date instead of a trip_id. Feeds that use frequency-based service do this routinely; resolve those against the static schedule the same way frequency-based schedules are expanded, or the selector will look empty.

  • Stale timestamps and clock skew. active_period uses the producer’s clock. If your consumer’s clock is skewed or you compare against a local naive datetime, alerts flicker on and off around window boundaries. Always compare POSIX-to-POSIX in UTC, then render locally.

  • HTML or Markdown smuggled into text. Some agencies embed HTML tags or line breaks in description_text. Treat the text as untrusted: strip or escape markup before display rather than assuming plain text.

  • Enum values from a newer spec revision. If an agency emits a cause or effect integer your bindings do not know, Enum.Name() raises ValueError. Wrap the lookup and fall back to the raw integer with an UNRECOGNIZED_ prefix so one unknown enum never crashes the batch. Schema-level drift like this is exactly what GTFS validation rules exist to catch upstream.

Performance and Scale Notes

Alert feeds are small compared to vehicle-position or TripUpdate feeds — a large agency rarely carries more than a few hundred active alerts — so raw parse time is negligible. The cost concentrates in the fan-out join, because a single agency-wide selector can multiply against tens of thousands of trips.

python
# Cap fan-out: resolve agency/route_type selectors to a route_id set first,
# then join once, rather than cross-joining every trip per selector.
routes = pd.read_csv(
    "gtfs/routes.txt",
    dtype={"route_id": str, "agency_id": str, "route_type": "Int64"},
    usecols=["route_id", "agency_id", "route_type"],
)

agency_scoped = selectors[selectors["agency_id"].notna()]
routes_for_agency = agency_scoped.merge(routes, on="agency_id", how="inner")

# Persist the resolved (alert_id, trip_id) map as Parquet for the display layer
affected_trips.drop_duplicates(subset=["alert_id", "trip_id"]).to_parquet(
    "cache/alert_trip_map.parquet", index=False
)

For a multi-agency aggregator polling dozens of feeds, keep the resolved alert-to-trip map in a columnar store (Parquet) keyed by alert_id, and recompute only the alerts whose entity.id set changed since the last poll — the alert body rarely changes between polls even though the same IDs recur. For chunked joins against very large national trips.txt files, apply the techniques from memory-efficient processing for large feeds.

Frequently Asked Questions

What does an empty informed_entity list mean in a GTFS service alert?

A GTFS-Realtime Alert must carry at least one informed_entity; a producer that emits an alert with no selectors is non-conformant. In practice some feeds do it to signal a network-wide notice, but consumers cannot reliably scope such an alert, so log it and surface it as agency-wide rather than dropping it.

How do I choose the right language from a TranslatedString?

Iterate the translation list and match the language field against your requested BCP-47 tag, falling back to a translation with no language set, then to the first entry. Never assume element zero is English; producers order translations arbitrarily.

Does an EntitySelector with only a route_id affect every trip on that route?

Yes. A selector that names a route_id but no trip applies to all trips on that route for the alert’s active_period. A selector that names both a route_id and a stop_id narrows the alert to that stop on that route, not the whole route or the whole stop.

Why do the same alert IDs reappear on every poll?

ServiceAlerts are stateful: an alert persists across polls for its whole lifetime, so the same entity.id recurs until the agency removes it. Deduplicate on entity.id and only act on the header_text or active_period changing, rather than treating each poll as new alerts.

Up: GTFS-Realtime Integration | Home