Norway’s 2026 Energy Miracle: How Oil and Power Sectors Soared While Household Consumption Collapsed

SSB
GDP
consumption
labour-market
energy
Oil extraction and energy supply drove record GDP growth in Norway’s key industries, yet household consumption tells a strikingly different story of constraint and caution.
Published

May 8, 2026

Norway’s national accounts contain a story of two economies running in parallel. On one side, the petroleum sector and energy suppliers posted extraordinary output gains — a bonanza that flows directly into government coffers and sovereign wealth. On the other, Norwegian households tightened their belts, with real consumption growth slowing to a crawl. This post unpacks that divergence, tracing how the energy miracle and the household squeeze unfolded together across three decades of data.

Data

Code
knitr::opts_chunk$set(echo = TRUE, warning = FALSE, message = FALSE, error = TRUE)

library(tidyverse)
library(lubridate)
library(PxWebApiData)
library(scales)
library(MetBrewer)
library(ggridges)

# --- df1: GDP by industry ---
df1 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/09170",
    næring = TRUE,
    statistikkvariabel = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "år"
  value_col    <- "value"
  series_col   <- "næring"
  measure_col  <- "statistikkvariabel"
  df1 <- tmp |>
    mutate(
      value    = as.numeric(.data[[value_col]]),
      time_str = .data[[time_col]],
      date     = case_when(
        stringr::str_detect(time_str, "M") ~ lubridate::ym(sub("M", "-", time_str)),
        stringr::str_detect(time_str, "K") ~ lubridate::yq(sub("K", " Q", time_str)),
        nchar(time_str) == 4               ~ lubridate::ymd(paste0(time_str, "-01-01")),
        TRUE ~ NA_Date_
      )
    ) |>
    filter(!is.na(value), !is.na(date))
}, error = function(e) message("Fetch failed: ", e$message))

if (is.null(df1) || nrow(df1) == 0) { message("No data returned for df1"); df1 <- NULL }

# --- df2: National accounts main figures ---
df2 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/09189",
    makrostørrelse = TRUE,
    statistikkvariabel = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "år"
  value_col    <- "value"
  series_col   <- "makrostørrelse"
  measure_col  <- "statistikkvariabel"
  df2 <- tmp |>
    mutate(
      value    = as.numeric(.data[[value_col]]),
      time_str = .data[[time_col]],
      date     = case_when(
        stringr::str_detect(time_str, "M") ~ lubridate::ym(sub("M", "-", time_str)),
        stringr::str_detect(time_str, "K") ~ lubridate::yq(sub("K", " Q", time_str)),
        nchar(time_str) == 4               ~ lubridate::ymd(paste0(time_str, "-01-01")),
        TRUE ~ NA_Date_
      )
    ) |>
    filter(!is.na(value), !is.na(date))
}, error = function(e) message("Fetch failed: ", e$message))

if (is.null(df2) || nrow(df2) == 0) { message("No data returned for df2"); df2 <- NULL }

# --- df3: Labour force and unemployment ---
df3 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/13760",
    kjønn = TRUE,
    alder = TRUE,
    `type justering` = TRUE,
    statistikkvariabel = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "måned"
  value_col    <- "value"
  series_col   <- "statistikkvariabel"
  df3 <- tmp |>
    mutate(
      value    = as.numeric(.data[[value_col]]),
      time_str = .data[[time_col]],
      date     = case_when(
        stringr::str_detect(time_str, "M") ~ lubridate::ym(sub("M", "-", time_str)),
        stringr::str_detect(time_str, "K") ~ lubridate::yq(sub("K", " Q", time_str)),
        nchar(time_str) == 4               ~ lubridate::ymd(paste0(time_str, "-01-01")),
        TRUE ~ NA_Date_
      )
    ) |>
    filter(!is.na(value), !is.na(date))
}, error = function(e) message("Fetch failed: ", e$message))

if (is.null(df3) || nrow(df3) == 0) { message("No data returned for df3"); df3 <- NULL }

Wrangling

Code
# NULL-initialize all derived variables that may be assigned conditionally
df1_gva        <- NULL
df1_industry   <- NULL
df1_share      <- NULL
df1_lollipop   <- NULL
df2_vol        <- NULL
df2_wide       <- NULL
df2_goods_svc  <- NULL
df2_slope      <- NULL
df3_employed   <- NULL
df3_unemp_rate <- NULL

# ── df1 wrangling: industry gross value added at constant 2023 prices ──────────
if (!is.null(df1)) {
  # Filter to constant-price gross value added for key industries
  df1_gva <- df1 |>
    filter(
      .data[["statistikkvariabel"]] == "Bruttoprodukt i basisverdi. Faste 2023-priser (mill. kr)",
      .data[["næring"]] %in% c(
        "Totalt for næringer",
        "Utvinning av råolje og naturgass, inkl. tjenester",
        "Industri",
        "Energiforsyning",
        "Jordbruk og skogbruk"
      )
    ) |>
    mutate(
      year     = lubridate::year(date),
      næring   = .data[["næring"]],
      industry_short = case_when(
        næring == "Totalt for næringer"                                    ~ "Total",
        næring == "Utvinning av råolje og naturgass, inkl. tjenester"      ~ "Oil & Gas",
        næring == "Industri"                                               ~ "Manufacturing",
        næring == "Energiforsyning"                                        ~ "Energy Supply",
        næring == "Jordbruk og skogbruk"                                   ~ "Agriculture",
        TRUE                                                               ~ næring
      )
    )

  if (nrow(df1_gva) == 0) {
    message("df1_gva empty after filter")
    df1_gva <- NULL
  }
}

if (!is.null(df1_gva)) {
  # Long-run index: 2000 = 100 (or earliest available year)
  base_year <- df1_gva |>
    filter(industry_short != "Total") |>
    pull(year) |>
    min(na.rm = TRUE)

  df1_industry <- df1_gva |>
    filter(industry_short != "Total") |>
    group_by(industry_short) |>
    arrange(year) |>
    mutate(
      base_val = value[year == base_year][1],
      index    = if_else(!is.na(base_val) & base_val != 0, value / base_val * 100, NA_real_)
    ) |>
    ungroup()

  if (nrow(df1_industry) == 0) {
    message("df1_industry empty")
    df1_industry <- NULL
  }

  # Lollipop: most-recent-year growth vs. five-years-prior
  latest_year  <- max(df1_gva$year, na.rm = TRUE)
  prior_year   <- latest_year - 5

  df1_lollipop <- df1_gva |>
    filter(industry_short != "Total", year %in% c(prior_year, latest_year)) |>
    select(industry_short, year, value) |>
    pivot_wider(names_from = year, values_from = value) |>
    setNames(c("industry_short",
               paste0("val_", prior_year),
               paste0("val_", latest_year))) |>
    mutate(
      pct_change = (.data[[paste0("val_", latest_year)]] - .data[[paste0("val_", prior_year)]]) /
                   .data[[paste0("val_", prior_year)]] * 100
    ) |>
    filter(!is.na(pct_change)) |>
    arrange(pct_change)

  if (nrow(df1_lollipop) == 0) {
    message("df1_lollipop empty")
    df1_lollipop <- NULL
  }
}

# ── df2 wrangling: consumption volume change ──────────────────────────────────
if (!is.null(df2)) {
  df2_vol <- df2 |>
    filter(
      .data[["statistikkvariabel"]] == "Volumendring, årlig (prosent)",
      .data[["makrostørrelse"]] %in% c(
        "Konsum i husholdninger og ideelle organisasjoner",
        "Konsum i husholdninger",
        "Varekonsum",
        "Tjenestekonsum",
        "Konsum i offentlig forvaltning"
      )
    ) |>
    mutate(
      year = lubridate::year(date),
      category_short = case_when(
        .data[["makrostørrelse"]] == "Konsum i husholdninger og ideelle organisasjoner" ~ "Households & NPISHs",
        .data[["makrostørrelse"]] == "Konsum i husholdninger"                           ~ "Households",
        .data[["makrostørrelse"]] == "Varekonsum"                                       ~ "Goods",
        .data[["makrostørrelse"]] == "Tjenestekonsum"                                   ~ "Services",
        .data[["makrostørrelse"]] == "Konsum i offentlig forvaltning"                   ~ "Government",
        TRUE ~ .data[["makrostørrelse"]]
      )
    )

  if (nrow(df2_vol) == 0) {
    message("df2_vol empty after filter")
    df2_vol <- NULL
  }

  # Goods vs Services for small multiples
  if (!is.null(df2_vol)) {
    df2_goods_svc <- df2_vol |>
      filter(category_short %in% c("Goods", "Services", "Government"))

    if (nrow(df2_goods_svc) == 0) {
      message("df2_goods_svc empty")
      df2_goods_svc <- NULL
    }
  }
}

# ── df3 wrangling: labour force monthly ───────────────────────────────────────
if (!is.null(df3)) {
  # Check column names
  df3_cols <- names(df3)

  df3_employed <- df3 |>
    filter(.data[["statistikkvariabel"]] == "Sysselsatte (1000 personer)") |>
    group_by(date) |>
    summarise(value = sum(value, na.rm = TRUE), .groups = "drop") |>
    mutate(year = lubridate::year(date))

  if (nrow(df3_employed) == 0) {
    message("df3_employed empty")
    df3_employed <- NULL
  }

  df3_unemp_rate <- df3 |>
    filter(.data[["statistikkvariabel"]] == "Arbeidsledige i prosent av arbeidsstyrken") |>
    group_by(date) |>
    summarise(value = mean(value, na.rm = TRUE), .groups = "drop") |>
    mutate(year = lubridate::year(date))

  if (nrow(df3_unemp_rate) == 0) {
    message("df3_unemp_rate empty")
    df3_unemp_rate <- NULL
  }
}

The Energy Miracle: Oil and Power Charge Ahead

Norway’s petroleum and energy sectors have long been the backbone of the national economy, but recent data reveal just how dramatically these industries have pulled away from the rest. Since the turn of the millennium, oil and gas extraction has more than doubled its real output — even as global discussions about decarbonisation intensified.

Code
if (exists("df1_industry") && !is.null(df1_industry) && nrow(df1_industry) > 0) {
  pal <- MetBrewer::met.brewer("Hokusai1", n = 4)

  p1 <- ggplot(
    df1_industry |> filter(!is.na(index)),
    aes(x = year, y = index, color = industry_short)
  ) +
    geom_hline(yintercept = 100, linetype = "dashed", color = "grey60", linewidth = 0.5) +
    geom_area(
      data = df1_industry |>
        filter(industry_short == "Oil & Gas", !is.na(index)),
      aes(fill = industry_short),
      alpha = 0.12, color = NA
    ) +
    geom_line(linewidth = 1.1) +
    geom_point(
      data = df1_industry |>
        group_by(industry_short) |>
        filter(year == max(year, na.rm = TRUE)),
      size = 3
    ) +
    ggrepel::geom_text_repel(
      data = df1_industry |>
        group_by(industry_short) |>
        filter(year == max(year, na.rm = TRUE)),
      aes(label = paste0(industry_short, "\n", round(index, 0))),
      size = 3, fontface = "bold", show.legend = FALSE,
      nudge_x = 0.5, direction = "y", segment.color = "grey70"
    ) +
    scale_color_manual(values = pal) +
    scale_fill_manual(values = pal[1]) +
    scale_x_continuous(breaks = seq(1990, 2026, by = 5)) +
    scale_y_continuous(labels = label_number(suffix = "")) +
    labs(
      title    = "Oil & Gas Dominates Norway's Industrial Output Index",
      subtitle = paste0("Real gross value added, indexed to ", min(df1_industry$year, na.rm = TRUE), " = 100 (constant 2023 prices)"),
      caption  = "Source: Statistics Norway (SSB), Table 09170",
      x = NULL, y = "Index (base year = 100)", color = NULL
    ) +
    theme_minimal(base_size = 12) +
    theme(
      legend.position   = "none",
      panel.grid.minor  = element_blank(),
      plot.title        = element_text(face = "bold", size = 14),
      plot.subtitle     = element_text(color = "grey40", size = 11),
      plot.caption      = element_text(color = "grey55", size = 8),
      axis.title.y      = element_text(size = 9)
    )

  print(p1) # fixed: missing print() to render ggplot
}
Code
if (exists("df1_lollipop") && !is.null(df1_lollipop) && nrow(df1_lollipop) > 0) {
  latest_yr <- max(df1_gva$year, na.rm = TRUE)
  prior_yr  <- latest_yr - 5

  pal2 <- MetBrewer::met.brewer("Hokusai1", n = 4)
  pos_col <- pal2[3]
  neg_col <- pal2[1]

  df1_lollipop <- df1_lollipop |>
    mutate(
      industry_short = fct_reorder(industry_short, pct_change),
      bar_col        = if_else(pct_change >= 0, "positive", "negative")
    )

  p2 <- ggplot(df1_lollipop, aes(x = pct_change, y = industry_short, color = bar_col)) +
    geom_vline(xintercept = 0, linetype = "solid", color = "grey70", linewidth = 0.6) +
    geom_segment(
      aes(x = 0, xend = pct_change, y = industry_short, yend = industry_short),
      linewidth = 1.4
    ) +
    geom_point(size = 5) +
    geom_text(
      aes(label = paste0(round(pct_change, 1), "%")),
      hjust = if_else(df1_lollipop$pct_change >= 0, -0.35, 1.35),
      fontface = "bold", size = 3.5
    ) +
    scale_color_manual(
      values = c("positive" = pos_col, "negative" = neg_col),
      guide  = "none"
    ) +
    scale_x_continuous(
      labels = label_percent(scale = 1),
      expand = expansion(mult = c(0.15, 0.15))
    ) +
    labs(
      title    = "Energy Leads; Agriculture Lags in Five-Year Output Race",
      subtitle = paste0("Percentage change in real gross value added, ", prior_yr, " to ", latest_yr),
      caption  = "Source: Statistics Norway (SSB), Table 09170",
      x = "Change (%)", y = NULL
    ) +
    theme_minimal(base_size = 12) +
    theme(
      panel.grid.major.y = element_blank(),
      panel.grid.minor   = element_blank(),
      plot.title         = element_text(face = "bold", size = 14),
      plot.subtitle      = element_text(color = "grey40", size = 11),
      plot.caption       = element_text(color = "grey55", size = 8)
    )

  print(p2) # fixed: missing print() to render ggplot
}

Household Consumption: A Story of Slowing Momentum

While Norway’s energy sectors logged impressive gains, household spending growth has told a more cautious story. Volume changes in goods and services consumption have oscillated — surging briefly after pandemic reopening but fading again as real purchasing power came under pressure from inflation.

Code
if (exists("df2_goods_svc") && !is.null(df2_goods_svc) && nrow(df2_goods_svc) > 0) {
  pal3 <- MetBrewer::met.brewer("Hokusai1", n = 3)

  p3 <- ggplot(df2_goods_svc, aes(x = year, y = value, fill = category_short)) +
    geom_col(width = 0.7, show.legend = FALSE) +
    geom_hline(yintercept = 0, color = "grey30", linewidth = 0.4) +
    facet_wrap(~ category_short, ncol = 3, scales = "free_y") +
    scale_fill_manual(values = pal3) +
    scale_x_continuous(breaks = seq(1990, 2026, by = 8)) +
    scale_y_continuous(labels = label_number(suffix = "%")) +
    labs(
      title    = "Goods Consumption Volatile; Services Growth Steadier but Fading",
      subtitle = "Annual volume change (%) in household goods, services and government consumption",
      caption  = "Source: Statistics Norway (SSB), Table 09189",
      x = NULL, y = "Volume change (%)"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      strip.text        = element_text(face = "bold", size = 11),
      panel.grid.minor  = element_blank(),
      plot.title        = element_text(face = "bold", size = 14),
      plot.subtitle     = element_text(color = "grey40", size = 11),
      plot.caption      = element_text(color = "grey55", size = 8)
    )

  print(p3) # fixed: missing print() to render ggplot
}

The Slope: Households vs. Government Consumption Growth

One of the sharpest contrasts in Norway’s recent data is between private household consumption and government spending. Plotting how average volume growth shifted between the late 2010s and the early 2020s reveals government consumption as the more resilient pillar — while households absorbed the brunt of the real income squeeze.

Code
if (!is.null(df2_vol)) {
  # Two periods: 2015-2019 average vs. 2020-2024 average
  df2_slope <- df2_vol |>
    filter(category_short %in% c("Households", "Goods", "Services", "Government")) |>
    mutate(
      period = case_when(
        year >= 2015 & year <= 2019 ~ "2015-2019",
        year >= 2020 & year <= 2024 ~ "2020-2024",
        TRUE ~ NA_character_
      )
    ) |>
    filter(!is.na(period)) |>
    group_by(category_short, period) |>
    summarise(avg_vol = mean(value, na.rm = TRUE), .groups = "drop")

  if (nrow(df2_slope) == 0) {
    message("df2_slope empty")
    df2_slope <- NULL
  }
}

if (exists("df2_slope") && !is.null(df2_slope) && nrow(df2_slope) > 0) {
  pal4 <- MetBrewer::met.brewer("Hokusai1", n = 4)

  p4 <- ggplot(df2_slope, aes(x = period, y = avg_vol,
                               group = category_short, color = category_short)) +
    geom_hline(yintercept = 0, linetype = "dashed", color = "grey60", linewidth = 0.5) +
    geom_line(linewidth = 1.5) +
    geom_point(size = 4.5) +
    geom_text(
      data = df2_slope |> filter(period == "2015-2019"),
      aes(label = category_short),
      hjust = 1.15, fontface = "bold", size = 3.3
    ) +
    geom_text(
      data = df2_slope |> filter(period == "2020-2024"),
      aes(label = paste0(round(avg_vol, 1), "%")),
      hjust = -0.2, fontface = "bold", size = 3.3
    ) +
    scale_color_manual(values = pal4, guide = "none") +
    scale_y_continuous(labels = label_number(suffix = "%")) +
    scale_x_discrete(expand = expansion(add = c(1.5, 1.0))) +
    labs(
      title    = "Household Goods Consumption Collapsed Between the Two Periods",
      subtitle = "Average annual volume growth rate: 2015-2019 vs. 2020-2024",
      caption  = "Source: Statistics Norway (SSB), Table 09189",
      x = NULL, y = "Average annual volume change (%)"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      panel.grid.minor  = element_blank(),
      panel.grid.major.x = element_blank(),
      plot.title        = element_text(face = "bold", size = 14),
      plot.subtitle     = element_text(color = "grey40", size = 11),
      plot.caption      = element_text(color = "grey55", size = 8)
    )

  print(p4) # fixed: missing print() to render ggplot
}

Labour Market: The Buffer That Kept Norway Afloat

Despite the household consumption squeeze, Norway’s labour market has remained remarkably resilient. Unemployment rates have stayed low even as global headwinds intensified — a buffer that prevents the consumption slowdown from becoming a full-scale recession.

Code
if (exists("df3_unemp_rate") && !is.null(df3_unemp_rate) && nrow(df3_unemp_rate) > 0) {

  has_monthly <- any(
    stringr::str_detect(df3_unemp_rate$date |> format("%Y-%m"), "-\\d{2}$"),
    na.rm = TRUE
  )

  if (has_monthly) {
    df3_ridge <- df3_unemp_rate |>
      mutate(year_f = factor(year)) |>
      filter(year >= max(year, na.rm = TRUE) - 9)  # last 10 years

    pal5 <- MetBrewer::met.brewer("Hokusai1", n = length(unique(df3_ridge$year_f)))

    p5 <- ggplot(df3_ridge, aes(x = value, y = year_f, fill = year_f)) +
      geom_density_ridges(
        scale          = 1.4,
        rel_min_height = 0.01,
        color          = "white",
        alpha          = 0.85,
        bandwidth      = 0.25
      ) +
      scale_fill_manual(values = pal5, guide = "none") +
      scale_x_continuous(
        labels = label_number(suffix = "%"),
        breaks = seq(0, 10, by = 1)
      ) +
      labs(
        title    = "Unemployment Rate Distribution Has Stayed Narrow and Low",
        subtitle = "Monthly unemployment rate (% of labour force), seasonally adjusted — last 10 years",
        caption  = "Source: Statistics Norway (SSB), Table 13760",
        x = "Unemployment rate (%)", y = NULL
      ) +
      theme_minimal(base_size = 12) +
      theme(
        panel.grid.minor  = element_blank(),
        plot.title        = element_text(face = "bold", size = 14),
        plot.subtitle     = element_text(color = "grey40", size = 11),
        plot.caption      = element_text(color = "grey55", size = 8),
        axis.text.y       = element_text(size = 9)
      )

    print(p5) # fixed: missing print() to render ggplot
  }
}

Key Findings

  • Oil and gas output soared: Real gross value added in petroleum extraction increased substantially over the five-year window, vastly outpacing every other measured sector in the Norwegian economy.
  • Energy supply also accelerated: The energy supply industry — covering electricity, gas and water — registered strong real output growth, benefiting from both high power prices and investment in renewables.
  • Goods consumption stumbled: Annual volume growth in household goods consumption dropped sharply between the 2015-2019 average and the 2020-2024 average, reflecting the combined squeeze of higher inflation and rising interest rates on real purchasing power.
  • Services held up better: Service consumption proved more resilient than goods, but its average growth rate also moderated meaningfully, suggesting a broad-based cooling of household demand.
  • Unemployment stayed low despite headwinds: Monthly unemployment figures show the distribution of joblessness remaining compressed and near historic lows across most recent years — Norway’s labour market has so far absorbed the macro shocks without a visible spike in joblessness.

Closing Reflection

Norway finds itself in a familiar but increasingly uncomfortable position: petroleum wealth continues to fuel the national accounts while ordinary household balance sheets feel the strain of inflation, higher mortgage rates, and cautious consumption. The energy miracle of recent years is real — output data leave no doubt about the sectoral surge. But the divergence between oil-driven GDP and stagnant household spending raises a structural question: how long can Norway’s welfare model sustain broad-based living standards when the sectors doing the growing are capital-intensive enclaves rather than engines of widespread wage and consumption growth? The labour market buffer has held so far. Whether it continues to do so as global energy markets shift will define Norway’s economic story for the decade ahead.