U.S. Gross Output: Nominal, Quantity, and Price

Levels, growth, breadth, dispersion, and contributions across 71 BEA leaf industries

Overview

Gross output (GO) measures production across industries, including intermediate transactions. Nominal gross output is roughly analogous to industry revenues. This report uses three related BEA series: nominal gross output ($ billions, SAAR), chain-type gross-output quantity indexes (2017 = 100), and chain-type gross-output price indexes (2017 = 100).

Each is analyzed separately using the same 71 non-overlapping industries, selected and ordered by the Leaf = 1 flags in the LeafTaxonomy sheet of IndustryTaxonomy.xlsx. The underlying observations come only from GrossOutput.xlsx, GrossOutput-Qty.xlsx, and GrossOutput-Price.xlsx. Data are matched by BEA line number rather than by text names or spreadsheet positions. All growth rates are nonannualized: quarter-over-quarter (QoQ) is primary; year-over-year (YoY) is supplemental.

Nominal GO industry levels are additive, allowing exact percentage-point contributions to growth (apart from BEA rounding versus the published total). Quantity and price indexes are not additive. We display approximate contributions for the quantity and price sections using lagged nominal GO shares, and explicitly report the difference between their sum and the official aggregate index growth. We do not force a reconciliation.

Data construction and checks

The LeafTaxonomy worksheet controls selection, order, and broad-group assignments. We read raw levels or indexes from the three separate BEA Table sheets, keyed on the BEA Line identifier. The year and quarter labels are reconstructed independently for each workbook. No previously computed filtered sheets or breadth statistics are used.

Show R code
# Taxonomy supplies flags and original BEA order, not the observed data.
tax_raw <- readxl::read_excel(
  files[["taxonomy"]], sheet = "LeafTaxonomy", col_names = FALSE,
  col_types = "text", trim_ws = FALSE, .name_repair = "unique"
)
meta_all <- tibble::tibble(
  leaf_flag = suppressWarnings(as.integer(tax_raw[[1]])),
  line = suppressWarnings(as.integer(tax_raw[[2]])),
  raw_name = tax_raw[[3]]
) |>
  dplyr::filter(!is.na(line), !is.na(raw_name)) |>
  dplyr::mutate(
    industry = stringr::str_trim(raw_name),
    indent = nchar(raw_name) - nchar(stringr::str_trim(raw_name, side = "left"))
  )
stopifnot(!anyDuplicated(meta_all$line), any(meta_all$industry == "All industries"))

# Parent group determined from indentation in the taxonomy (not any sorting).
current_group <- NA_character_
in_government <- FALSE
groups <- character(nrow(meta_all))
for (j in seq_len(nrow(meta_all))) {
  label <- meta_all$industry[j]
  if (label == "Government") {
    in_government <- TRUE
    current_group <- "Government"
  } else if (!in_government && meta_all$indent[j] == 4L) {
    current_group <- label
  }
  groups[j] <- current_group
}
meta_all$broad_group <- groups
industry_meta <- meta_all |>
  dplyr::filter(leaf_flag == 1L) |>
  dplyr::mutate(industry_id = dplyr::row_number()) |>
  dplyr::select(industry_id, line, industry, broad_group)
stopifnot(nrow(industry_meta) == 71L, !anyNA(industry_meta$broad_group),
          !anyDuplicated(industry_meta$line))

broad_groups <- unique(industry_meta$broad_group)
industry_order <- rev(industry_meta$industry_id)
industry_axis_labels <- stats::setNames(
  paste0("[", industry_meta$line, "] ",
         stringr::str_wrap(industry_meta$industry, width = 49)),
  as.character(industry_meta$industry_id)
)

# Read a BEA 'Table' tab and obtain exactly the same line IDs as in LeafTaxonomy.
read_bea_series <- function(path) {
  raw <- readxl::read_excel(path, sheet = "Table", col_names = FALSE,
                            col_types = "text", trim_ws = FALSE,
                            .name_repair = "unique")
  q_rows <- which(vapply(seq_len(nrow(raw)), function(i) {
    v <- unlist(raw[i, , drop = FALSE], use.names = FALSE)
    sum(stringr::str_detect(v, "^Q[1-4]$"), na.rm = TRUE) >= 8L
  }, logical(1)))
  stopifnot(length(q_rows) == 1L)
  q_row <- q_rows[1]
  q_cols <- which(stringr::str_detect(
    unlist(raw[q_row, , drop = FALSE], use.names = FALSE), "^Q[1-4]$"))
  q_text <- unlist(raw[q_row, q_cols, drop = FALSE], use.names = FALSE)
  year_text <- unlist(raw[q_row - 1L, q_cols, drop = FALSE], use.names = FALSE)
  years <- tidyr::fill(tibble::tibble(year = suppressWarnings(as.integer(year_text))),
                       year, .direction = "down")$year
  quarters <- as.integer(stringr::str_remove(q_text, "Q"))
  stopifnot(length(q_cols) > 4L, !anyNA(years), all(quarters %in% 1:4))
  ds <- as.Date(sprintf("%04d-%02d-01", years, 1L + (quarters - 1L) * 3L))
  stopifnot(!anyDuplicated(ds), all(diff(ds) > 0))

  body <- seq.int(q_row + 1L, nrow(raw))
  source_rows <- tibble::tibble(
    row = body,
    line = suppressWarnings(as.integer(raw[[1]][body])),
    name = stringr::str_trim(raw[[2]][body])
  ) |>
    dplyr::filter(!is.na(line))
  stopifnot(!anyDuplicated(source_rows$line))
  matching_rows <- match(industry_meta$line, source_rows$line)
  stopifnot(!anyNA(matching_rows), 1L %in% source_rows$line)

  # Matrix constructed in column-major order, with one source row per line.
  full_values <- matrix(
    suppressWarnings(as.numeric(unlist(
      raw[source_rows$row, q_cols, drop = FALSE], use.names = FALSE))),
    nrow = nrow(source_rows), ncol = length(ds)
  )
  leaf_values <- full_values[matching_rows, , drop = FALSE]
  official <- full_values[match(1L, source_rows$line), ]
  stopifnot(all(is.finite(leaf_values)), all(leaf_values > 0),
            all(is.finite(official)), all(official > 0))

  leaf_long <- tidyr::expand_grid(industry_id = industry_meta$industry_id,
                                  date_id = seq_along(ds)) |>
    dplyr::mutate(date = ds[date_id],
                  value = leaf_values[cbind(industry_id, date_id)]) |>
    dplyr::left_join(industry_meta, by = "industry_id") |>
    dplyr::select(date, industry_id, line, industry, broad_group, value)
  list(dates = ds, leaf = leaf_long, official = official,
       leaf_values = leaf_values)
}

src_go <- read_bea_series(files[["nominal"]])
src_qty <- read_bea_series(files[["quantity"]])
src_price <- read_bea_series(files[["price"]])
stopifnot(identical(src_go$dates, src_qty$dates),
          identical(src_go$dates, src_price$dates))
dates <- src_go$dates
value_matrix <- src_go$leaf_values

industry <- src_go$leaf |>
  dplyr::rename(go = value)

# Nominal additivity is checked against the published total but we use the
# leaf sum as analytical denominator so contributions add *exactly*.
aggregate <- industry |>
  dplyr::group_by(date) |>
  dplyr::summarise(go = sum(go), .groups = "drop") |>
  dplyr::arrange(date) |>
  dplyr::mutate(
    official_go = src_go$official,
    reconciliation_bn = go - official_go,
    go_lag1 = dplyr::lag(go),
    go_lag4 = dplyr::lag(go, 4),
    growth_qoq = go / go_lag1 - 1,
    growth_yoy = go / go_lag4 - 1
  )
industry <- industry |>
  dplyr::left_join(aggregate |>
                     dplyr::select(date, total_go = go, total_lag1 = go_lag1,
                                   total_lag4 = go_lag4), by = "date") |>
  dplyr::group_by(industry_id) |>
  dplyr::arrange(date, .by_group = TRUE) |>
  dplyr::mutate(
    go_lag1 = dplyr::lag(go),
    go_lag4 = dplyr::lag(go, 4),
    growth_qoq = go / go_lag1 - 1,
    growth_yoy = go / go_lag4 - 1,
    weight_qoq = go_lag1 / total_lag1,
    weight_yoy = go_lag4 / total_lag4,
    contrib_qoq_pp = 100 * (go - go_lag1) / total_lag1,
    contrib_yoy_pp = 100 * (go - go_lag4) / total_lag4
  ) |>
  dplyr::ungroup()

qoq_check <- industry |>
  dplyr::group_by(date) |>
  dplyr::summarise(sum_contributions_pp = sum(contrib_qoq_pp), .groups = "drop") |>
  dplyr::left_join(aggregate |> dplyr::select(date, growth_qoq), by = "date") |>
  dplyr::filter(!is.na(growth_qoq)) |>
  dplyr::mutate(diff_pp = sum_contributions_pp - 100 * growth_qoq)
yoy_check <- industry |>
  dplyr::group_by(date) |>
  dplyr::summarise(sum_contributions_pp = sum(contrib_yoy_pp), .groups = "drop") |>
  dplyr::left_join(aggregate |> dplyr::select(date, growth_yoy), by = "date") |>
  dplyr::filter(!is.na(growth_yoy)) |>
  dplyr::mutate(diff_pp = sum_contributions_pp - 100 * growth_yoy)
stopifnot(max(abs(qoq_check$diff_pp)) < 1e-8,
          max(abs(yoy_check$diff_pp)) < 1e-8)

latest_date <- max(dates)
latest_label <- format_q(latest_date)
latest_aggregate <- dplyr::filter(aggregate, date == latest_date)
latest_industry <- dplyr::filter(industry, date == latest_date)

The source tables agree on the same line numbers and quarters. The analytical sample contains 71 industries in 86 quarters, from 2005 Q1 through 2026 Q2. They belong to 15 broad BEA groups. The largest absolute difference between the published aggregate and the sum of the 71 rounded industries is $0.7 billion. Both contribution identities pass their numerical checks.

Show R code
knitr::kable(tibble::tibble(
  Check = c("Leaf industries", "Broad industry groups", "Quarterly observations",
            "Missing industry-level GO values", "Maximum absolute level reconciliation ($bn)",
            "Maximum absolute QoQ contribution residual (pp)",
            "Maximum absolute YoY contribution residual (pp)"),
  Result = c(nrow(industry_meta), length(broad_groups), length(dates),
             sum(is.na(value_matrix)),
             sprintf("%.3f", max(abs(aggregate$reconciliation_bn))),
             sprintf("%.10f", max(abs(qoq_check$diff_pp))),
             sprintf("%.10f", max(abs(yoy_check$diff_pp))))
))
Source-data and contribution-reconciliation checks
Check Result
Leaf industries 71
Broad industry groups 15
Quarterly observations 86
Missing industry-level GO values 0
Maximum absolute level reconciliation ($bn) 0.700
Maximum absolute QoQ contribution residual (pp) 0.0000000000
Maximum absolute YoY contribution residual (pp) 0.0000000000

Part I: Nominal gross output

1. Aggregate gross output

We begin with the level of total gross output and its growth. The level is measured in trillions of nominal dollars at an annual rate; the growth rates below are not annualized. Comparing QoQ and YoY helps distinguish near-term momentum from changes over a full year.

Show R code
ggplot(aggregate, aes(x = date, y = go / 1000)) +
  geom_line(color = neutral_col, linewidth = 1.1) +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y", expand = expansion(mult = c(.01, .01))) +
  scale_y_continuous(labels = scales::label_number(accuracy = 1)) +
  labs(title = "Total gross output", subtitle = "Sum of the 71 leaf industries",
       x = NULL, y = "Trillions of dollars (SAAR)")

Total nominal gross output, seasonally adjusted at annual rates
Show R code
aggregate |>
  dplyr::select(date, growth_qoq, growth_yoy) |>
  tidyr::pivot_longer(-date, names_to = "comparison", values_to = "growth") |>
  dplyr::mutate(comparison = factor(comparison,
                                   levels = c("growth_qoq", "growth_yoy"),
                                   labels = c("QoQ: nonannualized", "YoY"))) |>
  ggplot(aes(date, 100 * growth)) +
  geom_hline(yintercept = 0, color = "grey65") +
  geom_line(color = neutral_col, linewidth = .9) +
  facet_wrap(~comparison, ncol = 1, scales = "free_y") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(title = "Gross output growth", x = NULL, y = "Percent") +
  theme(strip.text = element_text(face = "bold"))

Quarterly and year-over-year growth in total nominal gross output

Reading the latest quarter. Total nominal gross output is $56.15 trillion SAAR in 2026 Q2; its QoQ growth is 2.97%, compared with 7.19% YoY. Since the data are nominal, these movements combine price and quantity changes.

2. The 71-industry snapshot

A comparison of industry levels shows where nominal gross output is concentrated. The chart deliberately retains the source industry order, rather than ranking industries by size; long charts are intentional so labels remain legible. Some names repeat in the hierarchy (for example, government enterprises), so the source line number identifies each industry uniquely.

Show R code
latest_industry |>
  dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order)) |>
  ggplot(aes(industry_axis, go / 1000)) +
  geom_col(fill = neutral_col, width = .76) +
  coord_flip() +
  scale_x_discrete(labels = industry_axis_labels) +
  scale_y_continuous(labels = scales::label_number(accuracy = .01)) +
  labs(title = paste("Industry gross output:", latest_label),
       subtitle = "Original BEA sequence; nominal dollars at annual rates",
       x = NULL, y = "Trillions of dollars (SAAR)") +
  theme(axis.text.y = element_text(size = 8.3),
        panel.grid.major.y = element_blank())

Nominal gross output for every leaf industry, in original BEA order

Industry size does not tell us whether the industry is currently expanding. The next view puts all 71 QoQ growth rates on a common axis without changing their order. The direction of growth is indicated by color rather than rank.

Show R code
latest_industry |>
  dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order),
                direction = ifelse(growth_qoq >= 0, "Expansion", "Contraction")) |>
  ggplot(aes(industry_axis, 100 * growth_qoq, fill = direction)) +
  geom_col(width = .76) +
  geom_hline(yintercept = 0, color = "grey30", linewidth = .25) +
  coord_flip() +
  scale_x_discrete(labels = industry_axis_labels) +
  scale_fill_manual(values = c(Expansion = positive_col, Contraction = negative_col)) +
  labs(title = paste("Industry QoQ growth:", latest_label),
       subtitle = "Nonannualized percentage change; original BEA sequence",
       x = NULL, y = "QoQ growth (%)", fill = NULL) +
  theme(axis.text.y = element_text(size = 8.3),
        panel.grid.major.y = element_blank())

QoQ growth by leaf industry, retaining the source order

Interpretation. The first chart emphasizes the size of each industry; the second emphasizes current momentum, giving small industries the same visual prominence as large ones. A separate, weighted breadth measure is therefore important.

3. Growth breadth

Breadth asks whether aggregate growth is widespread or narrowly based. We calculate these indicators independently for QoQ and YoY changes, using exactly the same 71 leaf industries in every quarter:

  • Equal-weighted breadth: share of industries with positive growth.
  • Output-weighted breadth: share of prior-period gross output produced by industries with positive growth. The weights are lagged one quarter for QoQ and four quarters for YoY.
  • Net breadth: share expanding minus share contracting, ranging from −100% to +100% (flat industries contribute zero to either side).

The output-weighted measure answers a different question from equal-weighted breadth: how much of the economy, rather than how many industries, is expanding?

Show R code
breadth_for <- function(df, growth_col, weight_col, contribution_col, horizon) {
  df |>
    dplyr::filter(!is.na(.data[[growth_col]]), !is.na(.data[[weight_col]])) |>
    dplyr::group_by(date) |>
    dplyr::summarise(
      horizon = horizon,
      n = dplyr::n(),
      n_up = sum(.data[[growth_col]] > 0),
      n_down = sum(.data[[growth_col]] < 0),
      n_flat = sum(.data[[growth_col]] == 0),
      breadth = mean(.data[[growth_col]] > 0),
      weighted_breadth = sum(.data[[weight_col]] * (.data[[growth_col]] > 0)) /
                         sum(.data[[weight_col]]),
      net_breadth = mean(.data[[growth_col]] > 0) - mean(.data[[growth_col]] < 0),
      median_growth_pp = 100 * stats::median(.data[[growth_col]]),
      positive_median_pp = if (any(.data[[growth_col]] > 0))
        100 * stats::median(.data[[growth_col]][.data[[growth_col]] > 0]) else NA_real_,
      negative_median_pp = if (any(.data[[growth_col]] < 0))
        100 * stats::median(.data[[growth_col]][.data[[growth_col]] < 0]) else NA_real_,
      sd_growth_pp = 100 * stats::sd(.data[[growth_col]]),
      iqr_growth_pp = 100 * stats::IQR(.data[[growth_col]]),
      weighted_sd_growth_pp = {
        r <- .data[[growth_col]]
        w <- .data[[weight_col]]
        mu <- stats::weighted.mean(r, w)
        100 * sqrt(stats::weighted.mean((r - mu)^2, w))
      },
      sd_contrib_pp = stats::sd(.data[[contribution_col]]),
      gross_positive_contrib_pp = sum(pmax(.data[[contribution_col]], 0)),
      gross_negative_contrib_pp = sum(pmin(.data[[contribution_col]], 0)),
      top5_share_of_positive = {
        positive <- .data[[contribution_col]][.data[[contribution_col]] > 0]
        if (length(positive)) sum(sort(positive, decreasing = TRUE)[
          seq_len(min(5L, length(positive)))]) / sum(positive) else NA_real_
      },
      .groups = "drop"
    )
}

breadth <- dplyr::bind_rows(
  breadth_for(industry, "growth_qoq", "weight_qoq", "contrib_qoq_pp", "QoQ"),
  breadth_for(industry, "growth_yoy", "weight_yoy", "contrib_yoy_pp", "YoY")
) |>
  dplyr::mutate(horizon = factor(horizon, levels = c("QoQ", "YoY")))
latest_breadth_qoq <- dplyr::filter(breadth, horizon == "QoQ", date == latest_date)
latest_breadth_yoy <- dplyr::filter(breadth, horizon == "YoY", date == latest_date)
Show R code
breadth |>
  dplyr::select(date, horizon, breadth, weighted_breadth) |>
  tidyr::pivot_longer(c(breadth, weighted_breadth),
                      names_to = "measure", values_to = "share") |>
  dplyr::mutate(measure = factor(measure,
                                levels = c("breadth", "weighted_breadth"),
                                labels = c("Equal-weighted", "GO-weighted"))) |>
  ggplot(aes(date, 100 * share, color = measure)) +
  geom_hline(yintercept = 50, linetype = "dotted", color = "grey55") +
  geom_line(linewidth = .85) +
  facet_wrap(~horizon, ncol = 1) +
  scale_color_manual(values = c("Equal-weighted" = neutral_col,
                                "GO-weighted" = positive_col)) +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 25)) +
  labs(title = "How widespread is expansion?", x = NULL,
       y = "Share with positive growth (%)", color = NULL)

Equal-weighted and lagged-GO-weighted shares of industries expanding
Show R code
ggplot(breadth, aes(date, 100 * net_breadth)) +
  geom_hline(yintercept = 0, color = "grey55") +
  geom_line(color = neutral_col, linewidth = .9) +
  facet_wrap(~horizon, ncol = 1) +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(title = "Net breadth", subtitle = "Up share minus down share",
       x = NULL, y = "Net breadth (percentage points of industries)")

Net breadth: percentage expanding less percentage contracting

Latest reading. In 2026 Q2, 64 of 71 industries expanded QoQ and 7 contracted. Equal-weighted breadth was 90.1%, compared with output-weighted breadth of 94.4%. YoY, the corresponding breadth measures were 91.5% and 93.1%. Differences between these readings can identify whether expansion is concentrated in large or small industries.

4. The growth distribution

Breadth records the sign of growth but not its magnitude. For example, 60 growing industries might rise very little while a small number of shrinking industries experience steep declines. To complement breadth, examine the unconditional median alongside the conditional median among growing and shrinking industries.

Show R code
breadth |>
  dplyr::select(date, horizon, median_growth_pp, positive_median_pp, negative_median_pp) |>
  tidyr::pivot_longer(dplyr::ends_with("_pp"),
                      names_to = "measure", values_to = "growth_pp") |>
  dplyr::mutate(measure = factor(measure,
    levels = c("positive_median_pp", "median_growth_pp", "negative_median_pp"),
    labels = c("Median of expanding industries", "Median of all industries",
               "Median of contracting industries"))) |>
  ggplot(aes(date, growth_pp, color = measure)) +
  geom_hline(yintercept = 0, color = "grey65") +
  geom_line(linewidth = .8, na.rm = TRUE) +
  facet_wrap(~horizon, ncol = 1, scales = "free_y") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  scale_color_manual(values = c("Median of expanding industries" = positive_col,
                                "Median of all industries" = neutral_col,
                                "Median of contracting industries" = negative_col)) +
  labs(title = "How large are industry gains and losses?", x = NULL,
       y = "Industry growth (%)", color = NULL)

Median growth across industries and within the positive- and negative-growth subsets

The latest-quarter distribution provides a direct snapshot. It treats all industries equally and therefore complements the output-weighted statistics.

Show R code
ggplot(latest_industry, aes(100 * growth_qoq)) +
  geom_histogram(bins = 22, fill = neutral_col, color = "white") +
  geom_vline(xintercept = 0, linetype = "dashed", color = negative_col) +
  geom_vline(xintercept = latest_breadth_qoq$median_growth_pp,
             linetype = "dotted", color = positive_col, linewidth = .9) +
  labs(title = paste("Distribution of industry growth:", latest_label),
       subtitle = "Dashed line = zero; dotted line = cross-sectional median",
       x = "Nonannualized QoQ growth (%)", y = "Number of industries")

Cross-sectional distribution of industry QoQ growth in the latest quarter

Interpretation. The median of the expanding industries was 2.02%, versus -0.46% among contracting industries. These conditional medians need not combine into aggregate growth; the size of each industry is ignored here.

5. Contributions to aggregate growth

Breadth asks how many industries are growing; contributions ask how much each industry changes total growth. For QoQ contributions, with \(GO_t=\sum_i GO_{i,t}\):

\[ \underbrace{100\left(\frac{GO_t}{GO_{t-1}}-1\right)}_{\text{Aggregate QoQ growth (\%)}} =\sum_i \underbrace{100\frac{GO_{i,t}-GO_{i,t-1}}{GO_{t-1}}}_{\text{Industry contribution (pp)}}. \]

Equivalently, each contribution is the industry’s lagged output weight multiplied by its own QoQ growth rate (measured in percent). Contributions can be positive or negative. The sum equals aggregate growth exactly when the total is computed from the same leaf series. The analogous YoY calculation compares each series with four quarters earlier.

Broad groups: stacked contributions over three years

The 71 industries are aggregated to 15 non-overlapping broad BEA groups, with the groups ordered as they appear in the workbook. The stacked bars separate positive and negative segments around zero, so the net contribution is the positive stack plus the negative stack, not necessarily the top of the positive stack. The black point marks the actual net aggregate QoQ growth.

Show R code
contrib_group <- industry |>
  dplyr::group_by(date, broad_group) |>
  dplyr::summarise(contrib_qoq_pp = sum(contrib_qoq_pp),
                   contrib_yoy_pp = sum(contrib_yoy_pp), .groups = "drop") |>
  dplyr::mutate(broad_group = factor(broad_group, levels = broad_groups))
recent_dates <- tail(dates, 12)
recent_group <- contrib_group |>
  dplyr::filter(date %in% recent_dates) |>
  dplyr::mutate(quarter_label = factor(format_q(date), levels = format_q(recent_dates)))
recent_aggregate <- aggregate |>
  dplyr::filter(date %in% recent_dates) |>
  dplyr::mutate(quarter_label = factor(format_q(date), levels = format_q(recent_dates)))

# The broad-group contribution identity must also hold.
check_groups <- contrib_group |>
  dplyr::filter(!is.na(contrib_qoq_pp)) |>
  dplyr::group_by(date) |>
  dplyr::summarise(group_total_pp = sum(contrib_qoq_pp), .groups = "drop") |>
  dplyr::left_join(aggregate |> dplyr::select(date, growth_qoq), by = "date")
stopifnot(max(abs(check_groups$group_total_pp - 100 * check_groups$growth_qoq)) < 1e-8)

palette_groups <- stats::setNames(
  grDevices::hcl.colors(length(broad_groups), palette = "Dark 3"), broad_groups
)
Show R code
ggplot(recent_group, aes(quarter_label, contrib_qoq_pp, fill = broad_group)) +
  geom_col(width = .72) +
  geom_hline(yintercept = 0, color = "grey35", linewidth = .25) +
  geom_point(data = recent_aggregate, aes(x = quarter_label, y = 100 * growth_qoq),
             inherit.aes = FALSE, color = "black", size = 2.2, shape = 21,
             fill = "white", stroke = .8) +
  scale_fill_manual(values = palette_groups, drop = FALSE) +
  labs(title = "Industry-group contributions to aggregate growth",
       subtitle = "Nominal, nonannualized QoQ; white dots = net aggregate growth",
       x = NULL, y = "Contribution to QoQ growth (percentage points)",
       fill = "BEA industry group") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        legend.position = "bottom", legend.text = element_text(size = 8),
        legend.key.height = grid::unit(0.3, "cm")) +
  guides(fill = guide_legend(ncol = 3, byrow = TRUE))

Contributions to QoQ total growth over the most recent 12 quarters, by BEA broad group

Interpretation. Contributions combine each group’s size with its growth. A large group can contribute meaningfully even with modest percentage growth; a small group can experience extreme growth without moving the aggregate much. Positive and negative contributions partially offset; the white dot gives the net result in each quarter.

All 71 industries: current contributions

The following detail chart retains every BEA leaf industry in its original order. The metric is contribution in percentage points, not the industry’s own percentage growth. Thus a small bar is not evidence that the industry’s own growth was weak; it may be small in economic weight.

Show R code
latest_industry |>
  dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order),
                direction = ifelse(contrib_qoq_pp >= 0, "Positive", "Negative")) |>
  ggplot(aes(industry_axis, contrib_qoq_pp, fill = direction)) +
  geom_col(width = .76) +
  geom_hline(yintercept = 0, color = "grey30", linewidth = .25) +
  coord_flip() +
  scale_x_discrete(labels = industry_axis_labels) +
  scale_fill_manual(values = c(Positive = positive_col, Negative = negative_col)) +
  labs(title = paste("Contributions by leaf industry:", latest_label),
       subtitle = "Original BEA order; industry changes as a share of lagged total GO",
       x = NULL, y = "QoQ contribution (percentage points)", fill = NULL) +
  theme(axis.text.y = element_text(size = 8.3),
        panel.grid.major.y = element_blank())

Latest-quarter contribution to aggregate QoQ growth for all 71 industries

All 71 industries: three-year contribution heatmap

This heatmap preserves industry order vertically and shows the last 12 quarters horizontally. It is useful for identifying persistent positive or negative contributors, as well as abrupt reversals. Color intensity measures the magnitude of the percentage-point contribution, not the industry’s growth rate.

Show R code
recent_detail <- industry |>
  dplyr::filter(date %in% recent_dates) |>
  dplyr::mutate(
    industry_axis = factor(industry_id, levels = industry_order),
    quarter_label = factor(format_q(date), levels = format_q(recent_dates))
  )
heat_limit <- max(abs(recent_detail$contrib_qoq_pp), na.rm = TRUE)

ggplot(recent_detail, aes(quarter_label, industry_axis, fill = contrib_qoq_pp)) +
  geom_tile(color = "white", linewidth = .15) +
  scale_y_discrete(labels = industry_axis_labels) +
  scale_fill_gradient2(low = negative_col, mid = "#F6F6F4", high = neutral_col,
                       midpoint = 0, limits = c(-heat_limit, heat_limit)) +
  labs(title = "Contribution history across the 71 industries",
       subtitle = "Signed percentage points of aggregate QoQ growth",
       x = NULL, y = NULL, fill = "Contribution\n(pp)") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        axis.text.y = element_text(size = 8.2),
        panel.grid = element_blank(), legend.position = "right")

QoQ growth contribution by industry and quarter, with unchanged BEA industry order

6. Dispersion of industry growth

Dispersion describes how differently the industries are performing, rather than whether the typical industry is expanding. Here we compare:

  • Standard deviation: how spread out the 71 growth rates are, in percentage points; especially sensitive to extreme observations.
  • Interquartile range (IQR): 75th percentile minus 25th percentile; more robust to outliers.
  • GO-weighted standard deviation: uses prior-period gross output weights, emphasizing the behavior of larger industries.

High dispersion can occur in an expansion or in a contraction, and it does not by itself imply weak aggregate activity. All measures use nominal growth, so sector-specific price changes can be a major source of cross-sectional variation.

Show R code
breadth |>
  dplyr::select(date, horizon, sd_growth_pp, iqr_growth_pp, weighted_sd_growth_pp) |>
  tidyr::pivot_longer(dplyr::ends_with("_pp"),
                      names_to = "measure", values_to = "dispersion") |>
  dplyr::mutate(measure = factor(measure,
    levels = c("sd_growth_pp", "iqr_growth_pp", "weighted_sd_growth_pp"),
    labels = c("Standard deviation", "Interquartile range", "GO-weighted SD"))) |>
  ggplot(aes(date, dispersion, color = measure)) +
  geom_line(linewidth = .8) +
  facet_wrap(~horizon, ncol = 1, scales = "free_y") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  scale_color_manual(values = c("Standard deviation" = neutral_col,
                                "Interquartile range" = positive_col,
                                "GO-weighted SD" = "#A36D32")) +
  labs(title = "Dispersion in industry growth rates", x = NULL,
       y = "Percentage points", color = NULL)

Equal-weighted and weighted cross-sectional dispersion of industry growth

In 2026 Q2, the QoQ cross-sectional standard deviation was 4.67 pp; the IQR was 2.83 pp, and the lagged-GO-weighted standard deviation was 4.01 pp. A large difference between the equal-weighted and GO-weighted SDs indicates that the greatest volatility is concentrated among either smaller or larger industries.

7. Dispersion and concentration of contributions

Growth-rate dispersion treats industries equally, but contribution dispersion incorporates industry size. The standard deviation of individual percentage-point contributions is therefore a distinct concept: dispersion in how much industries matter to aggregate change.

A companion concentration measure asks: What fraction of all positive contributions came from the five largest positive contributors? This requires ranking contributions within the calculation, but it does not reorder any industry chart. A high value means aggregate expansion relies heavily on a small subset of industries. The denominator is the sum of positive contributions, not net aggregate growth, because negative contributions can offset positive ones.

Show R code
ggplot(breadth, aes(date, sd_contrib_pp)) +
  geom_line(color = neutral_col, linewidth = .9) +
  facet_wrap(~horizon, ncol = 1, scales = "free_y") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(title = "Dispersion of industry growth contributions",
       subtitle = "Standard deviation across the 71 industry contributions",
       x = NULL, y = "Percentage points of total GO growth")

Cross-sectional standard deviation of individual contributions to aggregate growth
Show R code
ggplot(breadth, aes(date, 100 * top5_share_of_positive)) +
  geom_line(color = neutral_col, linewidth = .9, na.rm = TRUE) +
  facet_wrap(~horizon, ncol = 1) +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  scale_y_continuous(limits = c(0, 100), labels = scales::label_number(suffix = "%")) +
  labs(title = "Concentration among positive contributors",
       subtitle = "Top five positive contributions / sum of all positive contributions",
       x = NULL, y = "Share (%)")

Share of positive aggregate growth contributions provided by the five largest positive contributors

Interpretation. The top five positive contributors accounted for 41.6% of all positive QoQ contributions in 2026 Q2. This is a concentration statistic, not a direct measure of whether aggregate growth was strong or weak. It should be interpreted alongside breadth and the total positive and negative contributions.

8. Latest-quarter summary

The table gathers the principal measurements into one place. It is intended as a starting dashboard rather than a model of the determinants of industry growth.

Show R code
last_rows <- breadth |>
  dplyr::filter(date == latest_date) |>
  dplyr::arrange(horizon)
summary_table <- tibble::tibble(
  Metric = c("Aggregate growth", "Expanding industries", "Equal-weighted breadth",
             "GO-weighted breadth", "Net breadth", "Median industry growth",
             "Industry-growth SD", "Industry-growth IQR", "Weighted industry-growth SD",
             "Contribution SD", "Top-five positive-contribution share"),
  QoQ = c(fmt_pct(latest_aggregate$growth_qoq, 2),
          sprintf("%d / %d", latest_breadth_qoq$n_up, latest_breadth_qoq$n),
          fmt_pct(latest_breadth_qoq$breadth),
          fmt_pct(latest_breadth_qoq$weighted_breadth),
          sprintf("%+.1f pp", 100 * latest_breadth_qoq$net_breadth),
          sprintf("%.2f%%", latest_breadth_qoq$median_growth_pp),
          sprintf("%.2f pp", latest_breadth_qoq$sd_growth_pp),
          sprintf("%.2f pp", latest_breadth_qoq$iqr_growth_pp),
          sprintf("%.2f pp", latest_breadth_qoq$weighted_sd_growth_pp),
          sprintf("%.3f pp", latest_breadth_qoq$sd_contrib_pp),
          fmt_pct(latest_breadth_qoq$top5_share_of_positive)),
  YoY = c(fmt_pct(latest_aggregate$growth_yoy, 2),
          sprintf("%d / %d", latest_breadth_yoy$n_up, latest_breadth_yoy$n),
          fmt_pct(latest_breadth_yoy$breadth),
          fmt_pct(latest_breadth_yoy$weighted_breadth),
          sprintf("%+.1f pp", 100 * latest_breadth_yoy$net_breadth),
          sprintf("%.2f%%", latest_breadth_yoy$median_growth_pp),
          sprintf("%.2f pp", latest_breadth_yoy$sd_growth_pp),
          sprintf("%.2f pp", latest_breadth_yoy$iqr_growth_pp),
          sprintf("%.2f pp", latest_breadth_yoy$weighted_sd_growth_pp),
          sprintf("%.3f pp", latest_breadth_yoy$sd_contrib_pp),
          fmt_pct(latest_breadth_yoy$top5_share_of_positive))
)
knitr::kable(summary_table, caption = paste("As of", latest_label))
Latest-quarter aggregate, breadth, dispersion and concentration statistics
Metric QoQ YoY
Aggregate growth 2.97% 7.19%
Expanding industries 64 / 71 65 / 71
Equal-weighted breadth 90.1% 91.5%
GO-weighted breadth 94.4% 93.1%
Net breadth +80.3 pp +83.1 pp
Median industry growth 1.91% 4.95%
Industry-growth SD 4.67 pp 7.81 pp
Industry-growth IQR 2.83 pp 7.51 pp
Weighted industry-growth SD 4.01 pp 6.81 pp
Contribution SD 0.069 pp 0.152 pp
Top-five positive-contribution share 41.6% 35.6%

Part II: Gross-output quantity indexes

Unlike nominal gross output, chain-type quantity indexes describe real production volume relative to 2017 (= 100). An index value of 120 means that the quantity measure is 20% above its own 2017 base. Index levels cannot be added across industries, and a high industry index does not mean the industry is economically larger.

The next two parts use the same leaf-industry taxonomy, the same industry order, and the same QoQ/YoY breadth and dispersion formulas as the nominal section. For weighting, we reuse lagged nominal GO shares from GrossOutput.xlsx, not the index levels themselves.

Show R code
# Nonadditive chain indexes: lagged-NOMINAL-weighted industry growth is an
# approximation to aggregate index growth, not an exact BEA contribution.
build_index_panel <- function(source) {
  idx <- source$leaf |>
    dplyr::rename(index = value) |>
    dplyr::left_join(
      industry |>
        dplyr::select(date, industry_id, weight_qoq, weight_yoy),
      by = c("date", "industry_id")
    ) |>
    dplyr::group_by(industry_id) |>
    dplyr::arrange(date, .by_group = TRUE) |>
    dplyr::mutate(
      index_lag1 = dplyr::lag(index),
      index_lag4 = dplyr::lag(index, 4),
      growth_qoq = index / index_lag1 - 1,
      growth_yoy = index / index_lag4 - 1,
      contrib_qoq_pp = 100 * weight_qoq * growth_qoq,
      contrib_yoy_pp = 100 * weight_yoy * growth_yoy
    ) |>
    dplyr::ungroup()
  agg <- tibble::tibble(date = source$dates, index = source$official) |>
    dplyr::mutate(
      growth_qoq = index / dplyr::lag(index) - 1,
      growth_yoy = index / dplyr::lag(index, 4) - 1
    )

  checks <- idx |>
    dplyr::group_by(date) |>
    dplyr::summarise(
      approx_qoq_pp = sum(contrib_qoq_pp, na.rm = TRUE),
      approx_yoy_pp = sum(contrib_yoy_pp, na.rm = TRUE),
      .groups = "drop"
    ) |>
    dplyr::left_join(agg, by = "date") |>
    dplyr::mutate(
      residual_qoq_pp = 100 * growth_qoq - approx_qoq_pp,
      residual_yoy_pp = 100 * growth_yoy - approx_yoy_pp
    )
  b <- dplyr::bind_rows(
    breadth_for(idx, "growth_qoq", "weight_qoq", "contrib_qoq_pp", "QoQ"),
    breadth_for(idx, "growth_yoy", "weight_yoy", "contrib_yoy_pp", "YoY")
  ) |>
    dplyr::mutate(horizon = factor(horizon, levels = c("QoQ", "YoY")))
  list(industry = idx, aggregate = agg, checks = checks, breadth = b)
}

qty <- build_index_panel(src_qty)
price <- build_index_panel(src_price)
stopifnot(nrow(qty$industry) == 71L * length(dates),
          nrow(price$industry) == 71L * length(dates))

# Shared chart functions ensure the analysis is identical across both indexes.
plot_index_level <- function(panel, label) {
  ggplot(panel$aggregate, aes(date, index)) +
    geom_line(color = neutral_col, linewidth = 1.1) +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    labs(title = paste("Aggregate gross-output", tolower(label), "index"),
         subtitle = "BEA chain-type index; 2017 = 100",
         x = NULL, y = "Index (2017 = 100)")
}
plot_index_growth <- function(panel, label) {
  panel$aggregate |>
    dplyr::select(date, growth_qoq, growth_yoy) |>
    tidyr::pivot_longer(-date, names_to = "horizon", values_to = "growth") |>
    dplyr::mutate(horizon = factor(horizon,
      levels = c("growth_qoq", "growth_yoy"),
      labels = c("QoQ: nonannualized", "YoY"))) |>
    ggplot(aes(date, 100 * growth)) +
    geom_hline(yintercept = 0, color = "grey60") +
    geom_line(color = neutral_col, linewidth = .9) +
    facet_wrap(~ horizon, ncol = 1, scales = "free_y") +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    labs(title = paste("Aggregate", tolower(label), "growth"),
         x = NULL, y = "Percent")
}
plot_index_industries <- function(panel, label, metric = "level") {
  df <- panel$industry |>
    dplyr::filter(date == latest_date) |>
    dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order))
  if (metric == "level") {
    ggplot(df, aes(industry_axis, index)) +
      geom_col(fill = neutral_col, width = .76) +
      coord_flip() +
      scale_x_discrete(labels = industry_axis_labels) +
      labs(title = paste(label, "index by industry:", latest_label),
           subtitle = "2017 = 100; original BEA order (not industry size)",
           x = NULL, y = "2017 = 100") +
      theme(axis.text.y = element_text(size = 8.3),
            panel.grid.major.y = element_blank())
  } else {
    df <- df |>
      dplyr::mutate(direction = ifelse(growth_qoq >= 0, "Expansion", "Contraction"))
    ggplot(df, aes(industry_axis, 100 * growth_qoq, fill = direction)) +
      geom_col(width = .76) +
      geom_hline(yintercept = 0, color = "grey35", linewidth = .25) +
      coord_flip() +
      scale_x_discrete(labels = industry_axis_labels) +
      scale_fill_manual(values = c(Expansion = positive_col,
                                   Contraction = negative_col)) +
      labs(title = paste(label, "QoQ growth by industry:", latest_label),
           subtitle = "Nonannualized; original BEA order",
           x = NULL, y = "QoQ growth (%)", fill = NULL) +
      theme(axis.text.y = element_text(size = 8.3),
            panel.grid.major.y = element_blank())
  }
}
plot_index_breadth <- function(panel, label) {
  panel$breadth |>
    dplyr::select(date, horizon, breadth, weighted_breadth) |>
    tidyr::pivot_longer(c(breadth, weighted_breadth),
                        names_to = "measure", values_to = "share") |>
    dplyr::mutate(measure = factor(measure,
      levels = c("breadth", "weighted_breadth"),
      labels = c("Equal-weighted", "Nominal-GO-weighted"))) |>
    ggplot(aes(date, 100 * share, color = measure)) +
    geom_hline(yintercept = 50, linetype = "dotted", color = "grey55") +
    geom_line(linewidth = .8) +
    facet_wrap(~horizon, ncol = 1) +
    scale_color_manual(values = c("Equal-weighted" = neutral_col,
                                  "Nominal-GO-weighted" = positive_col)) +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 25)) +
    labs(title = paste(label, "growth breadth"), x = NULL,
         y = "Share of industries with positive growth (%)", color = NULL)
}
plot_index_net_breadth <- function(panel, label) {
  ggplot(panel$breadth, aes(date, 100 * net_breadth)) +
    geom_hline(yintercept = 0, color = "grey55") +
    geom_line(color = neutral_col, linewidth = .9) +
    facet_wrap(~horizon, ncol = 1) +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    labs(title = paste(label, "net breadth"),
         subtitle = "Share expanding minus share contracting",
         x = NULL, y = "Percentage points of industries")
}
plot_index_medians <- function(panel, label) {
  panel$breadth |>
    dplyr::select(date, horizon, median_growth_pp,
                  positive_median_pp, negative_median_pp) |>
    tidyr::pivot_longer(dplyr::ends_with("_pp"),
                        names_to = "series", values_to = "growth_pp") |>
    dplyr::mutate(series = factor(series,
      levels = c("positive_median_pp", "median_growth_pp", "negative_median_pp"),
      labels = c("Median if positive", "Overall median", "Median if negative"))) |>
    ggplot(aes(date, growth_pp, color = series)) +
    geom_hline(yintercept = 0, color = "grey60") +
    geom_line(linewidth = .75) +
    facet_wrap(~horizon, ncol = 1, scales = "free_y") +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    scale_color_manual(values = c("Median if positive" = positive_col,
                                  "Overall median" = neutral_col,
                                  "Median if negative" = negative_col)) +
    labs(title = paste(label, "growth: cross-sectional medians"),
         x = NULL, y = "Industry growth (%)", color = NULL)
}
plot_index_histogram <- function(panel, label) {
  panel$industry |>
    dplyr::filter(date == latest_date) |>
    ggplot(aes(100 * growth_qoq)) +
    geom_histogram(bins = 22, fill = neutral_col, color = "white") +
    geom_vline(xintercept = 0, linetype = "dashed", color = negative_col) +
    labs(title = paste(label, "growth distribution:", latest_label),
         x = "Industry QoQ growth (%)", y = "Number of industries")
}
plot_index_groups <- function(panel, label) {
  grouped <- panel$industry |>
    dplyr::filter(date %in% recent_dates) |>
    dplyr::group_by(date, broad_group) |>
    dplyr::summarise(contrib_qoq_pp = sum(contrib_qoq_pp), .groups = "drop") |>
    dplyr::mutate(
      broad_group = factor(broad_group, levels = broad_groups),
      quarter_label = factor(format_q(date), levels = format_q(recent_dates))
    )
  actual <- panel$aggregate |>
    dplyr::filter(date %in% recent_dates) |>
    dplyr::mutate(quarter_label = factor(format_q(date), levels = format_q(recent_dates)))
  ggplot(grouped, aes(quarter_label, contrib_qoq_pp, fill = broad_group)) +
    geom_col(width = .72) +
    geom_hline(yintercept = 0, color = "grey35", linewidth = .25) +
    geom_point(data = actual,
               aes(x = quarter_label, y = 100 * growth_qoq),
               inherit.aes = FALSE, color = "black", shape = 21,
               fill = "white", stroke = .8, size = 2.2) +
    scale_fill_manual(values = palette_groups, drop = FALSE) +
    labs(title = paste("Approximate", tolower(label), "growth contributions"),
         subtitle = "Lagged nominal-GO weights x industry index growth; white dots = official aggregate growth",
         x = NULL, y = "Approx. QoQ contribution (pp)", fill = "BEA industry group") +
    theme(axis.text.x = element_text(angle = 45, hjust = 1),
          legend.position = "bottom", legend.text = element_text(size = 8),
          legend.key.height = grid::unit(.3, "cm")) +
    guides(fill = guide_legend(ncol = 3, byrow = TRUE))
}
plot_index_detail_contributions <- function(panel, label) {
  panel$industry |>
    dplyr::filter(date == latest_date) |>
    dplyr::mutate(
      industry_axis = factor(industry_id, levels = industry_order),
      direction = ifelse(contrib_qoq_pp >= 0, "Positive", "Negative")
    ) |>
    ggplot(aes(industry_axis, contrib_qoq_pp, fill = direction)) +
    geom_col(width = .76) +
    coord_flip() +
    scale_x_discrete(labels = industry_axis_labels) +
    scale_fill_manual(values = c(Positive = positive_col, Negative = negative_col)) +
    labs(title = paste("Approximate", tolower(label), "contributions:", latest_label),
         subtitle = "Original BEA order; lagged nominal shares x index growth",
         x = NULL, y = "Approx. QoQ contribution (pp)", fill = NULL) +
    theme(axis.text.y = element_text(size = 8.3),
          panel.grid.major.y = element_blank())
}
plot_index_contribution_heatmap <- function(panel, label) {
  df <- panel$industry |>
    dplyr::filter(date %in% recent_dates) |>
    dplyr::mutate(
      industry_axis = factor(industry_id, levels = industry_order),
      quarter_label = factor(format_q(date), levels = format_q(recent_dates))
    )
  lim <- max(abs(df$contrib_qoq_pp), na.rm = TRUE)
  ggplot(df, aes(quarter_label, industry_axis, fill = contrib_qoq_pp)) +
    geom_tile(color = "white", linewidth = .15) +
    scale_y_discrete(labels = industry_axis_labels) +
    scale_fill_gradient2(low = negative_col, mid = "#F6F6F4", high = neutral_col,
                         midpoint = 0, limits = c(-lim, lim)) +
    labs(title = paste(label, "contribution history: 71 industries"),
         subtitle = "Approximate QoQ percentage-point contributions over 12 quarters",
         x = NULL, y = NULL, fill = "Approx. pp") +
    theme(axis.text.x = element_text(angle = 45, hjust = 1),
          axis.text.y = element_text(size = 8.2),
          panel.grid = element_blank(), legend.position = "right")
}
plot_index_dispersion <- function(panel, label) {
  panel$breadth |>
    dplyr::select(date, horizon, sd_growth_pp, iqr_growth_pp,
                  weighted_sd_growth_pp) |>
    tidyr::pivot_longer(dplyr::ends_with("_pp"),
                        names_to = "measure", values_to = "dispersion") |>
    dplyr::mutate(measure = factor(measure,
      levels = c("sd_growth_pp", "iqr_growth_pp", "weighted_sd_growth_pp"),
      labels = c("Standard deviation", "Interquartile range", "Nominal-GO-weighted SD"))) |>
    ggplot(aes(date, dispersion, color = measure)) +
    geom_line(linewidth = .8) +
    facet_wrap(~horizon, ncol = 1, scales = "free_y") +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    scale_color_manual(values = c("Standard deviation" = neutral_col,
                                  "Interquartile range" = positive_col,
                                  "Nominal-GO-weighted SD" = "#A36D32")) +
    labs(title = paste(label, "growth dispersion"), x = NULL,
         y = "Percentage points", color = NULL)
}
plot_index_contribution_dispersion <- function(panel, label) {
  ggplot(panel$breadth, aes(date, sd_contrib_pp)) +
    geom_line(color = neutral_col, linewidth = .9) +
    facet_wrap(~horizon, ncol = 1, scales = "free_y") +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    labs(title = paste(label, ": dispersion of approximate contributions"),
         x = NULL, y = "Approximate percentage points")
}
plot_index_concentration <- function(panel, label) {
  ggplot(panel$breadth, aes(date, 100 * top5_share_of_positive)) +
    geom_line(color = neutral_col, linewidth = .9, na.rm = TRUE) +
    facet_wrap(~horizon, ncol = 1) +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 25)) +
    labs(title = paste(label, "approximate-contribution concentration"),
         subtitle = "Five largest positive contributions / all positive contributions",
         x = NULL, y = "Share (%)")
}
plot_index_reconciliation <- function(panel, label) {
  panel$checks |>
    dplyr::select(date, residual_qoq_pp, residual_yoy_pp) |>
    tidyr::pivot_longer(-date, names_to = "horizon", values_to = "difference_pp") |>
    dplyr::mutate(horizon = factor(horizon,
      levels = c("residual_qoq_pp", "residual_yoy_pp"),
      labels = c("QoQ", "YoY"))) |>
    ggplot(aes(date, difference_pp)) +
    geom_hline(yintercept = 0, color = "grey55") +
    geom_line(color = neutral_col, linewidth = .85) +
    facet_wrap(~horizon, ncol = 1, scales = "free_y") +
    scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
    labs(title = paste(label, "index: approximation residual"),
         subtitle = "Published aggregate growth minus summed nominal-share-weighted industry growth",
         x = NULL, y = "Percentage points")
}
index_summary_table <- function(panel) {
  bq <- dplyr::filter(panel$breadth, date == latest_date, horizon == "QoQ")
  by <- dplyr::filter(panel$breadth, date == latest_date, horizon == "YoY")
  a <- dplyr::filter(panel$aggregate, date == latest_date)
  r <- dplyr::filter(panel$checks, date == latest_date)
  tibble::tibble(
    Metric = c("Aggregate index (2017 = 100)", "Aggregate growth",
      "Industries expanding", "Equal-weighted breadth", "Nominal-GO-weighted breadth",
      "Net breadth", "Median industry growth", "Industry-growth SD", "Industry-growth IQR",
      "Nominal-GO-weighted industry-growth SD", "Approximate contribution SD",
      "Top-five positive approximate-contribution share",
      "Sum of approximate industry contributions", "Published minus approximate growth"),
    QoQ = c(sprintf("%.3f", a$index), fmt_pct(a$growth_qoq, 2),
      sprintf("%d / %d", bq$n_up, bq$n), fmt_pct(bq$breadth),
      fmt_pct(bq$weighted_breadth), sprintf("%+.1f pp", 100 * bq$net_breadth),
      sprintf("%.2f%%", bq$median_growth_pp), sprintf("%.2f pp", bq$sd_growth_pp),
      sprintf("%.2f pp", bq$iqr_growth_pp), sprintf("%.2f pp", bq$weighted_sd_growth_pp),
      sprintf("%.3f pp", bq$sd_contrib_pp), fmt_pct(bq$top5_share_of_positive),
      sprintf("%+.3f pp", r$approx_qoq_pp), sprintf("%+.3f pp", r$residual_qoq_pp)),
    YoY = c(sprintf("%.3f", a$index), fmt_pct(a$growth_yoy, 2),
      sprintf("%d / %d", by$n_up, by$n), fmt_pct(by$breadth),
      fmt_pct(by$weighted_breadth), sprintf("%+.1f pp", 100 * by$net_breadth),
      sprintf("%.2f%%", by$median_growth_pp), sprintf("%.2f pp", by$sd_growth_pp),
      sprintf("%.2f pp", by$iqr_growth_pp), sprintf("%.2f pp", by$weighted_sd_growth_pp),
      sprintf("%.3f pp", by$sd_contrib_pp), fmt_pct(by$top5_share_of_positive),
      sprintf("%+.3f pp", r$approx_yoy_pp), sprintf("%+.3f pp", r$residual_yoy_pp))
  )
}

1. Aggregate quantity index and growth

The aggregate index describes the evolution of real gross-output quantities, while nonannualized QoQ and YoY changes show near-term and annual momentum.

Show R code
plot_index_level(qty, "Quantity")

Aggregate BEA gross-output quantity index
Show R code
plot_index_growth(qty, "Quantity")

Aggregate gross-output quantity growth: QoQ and YoY

Latest reading. In 2026 Q2, the published aggregate quantity index grew 1.23% QoQ and 2.60% YoY.

2. All 71 industries: current index and growth

The level chart retains the original BEA order, but an index level is not a measure of an industry’s size: every index has the same 2017 base. The QoQ chart is the more directly comparable measure of current industry momentum.

Show R code
plot_index_industries(qty, "Quantity", "level")

Gross-output quantity indexes for all 71 industries, original BEA order
Show R code
plot_index_industries(qty, "Quantity", "growth")

QoQ gross-output quantity growth by industry, original BEA order

3. Growth breadth

Equal-weighted breadth counts industries with positive index growth. Weighted breadth sums the prior-period nominal GO shares of those growing industries. Net breadth subtracts the share contracting from the share growing. Both QoQ and YoY horizons are shown.

Show R code
plot_index_breadth(qty, "Quantity")

Equal-weighted and nominal-output-weighted quantity growth breadth
Show R code
plot_index_net_breadth(qty, "Quantity")

Net breadth of quantity growth

Latest breadth. 49 of 71 industries grew QoQ, and output-weighted breadth was 78.3%.

4. Cross-sectional growth distribution

Breadth alone loses magnitude information. Conditional medians describe typical growth among growing industries and among shrinking industries; the histogram shows whether the latest distribution is skewed or dominated by outliers.

Show R code
plot_index_medians(qty, "Quantity")

Conditional and unconditional median quantity growth
Show R code
plot_index_histogram(qty, "Quantity")

Latest-quarter cross-sectional quantity growth distribution

5. Approximate industry contributions (last three years)

For an industry \(i\), we compute an approximate QoQ influence in percentage points:

\[ \widetilde{c}_{i,t}^{quantity} =100\left(\frac{GO^{\mathrm{nom}}_{i,t-1}}{\sum_j GO^{\mathrm{nom}}_{j,t-1}}\right) \left(\frac{I_{i,t}}{I_{i,t-1}}-1\right). \]

\(GO^{\mathrm{nom}}\) denotes nominal gross output and \(I\) the industry’s chain-type quantity index. The 15 broad-group stacked bars and the 71-leaf views are diagnostics, not an exact chain-weighted BEA decomposition. Positive and negative bars stack separately; the white dots represent growth in the published aggregate index, which need not equal the bar sums.

Show R code
plot_index_groups(qty, "Quantity")

Approximate industry-group contributions to quantity growth, last 12 quarters
Show R code
plot_index_detail_contributions(qty, "Quantity")

All 71 approximate quantity contributions, original BEA order
Show R code
plot_index_contribution_heatmap(qty, "Quantity")

Approximate contribution history for 71 industries and 12 quarters

The next chart shows the published aggregate growth minus the sum of approximate contributions (QoQ and YoY). This residual is the appropriate warning against treating the weighted calculations as an exact accounting identity.

Show R code
plot_index_reconciliation(qty, "Quantity")

Residual between published quantity index growth and approximate industry contributions

Latest-quarter residual: -0.0066 percentage points for QoQ growth. This can vary across history, especially when industry relative prices and weights move quickly.

6. Dispersion of industry growth

We compare the cross-sectional standard deviation, IQR and nominal-GO-weighted standard deviation of quantity growth. A high reading indicates more disagreement across industries, not necessarily high aggregate growth.

Show R code
plot_index_dispersion(qty, "Quantity")

Cross-sectional dispersion of gross-output quantity growth

7. Dispersion and concentration of approximate contributions

Contribution dispersion combines differences in growth with differences in economic size. The second chart tracks the share of all positive approximate contributions attributed to the five largest positive contributors. Rankings are calculated for the concentration statistic only; plotted industry order remains unchanged.

Show R code
plot_index_contribution_dispersion(qty, "Quantity")

Dispersion of approximate quantity growth contributions
Show R code
plot_index_concentration(qty, "Quantity")

Top-five share of positive approximate quantity contributions

8. Latest-quarter dashboard

Show R code
knitr::kable(index_summary_table(qty), caption = paste("As of", latest_label))
Summary of quantity index growth, breadth, dispersion, and approximate contributions
Metric QoQ YoY
Aggregate index (2017 = 100) 121.006 121.006
Aggregate growth 1.23% 2.60%
Industries expanding 49 / 71 46 / 71
Equal-weighted breadth 69.0% 64.8%
Nominal-GO-weighted breadth 78.3% 79.3%
Net breadth +38.0 pp +29.6 pp
Median industry growth 0.95% 1.09%
Industry-growth SD 2.43 pp 5.19 pp
Industry-growth IQR 1.80 pp 5.63 pp
Nominal-GO-weighted industry-growth SD 1.82 pp 4.75 pp
Approximate contribution SD 0.041 pp 0.106 pp
Top-five positive approximate-contribution share 45.1% 47.8%
Sum of approximate industry contributions +1.237 pp +2.619 pp
Published minus approximate growth -0.007 pp -0.018 pp

Part III: Gross-output price indexes

BEA’s chain-type price indexes measure industry gross-output price changes, normalized to 2017 = 100. These indexes are not dollar amounts or additive values. The analysis below repeats the quantity section for price growth and inflation breadth, using the same 71 leaf industries and lagged nominal-GO weights.

1. Aggregate price index and growth

The aggregate index describes the evolution of gross-output prices, while nonannualized QoQ and YoY changes show near-term and annual momentum.

Show R code
plot_index_level(price, "Price")

Aggregate BEA gross-output price index
Show R code
plot_index_growth(price, "Price")

Aggregate gross-output price growth: QoQ and YoY

Latest reading. In 2026 Q2, the published aggregate price index grew 1.72% QoQ and 4.47% YoY.

2. All 71 industries: current index and growth

The level chart retains the original BEA order, but an index level is not a measure of an industry’s size: every index has the same 2017 base. The QoQ chart is the more directly comparable measure of current industry momentum.

Show R code
plot_index_industries(price, "Price", "level")

Gross-output price indexes for all 71 industries, original BEA order
Show R code
plot_index_industries(price, "Price", "growth")

QoQ gross-output price growth by industry, original BEA order

3. Growth breadth

Equal-weighted breadth counts industries with positive index growth. Weighted breadth sums the prior-period nominal GO shares of those growing industries. Net breadth subtracts the share contracting from the share growing. Both QoQ and YoY horizons are shown.

Show R code
plot_index_breadth(price, "Price")

Equal-weighted and nominal-output-weighted price growth breadth
Show R code
plot_index_net_breadth(price, "Price")

Net breadth of price growth

Latest breadth. 65 of 71 industries grew QoQ, and output-weighted breadth was 92.4%.

4. Cross-sectional growth distribution

Breadth alone loses magnitude information. Conditional medians describe typical growth among growing industries and among shrinking industries; the histogram shows whether the latest distribution is skewed or dominated by outliers.

Show R code
plot_index_medians(price, "Price")

Conditional and unconditional median price growth
Show R code
plot_index_histogram(price, "Price")

Latest-quarter cross-sectional price growth distribution

5. Approximate industry contributions (last three years)

For an industry \(i\), we compute an approximate QoQ influence in percentage points:

\[ \widetilde{c}_{i,t}^{price} =100\left(\frac{GO^{\mathrm{nom}}_{i,t-1}}{\sum_j GO^{\mathrm{nom}}_{j,t-1}}\right) \left(\frac{I_{i,t}}{I_{i,t-1}}-1\right). \]

\(GO^{\mathrm{nom}}\) denotes nominal gross output and \(I\) the industry’s chain-type price index. The 15 broad-group stacked bars and the 71-leaf views are diagnostics, not an exact chain-weighted BEA decomposition. Positive and negative bars stack separately; the white dots represent growth in the published aggregate index, which need not equal the bar sums.

Show R code
plot_index_groups(price, "Price")

Approximate industry-group contributions to price growth, last 12 quarters
Show R code
plot_index_detail_contributions(price, "Price")

All 71 approximate price contributions, original BEA order
Show R code
plot_index_contribution_heatmap(price, "Price")

Approximate contribution history for 71 industries and 12 quarters

The next chart shows the published aggregate growth minus the sum of approximate contributions (QoQ and YoY). This residual is the appropriate warning against treating the weighted calculations as an exact accounting identity.

Show R code
plot_index_reconciliation(price, "Price")

Residual between published price index growth and approximate industry contributions

Latest-quarter residual: -0.0045 percentage points for QoQ growth. This can vary across history, especially when industry relative prices and weights move quickly.

6. Dispersion of industry growth

We compare the cross-sectional standard deviation, IQR and nominal-GO-weighted standard deviation of price growth. A high reading indicates more disagreement across industries, not necessarily high aggregate growth.

Show R code
plot_index_dispersion(price, "Price")

Cross-sectional dispersion of gross-output price growth

7. Dispersion and concentration of approximate contributions

Contribution dispersion combines differences in growth with differences in economic size. The second chart tracks the share of all positive approximate contributions attributed to the five largest positive contributors. Rankings are calculated for the concentration statistic only; plotted industry order remains unchanged.

Show R code
plot_index_contribution_dispersion(price, "Price")

Dispersion of approximate price growth contributions
Show R code
plot_index_concentration(price, "Price")

Top-five share of positive approximate price contributions

8. Latest-quarter dashboard

Show R code
knitr::kable(index_summary_table(price), caption = paste("As of", latest_label))
Summary of price index growth, breadth, dispersion, and approximate contributions
Metric QoQ YoY
Aggregate index (2017 = 100) 134.638 134.638
Aggregate growth 1.72% 4.47%
Industries expanding 65 / 71 67 / 71
Equal-weighted breadth 91.5% 94.4%
Nominal-GO-weighted breadth 92.4% 96.6%
Net breadth +83.1 pp +88.7 pp
Median industry growth 0.89% 3.70%
Industry-growth SD 4.38 pp 6.83 pp
Industry-growth IQR 1.02 pp 2.42 pp
Nominal-GO-weighted industry-growth SD 3.87 pp 5.61 pp
Approximate contribution SD 0.055 pp 0.089 pp
Top-five positive approximate-contribution share 51.6% 33.4%
Sum of approximate industry contributions +1.723 pp +4.494 pp
Published minus approximate growth -0.005 pp -0.024 pp

Cross-series perspective and methodology

Aggregate nominal, quantity, and price growth

At the aggregate level, nominal growth reflects changes in both quantities and prices. The BEA aggregate nominal total is drawn from GrossOutput.xlsx; published quantity and price indexes are read separately from their respective files. These aggregates can be compared, but this should not be confused with summing industry quantity or price indexes.

Show R code
combined_growth <- dplyr::bind_rows(
  aggregate |> dplyr::transmute(date, series = "Nominal gross output", qoq = growth_qoq, yoy = growth_yoy),
  qty$aggregate |> dplyr::transmute(date, series = "Quantity index", qoq = growth_qoq, yoy = growth_yoy),
  price$aggregate |> dplyr::transmute(date, series = "Price index", qoq = growth_qoq, yoy = growth_yoy)
) |>
  tidyr::pivot_longer(c(qoq, yoy), names_to = "horizon", values_to = "growth") |>
  dplyr::mutate(horizon = factor(horizon, levels = c("qoq", "yoy"), labels = c("QoQ", "YoY")))
ggplot(combined_growth, aes(date, 100 * growth, color = series)) +
  geom_hline(yintercept = 0, color = "grey60") +
  geom_line(linewidth = .8) +
  facet_wrap(~horizon, ncol = 1, scales = "free_y") +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(title = "Nominal growth versus quantity and price growth",
       subtitle = "Published aggregate indexes, nominal GO from 71-leaf sum",
       x = NULL, y = "Percent", color = NULL)

Aggregate nominal, quantity, and price gross-output growth
Show R code
knitr::kable(tibble::tibble(
  Series = c("Nominal gross output", "Quantity index", "Price index"),
  `QoQ growth` = c(fmt_pct(tail(aggregate$growth_qoq, 1), 2),
                   fmt_pct(tail(qty$aggregate$growth_qoq, 1), 2),
                   fmt_pct(tail(price$aggregate$growth_qoq, 1), 2)),
  `YoY growth` = c(fmt_pct(tail(aggregate$growth_yoy, 1), 2),
                   fmt_pct(tail(qty$aggregate$growth_yoy, 1), 2),
                   fmt_pct(tail(price$aggregate$growth_yoy, 1), 2)),
  `QoQ breadth` = c(fmt_pct(latest_breadth_qoq$breadth),
                    fmt_pct(tail(dplyr::filter(qty$breadth, horizon == "QoQ")$breadth, 1)),
                    fmt_pct(tail(dplyr::filter(price$breadth, horizon == "QoQ")$breadth, 1)))
), caption = paste("As of", latest_label))
Latest-quarter aggregate growth and breadth across the three datasets
Series QoQ growth YoY growth QoQ breadth
Nominal gross output 2.97% 7.19% 90.1%
Quantity index 1.23% 2.60% 69.0%
Price index 1.72% 4.47% 91.5%

Accounting and interpretive cautions

  1. Industry selection: The 71 Leaf = 1 flags, BEA line IDs, group hierarchy, and plotted order come from IndustryTaxonomy.xlsx / LeafTaxonomy. The raw observations come from the separate nominal, quantity and price workbooks. This is a line-number join, not a row-position or text-name join.
  2. Nominal additivity: Only nominal GO levels can be added across the non-overlapping leaves. Summing leaf levels differs slightly from the rounded published BEA total. The nominal contribution identity uses the leaf sum for exact arithmetic.
  3. No index additivity: Quantity and price levels are chain-type indexes with a shared 2017 base, but they cannot be summed. A level of 150 for one industry is not 50% larger output than an index level of 100 for another industry.
  4. Approximate index contributions: Lagged nominal GO shares times index growth are a transparent approximation, not an official BEA Fisher chain-index growth decomposition. The explicit residual measures the discrepancy. These contributions should never be described as exact contributions to chain-type index growth.
  5. Nominal-weighted breadth: QoQ uses last-quarter nominal GO shares; YoY uses shares four quarters earlier. This offers an economically interpretable size weighting without summing index values.
  6. Nonannualized growth: The underlying nominal dollars are reported at seasonally adjusted annual rates, but all percentage changes here are not annualized.
  7. Gross output versus GDP: GO includes intermediate production and is not the same as GDP or industry value added.
  8. Interpretation of price breadth: The price section measures the share of industries whose gross-output prices rose. It is not a share of consumer prices or the CPI basket.
  9. Industry reclassifications: If BEA revises line IDs, the explicit 71-line matching and date checks stop the report rather than silently use an inconsistent subset.

Source: U.S. Bureau of Economic Analysis, Gross Output by Industry, chain-type quantity indexes for gross output and chain-type price indexes for gross output, BEA extracts revised September 30, 2026. Original files: GrossOutput.xlsx, GrossOutput-Qty.xlsx, and GrossOutput-Price.xlsx; selected using IndustryTaxonomy.xlsx.