Exploring the (now-dead) Spirit Airlines Network

On May 2nd, Spirit Airlines permanently ceased operations. The airline, which had been the seventh-largest in the US, leaves behind 172 planes (many of which are currently being ferried back to their lessors) and 73 destinations.

It’s those destinations I’m most curious about - what will happen to them when Spirit exits?

While Spirit may have been a terminally flawed business, boxed out by labor costs and the “k-shaped” economic recovery following the pandemic, it did have a genuine role in the US aviation world as a low-fare spill carrier, keeping more premium competitors honest on cost and capacity. This role benefited American consumers, even if they didn’t fly on Spirit directly.

With this in mind, I spent some time mucking about the Bureau of Transportation Statistics T-100 data, which shows flight capacity and load factors, and DB1C data, which captures fares paid. In this post, you’ll see visualizations from both datasets, seeking to shed light on Spirit’s operations at a high level.

Where was Spirit Flying?

Starting with a map visualization drives home a key point to start off - while it might have dabbled in flying between major cities, Spirit was really a leisure airline at the end of the day, with a core competency of flying people to Florida.

Spirit Network Map

This trend is clearly visible when we look at the busiest O&D (origin and destination) markets, the vast majority of which touch either Fort Lauderdale (FLL) or Orlando (MCO).

Spirit Largest O&D

Breaking the data out by focus city makes everything a bit more legible…

Spirit Passengers by Focus City

What Might Other Airlines Backfill?

The Highest Fare Routes?

The DB1C data is awesome because it gives us a sense of the fares that customers are paying to fly between two markets. While in Spirit’s case, that may not represent the fully-loaded travel cost (since it was totally possible and maybe even probable to spend more on bags and seat assignments than the base fare itself), I think it’s a useful enough proxy to identify the high and low-potential routes within the network portfolio, that other airlines might want to go after.

Spirit Highest and Lowest Fares by Route

Spirit Highest Fares by Focus City

Plotting fares against distance flown starts to show the limits of this approach though - fares were generally a function of distance flown, and without knowing how many fares were actually sold, it’s hard to know if the flights were full enough to make money.

Fare vs Distance for Spirit’s Largest O&D Markets

The Most Sold-Out Routes?

Fortunately for us, the BTS’s T-100 data provides load factors per flight, which supplies the additional angle we are looking for. Where was Spirit able to fully sell out their cabins, versus routes which were perennially half-empty?

Spirit’s Best and Worst Routes by Load Factor

Spirit Highest Load Factors by Focus City

Or Both - Routes with High Fares and Load Factors?

Combining the DB1C data with the T-100 data gives us a fully unified dataset, and allows us to compare load factors and fares in a single view.

My best guess is that the other major US airlines will immediately step up frequencies for the Spirit flights in the top right quadrant of the chart: flights with full cabins and relatively high fares. JetBlue seems to have already kick-started this process in FLL…. I will be curious to see what carriers are best positioned to pick up the best routes out of MCO (no hub incumbent) and LAS (perhaps Southwest?!).

Load Factors vs Fares for Spirit’s Largest Routes

In Memoriam

One last thing - I will never, ever stop enjoying this photo of The Great Seafood Feast - perhaps the perfect embodiment of Spirit as a carrier, and also the reason for its downfall. RIP!

Spirit Airlines Seafood Feast

Code Reference

This was the second or third time I’ve run into a dataset too heavy to manipulate within R session memory… to make the data workable in my usual RStudio environment, I used Apache Arrow to lazy-load the columnar parquet data and DuckDB as a database engine. that stack makes working with medium-sized data trivial!

Loading the DB1C Data

library(arrow)
library(tidyverse)

# point to your file
path <- "DB1C.PUBLIC.202510.REL02.27FEB2026/DB1C.PUBLIC.202510.REL02.27FEB2026.parquet"

# lazy load (does NOT pull all into memory yet)
db1c <- open_dataset(path)
library(DBI)
library(duckdb)
library(glue)
library(scales)
library(stringr)

con <- dbConnect(duckdb())

duckdb_register_arrow(con, "db1c", db1c)

dir.create("~/duckdb_tmp", showWarnings = FALSE)

DBI::dbExecute(con, "SET preserve_insertion_order = false")
DBI::dbExecute(con, "SET temp_directory = '~/duckdb_tmp'")
DBI::dbExecute(con, "SET memory_limit = '6GB'")
DBI::dbExecute(con, "SET threads = 4")


# Fully Spirit-marketed itinerary filter:
# For every coupon that exists, MktCarrier_i must be NK.
all_spirit_where <- paste(
  sprintf("(CouponSeg < %s OR MktCarrier_%s = 'NK')", 1:23, 1:23),
  collapse = "\n  AND "
)

# Only keep columns needed downstream
# Important: Dwell_Time and TripBk fields start at _2, not _1

needed_cols <- c(
  "RpCarrier", "RpYear", "RpMonth", "CouponSeg", "IssueCarrier",
  "TotalAmt", "TaxAmt", "DollarCred", "NumPax", "PurWinGrp",
  
  unlist(lapply(1:23, function(i) {
    c(
      paste0("SchFlYr_", i),
      paste0("SchFlMo_", i),
      paste0("Apt_", i),
      paste0("CityMktID_", i),
      if (i == 1) "WAC_1" else paste0("CityWAC_", i),
      paste0("ViaApt_", i),
      paste0("OpCarrier_", i),
      paste0("MktCarrier_", i),
      paste0("Coupon_SegDist_", i)
    )
  })),
  
  # These start at _2 in your schema
  paste0("Dwell_Time_", 2:23),
  paste0("TripBk19_7_", 2:23),
  paste0("TripBk19_8_", 2:23),
  
  # Final destination fields
  "LastApt", "LastCityMktID", "LastCityWAC",
  "TripBk19_7_last", "TripBk19_8_last"
)

needed_cols <- unique(needed_cols)
needed_cols_sql <- paste(needed_cols, collapse = ",\n  ")


# This should be much smaller than scanning/materializing the full DB1C.
# RpCarrier = 'NK' is the big speedup.
DBI::dbExecute(con, glue("
CREATE OR REPLACE TABLE db1c_spirit_base AS
SELECT
  {needed_cols_sql}
FROM db1c
WHERE RpCarrier = 'NK'
  AND CouponSeg BETWEEN 1 AND 23
  AND {all_spirit_where}
"))


# Add ticket_id only after aggressive filtering
DBI::dbExecute(con, "
CREATE OR REPLACE TABLE db1c_spirit_base_numbered AS
SELECT
  row_number() OVER () AS ticket_id,
  *
FROM db1c_spirit_base
")

make_segment_sql <- function(i) {
  
  origin_airport <- paste0("Apt_", i)
  dest_airport   <- if (i < 23) paste0("Apt_", i + 1) else "LastApt"
  
  origin_city <- paste0("CityMktID_", i)
  dest_city   <- if (i < 23) paste0("CityMktID_", i + 1) else "LastCityMktID"
  
  origin_wac <- if (i == 1) "WAC_1" else paste0("CityWAC_", i)
  dest_wac   <- if (i < 23) paste0("CityWAC_", i + 1) else "LastCityWAC"
  
  trip_break_197 <- if (i < 23) paste0("TripBk19_7_", i + 1) else "TripBk19_7_last"
  trip_break_198 <- if (i < 23) paste0("TripBk19_8_", i + 1) else "TripBk19_8_last"
  
  dwell_after <- if (i < 23) paste0("Dwell_Time_", i + 1) else "NULL"
  
  glue("
    SELECT
      ticket_id,

      RpCarrier,
      RpYear,
      RpMonth,
      CouponSeg,
      IssueCarrier,
      TotalAmt,
      TaxAmt,
      DollarCred,
      NumPax,
      PurWinGrp,

      {i} AS segment_no,

      SchFlYr_{i} AS sch_flight_year,
      SchFlMo_{i} AS sch_flight_month,

      {origin_airport} AS origin_airport,
      {dest_airport} AS dest_airport,

      {origin_city} AS origin_city_market_id,
      {dest_city} AS dest_city_market_id,

      {origin_wac} AS origin_wac,
      {dest_wac} AS dest_wac,

      ViaApt_{i} AS via_airport,
      OpCarrier_{i} AS op_carrier,
      MktCarrier_{i} AS mkt_carrier,

      Coupon_SegDist_{i} AS segment_distance,
      {dwell_after} AS dwell_time_after,

      {trip_break_197} AS trip_break_197,
      {trip_break_198} AS trip_break_198

    FROM db1c_spirit_base_numbered
    WHERE CouponSeg >= {i}
      AND {origin_airport} IS NOT NULL
      AND {dest_airport} IS NOT NULL
  ")
}

segment_sql <- paste(
  vapply(1:23, make_segment_sql, character(1)),
  collapse = "\nUNION ALL\n"
)


DBI::dbExecute(con, glue("
CREATE OR REPLACE TEMP TABLE db1c_segments AS
WITH base AS (
  SELECT
    row_number() OVER () AS ticket_id,
    *
  FROM db1c_spirit_base_numbered
)
{segment_sql}
"))

Turning the Segments into O&D Trips

DBI::dbExecute(con, "
CREATE OR REPLACE TEMP TABLE db1c_od_trips AS
WITH marked AS (
  SELECT
    *,
    CASE
      WHEN trip_break_197 IN ('X', 'Y') THEN 1
      WHEN segment_no = CouponSeg THEN 1
      ELSE 0
    END AS ends_trip
  FROM db1c_segments
),

numbered AS (
  SELECT
    *,
    1 + COALESCE(
      SUM(ends_trip) OVER (
        PARTITION BY ticket_id
        ORDER BY segment_no
        ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
      ),
      0
    ) AS trip_no
  FROM marked
),

ticket_totals AS (
  SELECT
    ticket_id,
    SUM(segment_distance) AS itinerary_distance
  FROM db1c_segments
  GROUP BY ticket_id
),

trip_rollup AS (
  SELECT
    ticket_id,
    trip_no,

    ANY_VALUE(RpCarrier) AS reporting_carrier,
    ANY_VALUE(RpYear) AS report_year,
    ANY_VALUE(RpMonth) AS report_month,
    ANY_VALUE(CouponSeg) AS coupon_segments,
    ANY_VALUE(IssueCarrier) AS issue_carrier,
    ANY_VALUE(PurWinGrp) AS purchase_window_group,

    ANY_VALUE(TotalAmt) AS total_amount,
    ANY_VALUE(TaxAmt) AS tax_amount,
    ANY_VALUE(NumPax) AS num_pax,

    FIRST(origin_airport ORDER BY segment_no) AS origin_airport,
    LAST(dest_airport ORDER BY segment_no) AS dest_airport,

    FIRST(origin_city_market_id ORDER BY segment_no) AS origin_city_market_id,
    LAST(dest_city_market_id ORDER BY segment_no) AS dest_city_market_id,

    FIRST(origin_wac ORDER BY segment_no) AS origin_wac,
    LAST(dest_wac ORDER BY segment_no) AS dest_wac,

    COUNT(*) AS n_segments,
    SUM(segment_distance) AS trip_distance,

    FIRST(origin_airport ORDER BY segment_no) || '-' ||
      STRING_AGG(dest_airport, '-' ORDER BY segment_no) AS path,

    BOOL_OR(mkt_carrier = 'NK') AS any_mkt_spirit,
    BOOL_AND(mkt_carrier = 'NK') AS all_mkt_spirit,

    BOOL_OR(op_carrier = 'NK') AS any_op_spirit,
    BOOL_AND(op_carrier = 'NK') AS all_op_spirit,

    STRING_AGG(DISTINCT mkt_carrier, ',') AS mkt_carriers,
    STRING_AGG(DISTINCT op_carrier, ',') AS op_carriers

  FROM numbered
  GROUP BY ticket_id, trip_no
)

SELECT
  tr.*,
  tt.itinerary_distance,

  CASE
    WHEN tr.tax_amount IS NULL THEN tr.total_amount
    ELSE tr.total_amount - tr.tax_amount
  END AS base_fare_est,

  CASE
    WHEN tt.itinerary_distance > 0 THEN
      (
        CASE
          WHEN tr.tax_amount IS NULL THEN tr.total_amount
          ELSE tr.total_amount - tr.tax_amount
        END
      ) * tr.trip_distance / tt.itinerary_distance
    ELSE NULL
  END AS allocated_base_fare,

  CASE
    WHEN tr.origin_airport < tr.dest_airport THEN tr.origin_airport || '-' || tr.dest_airport
    ELSE tr.dest_airport || '-' || tr.origin_airport
  END AS route,

  tr.origin_airport || '-' || tr.dest_airport AS directional_route,

  CASE
    WHEN tr.n_segments = 1 THEN TRUE
    ELSE FALSE
  END AS nonstop_trip,

  CASE
    WHEN tr.n_segments > 1 THEN TRUE
    ELSE FALSE
  END AS connecting_trip

FROM trip_rollup tr
LEFT JOIN ticket_totals tt
  ON tr.ticket_id = tt.ticket_id
")

Compile Spirit Data at the Route Level

spirit_od_routes_all <- tbl(con, "db1c_od_trips") %>%
  filter(all_mkt_spirit) %>%
  mutate(
    passenger_miles = num_pax * trip_distance,
    fare_revenue_est = num_pax * allocated_base_fare,
    connecting_pax = case_when(
      connecting_trip ~ num_pax,
      TRUE ~ 0L
    ),
    nonstop_pax = case_when(
      nonstop_trip ~ num_pax,
      TRUE ~ 0L
    )
  ) %>%
  group_by(route) %>%
  summarise(
    passengers = sum(num_pax, na.rm = TRUE),
    tickets = n(),
    fare_revenue_est = sum(fare_revenue_est, na.rm = TRUE),
    passenger_miles = sum(passenger_miles, na.rm = TRUE),
    connecting_pax = sum(connecting_pax, na.rm = TRUE),
    nonstop_pax = sum(nonstop_pax, na.rm = TRUE),
    distance_weighted = sum(trip_distance * num_pax, na.rm = TRUE),
    fare_weighted = sum(allocated_base_fare * num_pax, na.rm = TRUE),
    segments_weighted = sum(n_segments * num_pax, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  mutate(
    avg_distance = distance_weighted / passengers,
    avg_fare = fare_weighted / passengers,
    avg_segments = segments_weighted / passengers,
    yield_est = fare_revenue_est / passenger_miles,
    connecting_share = connecting_pax / passengers,
    nonstop_share = nonstop_pax / passengers
  ) %>%
  select(
    route,
    passengers,
    tickets,
    fare_revenue_est,
    avg_fare,
    yield_est,
    avg_distance,
    avg_segments,
    nonstop_share,
    connecting_share,
    passenger_miles
  ) %>%
  arrange(desc(passengers))


top_spirit_routes_by_passengers <- spirit_od_routes_all %>%
  head(50) %>%
  collect()

top_spirit_routes_by_revenue <- spirit_od_routes_all %>%
  arrange(desc(fare_revenue_est)) %>%
  head(50) %>%
  collect()

top_spirit_routes_by_yield <- spirit_od_routes_all %>%
  filter(passengers >= 1000) %>%
  arrange(desc(yield_est)) %>%
  head(50) %>%
  collect()

Visualization

# ------------------------------------------------------------------------------
# Highest and lowest Spirit O&D markets by average fare
# ------------------------------------------------------------------------------

library(forcats)
library(scales)
library(ggplot2)
library(dplyr)

spirit_yellow <- "#FFE600"
spirit_black  <- "#1F1F1F"
spirit_grey   <- "#6B6B6B"
low_fare_grey <- "#9E9E9E"

min_passengers <- 1000
n_routes <- 15

spirit_fare_extremes <- spirit_od_routes_all %>%
  filter(passengers >= min_passengers) %>%
  collect()

top_fares <- spirit_fare_extremes %>%
  slice_max(avg_fare, n = n_routes) %>%
  mutate(fare_group = "Highest fares")

low_fares <- spirit_fare_extremes %>%
  slice_min(avg_fare, n = n_routes) %>%
  mutate(fare_group = "Lowest fares")

fare_extremes <- bind_rows(top_fares, low_fares) %>%
  mutate(
    fare_group = factor(fare_group, levels = c("Highest fares", "Lowest fares")),
    route_label = paste0(route, "  ·  ", comma(passengers), " pax"),
    route_label = fct_reorder(route_label, avg_fare)
  )

ggplot(
  fare_extremes,
  aes(x = avg_fare, y = route_label, fill = fare_group)
) +
  geom_col(
    color = spirit_black,
    linewidth = 0.25,
    width = 0.75
  ) +
  geom_text(
    aes(label = dollar(avg_fare, accuracy = 1)),
    hjust = -0.12,
    size = 3.1,
    color = spirit_black
  ) +
  facet_wrap(
    ~ fare_group,
    scales = "free_y",
    ncol = 1
  ) +
  scale_fill_manual(
    values = c(
      "Highest fares" = spirit_yellow,
      "Lowest fares" = low_fare_grey
    )
  ) +
  scale_x_continuous(
    labels = dollar_format(),
    expand = expansion(mult = c(0, 0.18))
  ) +
  theme_minimal(base_size = 12) +
  theme(
    panel.grid.minor.x = element_blank(),
    panel.grid.major.y = element_blank(),
    strip.background = element_rect(fill = spirit_black, color = NA),
    strip.text = element_text(color = "grey97", face = "bold"),
    legend.position = "none",
    legend.background = element_rect(color = NA),
    plot.title = element_text(size = 20, face = "bold"),
    plot.subtitle = element_text(size = 12),
    plot.caption = element_text(colour = "grey60"),
    plot.title.position = "plot"
  ) +
  labs(
    title = "Spirit's Highest and Lowest-Fare O&D Markets",
    subtitle = "Spirit's revenue model is different than other airlines, with lots of ancillary revenue not\ncaptured directly in fares, but the fare data is still useful to assess demand and\npricing power.",
    x = "Estimated pre-tax fare",
    y = NULL,
    caption = "Source: BTS DB1C Public O&D Data, October 2025\nconormclaughlin.net"
  )