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
pandasinstalled:
pip install gtfs-realtime-bindings pandas requests
- A working decode step. This guide assumes you can already turn the raw protobuf response into a
FeedMessageobject; if not, start with decoding GTFS-Realtime protobuf feeds and its Python parsing walkthrough. - The matching static feed. Selectors reference identifiers defined in the static
routes.txt,stops.txt, andtrips.txtfiles; review understanding GTFS static feed structure so the join targets are clear before you resolve any selector. - The feed’s
agency_timezone, needed to renderactive_periodtimestamps in local time. The mechanics of that conversion are covered in timezone handling and schedule normalization.
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.
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.
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.
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.
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.
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.
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:
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_idorstop_idin 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. -
tripselectors without atrip_id. ATripDescriptorinside a selector can identify a trip byroute_id+start_time+start_dateinstead of atrip_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_perioduses 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
causeoreffectinteger your bindings do not know,Enum.Name()raisesValueError. Wrap the lookup and fall back to the raw integer with anUNRECOGNIZED_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.
# 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.
Related
- Parsing GTFS Service Alerts in Python — the complete script that flattens
informed_entity[]into a tidy DataFrame with translated text and active periods - Matching Realtime to Static Schedules — the join-key reconciliation that turns a selector’s
route_id/stop_id/trip_idinto concrete static rows - Processing TripUpdates and Delays — the schedule-delta counterpart to the text-only alert feed
- Decoding GTFS-Realtime Protobuf Feeds — the decode step every alert pipeline depends on
- Understanding GTFS Static Feed Structure — the
routes.txt,stops.txt, andtrips.txttargets that selectors resolve against