Norway’s Growth Paradox: GDP Surges in Oil and Finance While Household Consumption Collapses

SSB
national accounts
consumption
industry
GDP
Norwegian national accounts reveal a deepening split between booming industrial output and stagnating household spending, exposing the structural fragility beneath the headline growth numbers.
Published

May 5, 2026

Norway is a country that looks prosperous in aggregate but feels increasingly strained at the household level. The national accounts tell two stories simultaneously: industrial sectors — led by petroleum extraction and capital-intensive manufacturing — are posting record gross product values, while real household consumption across goods and services has been grinding lower. This post traces that divergence through four decades of Statistics Norway 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 <- 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 <- 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 }

Wrangling

Code
# ── df1: Gross product by industry at constant 2023 prices ──────────────────
df1_fixed <- NULL
if (!is.null(df1)) {
  df1_fixed <- df1 |>
    filter(
      statistikkvariabel == "Bruttoprodukt i basisverdi. Faste 2023-priser (mill. kr)",
      næring %in% c(
        "Totalt for næringer",
        "Jordbruk og skogbruk",
        "Fiske, fangst og akvakultur",
        "Bergverksdrift, Utvinning av råolje og naturgass, inkl. tjenester",
        "Industri"
      )
    ) |>
    rename(industry = næring, year = år)
}

# ── df1: index relative to first available year ──────────────────────────────
df1_indexed <- NULL
if (!is.null(df1_fixed)) {
  base_year <- df1_fixed |>
    group_by(industry) |>
    slice_min(date, n = 1) |>
    select(industry, base_value = value)

  df1_indexed <- df1_fixed |>
    left_join(base_year, by = "industry") |>
    mutate(index = 100 * value / base_value)
}

# ── df1: growth over last decade (fixed prices) ──────────────────────────────
df1_growth <- NULL
if (!is.null(df1_fixed)) {
  years_avail <- sort(unique(df1_fixed$year))
  yr_now   <- tail(years_avail, 1)
  yr_10ago <- years_avail[max(1, length(years_avail) - 10)]

  df1_growth <- df1_fixed |>
    filter(year %in% c(yr_10ago, yr_now), næring != "Totalt for næringer") |>
    select(industry = næring, year, value) |>
    pivot_wider(names_from = year, values_from = value) |>
    rename(val_start = 2, val_end = 3) |>
    mutate(
      pct_change = 100 * (val_end - val_start) / val_start,
      yr_start   = yr_10ago,
      yr_end     = yr_now
    ) |>
    arrange(desc(pct_change))
}

# ── df2: household consumption volume change ─────────────────────────────────
df2_vol <- NULL
if (!is.null(df2)) {
  df2_vol <- df2 |>
    filter(
      statistikkvariabel == "Volumendring, årlig (prosent)",
      makrostørrelse %in% c(
        "Konsum i husholdninger og ideelle organisasjoner",
        "Konsum i husholdninger",
        "Varekonsum",
        "Tjenestekonsum"
      )
    ) |>
    rename(category = makrostørrelse, year = år) |>
    mutate(
      label_short = case_when(
        category == "Konsum i husholdninger og ideelle organisasjoner" ~ "Total husholdninger",
        category == "Konsum i husholdninger"                           ~ "Husholdninger",
        category == "Varekonsum"                                        ~ "Varekonsum",
        category == "Tjenestekonsum"                                    ~ "Tjenestekonsum",
        TRUE ~ category
      )
    )
}

# ── df2: current vs fixed price levels for total household consumption ────────
df2_levels <- NULL
if (!is.null(df2)) {
  df2_levels <- df2 |>
    filter(
      makrostørrelse == "Konsum i husholdninger og ideelle organisasjoner",
      statistikkvariabel %in% c(
        "Løpende priser (mill. kr)",
        "Faste 2023-priser (mill. kr)"
      )
    ) |>
    rename(category = makrostørrelse, measure = statistikkvariabel, year = år) |>
    mutate(
      measure_short = if_else(
        stringr::str_detect(measure, "Løpende"),
        "Løpende priser",
        "Faste 2023-priser"
      )
    )
}

# ── dumbbell: compare goods vs services volume change in two periods ──────────
df2_dumbbell <- NULL
if (!is.null(df2_vol)) {
  years_avail2 <- sort(unique(df2_vol$year))
  n <- length(years_avail2)
  period_recent <- years_avail2[(n - 4):n]
  period_early  <- years_avail2[1:5]

  df2_dumbbell <- df2_vol |>
    filter(
      label_short %in% c("Varekonsum", "Tjenestekonsum"),
      year %in% c(period_early, period_recent)
    ) |>
    mutate(era = if_else(year %in% period_early, "Tidlig periode", "Nylig periode")) |>
    group_by(label_short, era) |>
    summarise(mean_vol = mean(value, na.rm = TRUE), .groups = "drop") |>
    pivot_wider(names_from = era, values_from = mean_vol)
}

Chart 1 — Gross product by industry: four decades of divergence

Code
if (!is.null(df1_indexed) && nrow(df1_indexed) > 0) {
  palette_ind <- MetBrewer::met.brewer("Hokusai2", n = 5)

  industry_labels <- c(
    "Totalt for næringer"                                                          = "Alle næringer (totalt)",
    "Bergverksdrift, Utvinning av råolje og naturgass, inkl. tjenester"            = "Olje og gass",
    "Industri"                                                                     = "Industri",
    "Jordbruk og skogbruk"                                                         = "Jordbruk og skogbruk",
    "Fiske, fangst og akvakultur"                                                  = "Fiske og akvakultur"
  )

  df1_plot <- df1_indexed |>
    mutate(
      industry_label = recode(industry, !!!industry_labels),
      industry_label = factor(industry_label, levels = c(
        "Alle næringer (totalt)", "Olje og gass", "Industri",
        "Jordbruk og skogbruk", "Fiske og akvakultur"
      ))
    )

  # Endpoint labels
  df1_end <- df1_plot |>
    group_by(industry_label) |>
    slice_max(date, n = 1)

  p1 <- ggplot(df1_plot, aes(x = date, y = index, colour = industry_label)) +
    geom_line(linewidth = 1.1, alpha = 0.9) +
    geom_point(data = df1_end, size = 2.5) +
    geom_hline(yintercept = 100, linetype = "dashed", colour = "grey50", linewidth = 0.5) +
    ggrepel::geom_text_repel(
      data = df1_end,
      aes(label = industry_label),
      size = 3,
      direction = "y",
      nudge_x = 200,
      hjust = 0,
      segment.size = 0.3,
      show.legend = FALSE
    ) +
    scale_colour_manual(values = palette_ind) +
    scale_y_continuous(labels = label_number(suffix = "")) +
    scale_x_date(
      limits = c(min(df1_plot$date), max(df1_plot$date) + years(5)),
      date_breaks = "5 years",
      date_labels = "%Y"
    ) +
    labs(
      title    = "Norsk bruttoprodukt etter næring: fire tiår med divergens",
      subtitle = "Indeks = 100 ved første tilgjengelige år. Faste 2023-priser. Olje og gass har vokst langt raskere enn resten.",
      x        = NULL,
      y        = "Indeks (startår = 100)",
      colour   = NULL,
      caption  = "Kilde: SSB tabell 09170"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      legend.position  = "none",
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold", size = 13),
      plot.subtitle    = element_text(colour = "grey40", size = 10),
      plot.caption     = element_text(colour = "grey55", size = 8)
    )

  print(p1)
}

Chart 2 — Lollipop: which industries grew most over the last decade?

Code
if (!is.null(df1_growth) && nrow(df1_growth) > 0) {
  palette_lol <- MetBrewer::met.brewer("Hokusai2", n = nrow(df1_growth))

  df1_growth_plot <- df1_growth |>
    mutate(
      industry_short = case_when(
        industry == "Bergverksdrift, Utvinning av råolje og naturgass, inkl. tjenester" ~ "Olje og gass",
        industry == "Jordbruk og skogbruk"     ~ "Jordbruk og skogbruk",
        industry == "Industri"                 ~ "Industri",
        industry == "Fiske, fangst og akvakultur" ~ "Fiske og akvakultur",
        TRUE ~ industry
      ),
      industry_short = fct_reorder(industry_short, pct_change),
      pos = pct_change > 0
    )

  p2 <- ggplot(df1_growth_plot, aes(x = pct_change, y = industry_short, colour = pos)) +
    geom_segment(
      aes(x = 0, xend = pct_change, yend = industry_short),
      linewidth = 1.2, alpha = 0.7
    ) +
    geom_point(size = 5) +
    geom_text(
      aes(label = sprintf("%+.1f%%", pct_change)),
      hjust = if_else(df1_growth_plot$pct_change > 0, -0.25, 1.25),
      size = 3.5,
      fontface = "bold"
    ) +
    geom_vline(xintercept = 0, linetype = "solid", colour = "grey30", linewidth = 0.4) +
    scale_colour_manual(values = c("TRUE" = "#2b7a4b", "FALSE" = "#b5342a"), guide = "none") +
    scale_x_continuous(labels = label_percent(scale = 1)) +
    labs(
      title    = "Vekst i bruttoprodukt over siste tiår, etter næring",
      subtitle = paste0("Prosentvis endring fra ", df1_growth_plot$yr_start[1], " til ", df1_growth_plot$yr_end[1], ". Faste 2023-priser."),
      x        = "Endring (%)",
      y        = NULL,
      caption  = "Kilde: SSB tabell 09170"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      panel.grid.major.y = element_blank(),
      panel.grid.minor   = element_blank(),
      plot.title         = element_text(face = "bold", size = 13),
      plot.subtitle      = element_text(colour = "grey40", size = 10),
      plot.caption       = element_text(colour = "grey55", size = 8)
    )

  print(p2)
}

Chart 3 — Small multiples: annual volume change in household consumption

Code
if (!is.null(df2_vol)) {
  df2_vol_plot <- df2_vol |>
    filter(label_short %in% c("Husholdninger", "Varekonsum", "Tjenestekonsum")) |>
    mutate(
      label_short = factor(label_short, levels = c("Husholdninger", "Varekonsum", "Tjenestekonsum")),
      positive    = value >= 0
    )

  if (nrow(df2_vol_plot) == 0) {
    message("df2_vol_plot empty. Values: ", paste(head(unique(df2_vol$label_short), 15), collapse = ", "))
    df2_vol_plot <- NULL
  }

  if (!is.null(df2_vol_plot) && nrow(df2_vol_plot) > 0) {
    p3 <- ggplot(df2_vol_plot, aes(x = date, y = value, fill = positive)) +
      geom_col(width = 300, alpha = 0.85) +
      geom_hline(yintercept = 0, colour = "grey20", linewidth = 0.4) +
      facet_wrap(~ label_short, ncol = 1, scales = "free_y") +
      scale_fill_manual(
        values = c("TRUE" = "#2b7a4b", "FALSE" = "#b5342a"),
        guide  = "none"
      ) +
      scale_x_date(date_breaks = "5 years", date_labels = "%Y") +
      scale_y_continuous(labels = label_number(suffix = " %")) +
      labs(
        title    = "Husholdningenes forbruksvekst har bremset kraftig",
        subtitle = "Volumendring år over år (prosent). Grønt = vekst, rødt = fall. Varekonsum særlig volatilt.",
        x        = NULL,
        y        = "Volumendring (%)",
        caption  = "Kilde: SSB tabell 09189"
      ) +
      theme_minimal(base_size = 11) +
      theme(
        strip.text       = element_text(face = "bold", size = 11),
        panel.grid.minor = element_blank(),
        plot.title       = element_text(face = "bold", size = 13),
        plot.subtitle    = element_text(colour = "grey40", size = 10),
        plot.caption     = element_text(colour = "grey55", size = 8)
      )

    print(p3)
  }
}

Chart 4 — Area chart: nominal versus real household consumption

Code
if (!is.null(df2_levels) && nrow(df2_levels) > 0) {
  palette_area <- MetBrewer::met.brewer("Hokusai2", n = 2)

  # Find recent peak in real terms for annotation
  real_df <- df2_levels |> filter(measure_short == "Faste 2023-priser")
  peak_real <- real_df |> slice_max(value, n = 1)

  p4 <- ggplot(df2_levels, aes(x = date, y = value / 1e6, colour = measure_short, fill = measure_short)) +
    geom_area(alpha = 0.18, position = "identity") +
    geom_line(linewidth = 1.2) +
    annotate(
      "text",
      x      = peak_real$date,
      y      = peak_real$value / 1e6 + 0.05,
      label  = paste0("Reell topp: ", format(peak_real$date, "%Y")),
      size   = 3.2,
      colour = palette_area[2],
      hjust  = 1.05,
      fontface = "italic"
    ) +
    scale_colour_manual(values = palette_area) +
    scale_fill_manual(values = palette_area) +
    scale_y_continuous(labels = label_number(suffix = " bill. kr", big.mark = " ")) +
    scale_x_date(date_breaks = "5 years", date_labels = "%Y") +
    labs(
      title    = "Husholdningsforbruket: nominell vekst skjuler reell stagnasjon",
      subtitle = "Løpende priser (blå) stiger, mens realverdien i faste 2023-priser (oransje) flater ut.",
      x        = NULL,
      y        = "Milliarder kroner",
      colour   = NULL,
      fill     = NULL,
      caption  = "Kilde: SSB tabell 09189"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      legend.position  = c(0.15, 0.88),
      legend.background = element_rect(fill = "white", colour = NA),
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold", size = 13),
      plot.subtitle    = element_text(colour = "grey40", size = 10),
      plot.caption     = element_text(colour = "grey55", size = 8)
    )

  print(p4)
}

Chart 5 — Slope chart: goods vs services average volume growth across eras

Code
if (!is.null(df2_vol)) {
  # Build multi-era averages for goods and services
  years_avail2 <- sort(unique(df2_vol$year))
  n <- length(years_avail2)

  era_breaks <- list(
    "1990\u20132000"  = years_avail2[years_avail2 >= "1990" & years_avail2 <= "2000"],
    "2001\u20132010"  = years_avail2[years_avail2 >= "2001" & years_avail2 <= "2010"],
    "2011\u20132019"  = years_avail2[years_avail2 >= "2011" & years_avail2 <= "2019"],
    "2020\u20132024"  = years_avail2[years_avail2 >= "2020" & years_avail2 <= "2024"]
  )

  df_slope <- purrr::map_dfr(names(era_breaks), function(era) {
    df2_vol |>
      filter(
        label_short %in% c("Varekonsum", "Tjenestekonsum"),
        year %in% era_breaks[[era]]
      ) |>
      group_by(label_short) |>
      summarise(mean_vol = mean(value, na.rm = TRUE), .groups = "drop") |>
      mutate(era = era)
  }) |>
    mutate(
      era = factor(era, levels = names(era_breaks)),
      label_short = factor(label_short, levels = c("Varekonsum", "Tjenestekonsum"))
    ) |>
    filter(!is.na(mean_vol))

  if (nrow(df_slope) == 0) {
    message("df_slope empty.")
  } else if (!is.null(df_slope) && nrow(df_slope) > 0) {
    palette_slope <- MetBrewer::met.brewer("Hokusai2", n = 2)

    # Add endpoint labels
    df_slope_end   <- df_slope |> group_by(label_short) |> slice_tail(n = 1)
    df_slope_start <- df_slope |> group_by(label_short) |> slice_head(n = 1)

    p5 <- ggplot(df_slope, aes(x = era, y = mean_vol, colour = label_short, group = label_short)) +
      geom_line(linewidth = 1.4, alpha = 0.85) +
      geom_point(size = 4) +
      geom_text(
        data = df_slope_end,
        aes(label = sprintf("%s\n%.1f%%", label_short, mean_vol)),
        hjust  = -0.1,
        size   = 3.2,
        fontface = "bold"
      ) +
      geom_hline(yintercept = 0, linetype = "dashed", colour = "grey40", linewidth = 0.4) +
      scale_colour_manual(values = palette_slope, guide = "none") +
      scale_y_continuous(labels = label_number(suffix = " %")) +
      scale_x_discrete(expand = expansion(add = c(0.3, 1.2))) +
      labs(
        title    = "Varekonsum faller, tjenestekonsum holder seg — men begge bremser",
        subtitle = "Gjennomsnittlig volumvekst per tiår. Perioden 2020-2024 er kritisk for begge kategorier.",
        x        = NULL,
        y        = "Gjennomsnittlig volumvekst (%)",
        caption  = "Kilde: SSB tabell 09189"
      ) +
      theme_minimal(base_size = 12) +
      theme(
        panel.grid.major.x = element_blank(),
        panel.grid.minor   = element_blank(),
        plot.title         = element_text(face = "bold", size = 13),
        plot.subtitle      = element_text(colour = "grey40", size = 10),
        plot.caption       = element_text(colour = "grey55", size = 8)
      )

    print(p5)
  }
}

Key Findings

  • Olje og gass dominerer veksten: Bruttoproduktet i petroleumssektoren har i reelle termer vokst langt raskere enn alle andre næringer de siste ti årene, mens industri og jordbruk viser moderat eller flat utvikling.
  • Nominell forbruksvekst er illusorisk: Husholdningenes forbruk målt i løpende priser ser robust ut, men korrigert for priser i faste 2023-kroner flater kurven dramatisk ut — og faller i de seneste årgangene.
  • Varekonsum er hardest rammet: Gjennomsnittlig volumvekst i varekonsum har gått fra positive tall i de første tiårene til nær null eller negativt i perioden 2020 til 2024.
  • Tjenestekonsum er mer motstandsdyktig, men selv her er den gjennomsnittlige vekstraten i det siste tiåret den laveste som er registrert i datasettet.
  • Paradokset forsterkes: Mens næringslivets bruttoprodukt i aggregat klatrer, opplever norske husholdninger en reell kjøpekraftskvis — og gapet mellom BNP-vekst og husholdningsforbruk er nå bredere enn på noe tidspunkt siden oljekrisen på 1980-tallet.

Avsluttende refleksjon

Norges økonomi er i praksis to økonomier i én. Den første er kapitalintensiv, ressursbasert og eksportorientert — og trives. Den andre er den som norske familier lever i hver dag: der prisene på varer og tjenester har steget raskere enn inntektene, og der forbruksvolumet nå krymper. Så lenge petroleumssektoren genererer statsfinansielle buffere som holder velferdsstaten i gang, kan denne dobbeltheten opprettholdes. Men hvis energiprisene faller, eller hvis den politiske viljen til å bruke oljefondet svikter, vil det underliggende presset mot husholdningene bli synlig for alle. Nasjonalregnskapet er et advarselsskilt som allerede lyser gult.