This vignette shows how to read GTFS-realtime service alerts into R using the {gtfsrealtime} package. Service alerts describe disruptions, planned work, stop closures, detours, and other rider-facing information. The {gtfsrealtime} package reads the nested GTFS-realtime alert format and flattens it into a data frame that is easier to inspect and analyze in R.
Load libraries
First, we load {gtfsrealtime} to read GTFS-realtime files, {dplyr} to inspect and summarize the resulting data frame, and {stringr} to shorten long alert messages for display.
Load a GTFS-realtime service alerts feed
This example uses a New York City service alerts feed included with
{gtfsrealtime}. The file is compressed with bzip2 to save space.
{gtfsrealtime} can automatically detect and read uncompressed files as
well as files compressed with zip, gzip, or bzip2. Zip files can contain
multiple GTFS-realtime files, in which case {gtfsrealtime} will read all
of them. You can differentiate which file each update came from based on
the file_index field.
GTFS-realtime time values are stored as Unix timestamps, which are
interpreted relative to UTC. To convert to local time, we provide a
local time zone. Time zones are specified in standardized TZ database
format (generally Continent/City; for a list, see
here). If you do not want to convert times, you can specify a time
zone of Etc/UTC.
alerts <- read_gtfsrt_alerts(
system.file("nyc-service-alerts.pb.bz2", package = "gtfsrealtime"),
"America/New_York"
)Explore the alerts
GTFS-realtime alerts are nested:
one alert can include multiple active time periods and multiple informed
entities, such as routes, stops, trips, or agencies affected by the
alert. read_gtfsrt_alerts() flattens this structure into a
data frame. As a result, the same alert id may appear in
multiple rows when an alert applies to more than one entity or time
period.
glimpse(alerts)
#> Rows: 430
#> Columns: 28
#> $ id <chr> "MTA NYCT_lmm:planned_work:22245", "MTA NYC…
#> $ start <dttm> 2025-01-03 00:00:35, 2025-01-03 00:00:35, …
#> $ end <dttm> NA, NA, 2026-06-30 00:00:00, 2026-06-30 00…
#> $ agency_id <chr> "MTA NYCT", "MTA NYCT", "MTA NYCT", "MTA NY…
#> $ route_id <chr> "X38", "X28", NA, NA, NA, NA, "B9", "B63", …
#> $ route_type <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ direction_id <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ trip_trip_id <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ trip_route_id <chr> NA, NA, "M15+", "M15+", "S66", "S66", NA, N…
#> $ trip_direction_id <dbl> NA, NA, 0, 1, 1, 0, NA, NA, NA, 1, 0, 0, 1,…
#> $ trip_start_time <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ trip_start_date <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ trip_schedule_relationship <fct> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ trip_modification_id <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ stop_id <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ cause <fct> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ effect <fct> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ language <chr> "EN", "EN", "EN", "EN", "EN", "EN", "EN", "…
#> $ cause_detail <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ effect_detail <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ url <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ header_text <chr> "X28 and X38 stops on Surf Ave at W 21st …
#> $ description_text <chr> "X28 and X38 stops on Surf Ave at W 21st …
#> $ tts_header_text <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ tts_description_text <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ severity_level <fct> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ file_timestamp <dttm> 2026-01-28 08:57:42, 2026-01-28 08:57:42, …
#> $ file_index <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…Inspecting a single alert
Because a single alert can apply to more than one route, stop, or trip, it is useful to inspect all rows associated with one alert id. In the example below, we select the first alert in the feed and display the affected route or stop fields along with the rider-facing alert text.
alerts |>
filter(id == first(id)) |>
select(id, route_id, stop_id, header_text, description_text)
#> id route_id stop_id
#> 1 MTA NYCT_lmm:planned_work:22245 X38 <NA>
#> 2 MTA NYCT_lmm:planned_work:22245 X28 <NA>
#> header_text
#> 1 X28 and X38 stops on Surf Ave at W 21st St are closed in both directions
#> 2 X28 and X38 stops on Surf Ave at W 21st St are closed in both directions
#> description_text
#> 1 X28 and X38 stops on Surf Ave at W 21st St are closed in both directions\nFor northbound service, use the temporary stop on Surf Ave at W 22nd St.\nFor southbound service, use the stop on W 17th St at Mermaid Ave.\n\nWhat's happening?\nConstruction\n\nNote: Bus arrival information may not be available/accurate while buses are detoured
#> 2 X28 and X38 stops on Surf Ave at W 21st St are closed in both directions\nFor northbound service, use the temporary stop on Surf Ave at W 22nd St.\nFor southbound service, use the stop on W 17th St at Mermaid Ave.\n\nWhat's happening?\nConstruction\n\nNote: Bus arrival information may not be available/accurate while buses are detouredSummarizing affected routes
We can also summarize which routes appear most often in the alerts
feed. Route IDs appear in two places in the alerts feed: in the
route_id column and in the trip_route_id
column (which correspond to alerts that apply to an entire route, and
those that correspond to a single trip on a route). First, we put those
together, and then we count the number of routes. Note that
trip_route_id is optional if the trip ID itself is
specified, so it might be necessary to refer to the static GTFS to map
trip IDs to route IDs with some feeds, though in the example feed all
updates have route IDs or trip route IDs. It is also possible to have
service alerts that apply to specific stops, modes (bus/tram/etc), or
agency, and do not have a route ID, though there are none in the New
York MTA example feed. If alerts have translated strings or multiple
time periods, they may be in multiple rows in the data frame, so we make
sure to group by the alert ID and select only a single instance before
counting the number of alerts by route.
# make sure trip_route_id and route_id always agree if both are specified
stopifnot(with(alerts, all(is.na(route_id) | is.na(trip_route_id) | route_id == trip_route_id)))
# make sure every update has a route id
stopifnot(with(alerts, all(!is.na(route_id) | !is.na(trip_route_id))))
alerts |>
mutate(route_id = coalesce(route_id, trip_route_id)) |>
group_by(id) |>
slice_head(n=1) |>
ungroup() |>
count(route_id, sort = TRUE) |>
head(10)
#> # A tibble: 10 × 2
#> route_id n
#> <chr> <int>
#> 1 M15 3
#> 2 M15+ 3
#> 3 M66 3
#> 4 B44 2
#> 5 B65 2
#> 6 BX39 2
#> 7 Q18 2
#> 8 Q32 2
#> 9 Q47 2
#> 10 B12 1Working with alert text
The alert text fields contain the rider-facing message.
header_text usually provides a short summary, while
description_text provides more detail. All fields are
described in the documentation for
[read_gtfsrt_alerts()].
alerts |>
distinct(id, header_text, description_text) |>
mutate(
header_text = str_trunc(header_text, 80),
description_text = str_trunc(description_text, 120)
) |>
head()
#> id
#> 1 MTA NYCT_lmm:planned_work:22245
#> 2 MTA NYCT_lmm:planned_work:29838
#> 3 MTA NYCT_lmm:alert:503501
#> 4 MTA NYCT_lmm:alert:503667
#> 5 MTA NYCT_lmm:planned_work:22403
#> 6 MTA NYCT_lmm:planned_work:29559
#> header_text
#> 1 X28 and X38 stops on Surf Ave at W 21st St are closed in both directions
#> 2 Northbound M15-SBS stop on Water St at Pine St is closed, buses make a tempor...
#> 3 S66 buses are detoured in both directions due to icy roads at Arlo Rd/Stratfo...
#> 4 You may wait longer for these buses:\nB9, B35, B63
#> 5 Southbound Bx39 stop on White Plains Rd at E 215th St is closed; please use t...
#> 6 M5 stop on W 31st St at 6th Ave is closed; for service, consider the southbou...
#> description_text
#> 1 X28 and X38 stops on Surf Ave at W 21st St are closed in both directions\nFor northbound service, use the temporary ...
#> 2 Northbound M15-SBS stop on Water St at Pine St is closed, buses make a temporary stop across the intersection on Wate...
#> 3 S66 buses are detoured in both directions due to icy roads at Arlo Rd/Stratford Ave.\nS66 buses in both directions wil...
#> 4 You may wait longer for these buses:\nB9, B35, B63\nWe're running as much service as we can with the operators we have ...
#> 5 Southbound Bx39 stop on White Plains Rd at E 215th St is closed; please use the new stop on White Plains Rd at E 216t...
#> 6 M5 stop on W 31st St at 6th Ave is closed; for service, consider the southbound stop on 5th Ave at W 33rd St or the n...