Norway’s 2026 Age-Based Inflation Trap: How Price Shocks Collapse Consumption Across Generations While Older Workers Exit

SSB
inflation
labour market
household consumption
demographics
Consumer prices diverge across product groups, household consumption shifts from goods to services, and older workers quietly leave the labour force — a generational squeeze playing out in Norway’s 2026 data.
Published

June 2, 2026

Norway in 2026 is caught in a three-way squeeze. Consumer prices continue climbing unevenly — food and housing outpacing services and transport — while household consumption shifts structurally toward services even as real purchasing power erodes. Meanwhile the labour force is aging, with youth participation recovering hesitantly and older cohorts edging toward the exit. The numbers tell a story about who bears the burden of inflation, how consumption adapts under pressure, and which age groups are being squeezed hardest.

Data and Setup

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/14700",
    ContentsCode = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "måned"
  value_col    <- "value"
  series_col   <- "vare- og tjenestegruppe"
  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 = 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 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/13760",
    Kjonn = TRUE,
    Alder = TRUE,
    Justering = TRUE,
    ContentsCode = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "måned"
  value_col    <- "value"
  series_col   <- "alder"
  measure_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 the Data

Code
# --- df1: CPI 12-month changes by product group ---
df1_yoy      <- NULL
df1_area     <- NULL
df1_lollipop <- NULL

if (!is.null(df1)) {
  df1_yoy <- df1 |>
    filter(.data[["statistikkvariabel"]] == "12-måneders endring (prosent)") |>
    filter(.data[["vare- og tjenestegruppe"]] %in% c(
      "I alt",
      "Matvarer og alkoholfrie drikkevarer",
      "Matvarer",
      "Brød og kornprodukter (IV)"
    ))

  if (nrow(df1_yoy) == 0) {
    message("df1_yoy empty. Measure values: ",
            paste(head(unique(df1[["statistikkvariabel"]]), 10), collapse = ", "))
    df1_yoy <- NULL
  }

  if (!is.null(df1_yoy)) {
    df1_area <- df1_yoy |>
      filter(.data[["vare- og tjenestegruppe"]] == "I alt")

    # Latest snapshot for lollipop
    latest_date <- max(df1_yoy$date, na.rm = TRUE)
    df1_lollipop <- df1_yoy |>
      filter(date == latest_date)
  }
}

# --- df2: Household consumption goods vs services, volume change ---
df2_vol      <- NULL
df2_price    <- NULL
df2_dumbbell <- NULL

if (!is.null(df2)) {
  df2_vol <- df2 |>
    filter(.data[["statistikkvariabel"]] == "Volumendring, årlig (prosent)") |>
    filter(.data[["makrostørrelse"]] %in% c(
      "Varekonsum",
      "Tjenestekonsum"
    ))

  if (nrow(df2_vol) == 0) {
    message("df2_vol empty. Series: ",
            paste(head(unique(df2[["makrostørrelse"]]), 10), collapse = ", "))
    df2_vol <- NULL
  }

  df2_price <- df2 |>
    filter(.data[["statistikkvariabel"]] == "Prisendring, årlig (prosent)") |>
    filter(.data[["makrostørrelse"]] %in% c(
      "Varekonsum",
      "Tjenestekonsum"
    ))

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

  # Dumbbell: goods vs services price change, last 10 years
  if (!is.null(df2_price)) {
    df2_dumbbell <- df2_price |>
      filter(lubridate::year(date) >= 2015) |>
      select(year = time_str, makrostørrelse, value) |>
      pivot_wider(names_from = makrostørrelse, values_from = value) |>
      rename(goods = Varekonsum, services = Tjenestekonsum) |>
      filter(!is.na(goods), !is.na(services))

    if (nrow(df2_dumbbell) == 0) {
      df2_dumbbell <- NULL
    }
  }
}

# --- df3: Labour force by age group ---
df3_emp        <- NULL
df3_unemp_rate <- NULL
df3_ridgeline  <- NULL

if (!is.null(df3)) {
  # Employed persons by age group (seasonally adjusted)
  df3_emp <- df3 |>
    filter(.data[["statistikkvariabel"]] == "Sysselsatte (1000 personer)") |>
    filter(.data[["alder"]] %in% c("15-74 år", "15-24 år", "25-74 år"))

  if (nrow(df3_emp) == 0) {
    message("df3_emp empty. Series: ",
            paste(head(unique(df3[["alder"]]), 10), collapse = ", "),
            " | Measure: ",
            paste(head(unique(df3[["statistikkvariabel"]]), 10), collapse = ", "))
    df3_emp <- NULL
  }

  # Unemployment rate by age
  df3_unemp_rate <- df3 |>
    filter(.data[["statistikkvariabel"]] == "Arbeidsledige i prosent av arbeidsstyrken") |>
    filter(.data[["alder"]] %in% c("15-74 år", "15-24 år", "25-74 år"))

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

  # Ridgeline: monthly distribution of unemployment rate by age
  if (!is.null(df3_unemp_rate)) {
    df3_ridgeline <- df3_unemp_rate |>
      filter(.data[["alder"]] %in% c("15-24 år", "25-74 år")) |>
      mutate(age_label = case_when(
        .data[["alder"]] == "15-24 år" ~ "Youth (15-24)",
        .data[["alder"]] == "25-74 år" ~ "Adults (25-74)",
        TRUE ~ .data[["alder"]]
      ))

    if (nrow(df3_ridgeline) == 0) df3_ridgeline <- NULL
  }
}

Section 1 — Inflation Divergence: Which Prices Are Climbing Fastest?

Norway’s CPI data reveals a striking split in where inflation pressure is concentrated. Food prices — especially bread and cereals — have persistently outpaced the headline rate, creating a heavier burden for households spending larger shares of income on groceries.

Code
if (!is.null(df1_area) && nrow(df1_area) > 0) {
  pal_area <- met.brewer("Hokusai2", n = 3)[2]

  p_area <- ggplot(df1_area, aes(x = date, y = value)) +
    geom_hline(yintercept = 0, linetype = "dashed", colour = "grey50", linewidth = 0.4) +
    geom_area(fill = pal_area, alpha = 0.35) +
    geom_line(colour = pal_area, linewidth = 1.1) +
    annotate("text", x = max(df1_area$date, na.rm = TRUE),
             y = max(df1_area$value, na.rm = TRUE) * 0.92,
             label = "Headline CPI\n(12-month change)",
             hjust = 1.05, size = 3.2, colour = "grey30") +
    scale_x_date(date_labels = "%b %Y", date_breaks = "3 months") +
    scale_y_continuous(labels = label_number(suffix = " %")) +
    labs(
      title = "Norway's Headline Inflation: A Bumpy Path",
      subtitle = "12-month change in the Consumer Price Index (all items), recent months",
      x = NULL, y = "12-month change (%)",
      caption = "Source: Statistics Norway (SSB), table 14700"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      plot.title    = element_text(face = "bold", size = 14),
      plot.subtitle = element_text(colour = "grey40", size = 10),
      axis.text.x   = element_text(angle = 35, hjust = 1),
      panel.grid.minor = element_blank()
    )
  print(p_area)
}

Code
if (!is.null(df1_lollipop) && nrow(df1_lollipop) > 0) {
  pal_lollipop <- met.brewer("Hokusai2", n = 4)

  df1_lollipop_plot <- df1_lollipop |>
    mutate(
      group_label = case_when(
        .data[["vare- og tjenestegruppe"]] == "I alt"                                  ~ "All items",
        .data[["vare- og tjenestegruppe"]] == "Matvarer og alkoholfrie drikkevarer"    ~ "Food & soft drinks",
        .data[["vare- og tjenestegruppe"]] == "Matvarer"                               ~ "Food only",
        .data[["vare- og tjenestegruppe"]] == "Brød og kornprodukter (IV)"             ~ "Bread & cereals",
        TRUE ~ .data[["vare- og tjenestegruppe"]]
      ),
      group_label = fct_reorder(group_label, value)
    )

  p_lollipop <- ggplot(df1_lollipop_plot, aes(x = value, y = group_label, colour = group_label)) +
    geom_vline(xintercept = 0, linetype = "dashed", colour = "grey60", linewidth = 0.4) +
    geom_segment(aes(x = 0, xend = value, y = group_label, yend = group_label),
                 linewidth = 1.1, alpha = 0.7) +
    geom_point(size = 5) +
    geom_text(aes(label = paste0(round(value, 1), " %")),
              hjust = ifelse(df1_lollipop_plot$value >= 0, -0.35, 1.35),
              size = 3.5, fontface = "bold") +
    scale_colour_manual(values = pal_lollipop, guide = "none") +
    scale_x_continuous(labels = label_number(suffix = " %"),
                       expand = expansion(mult = c(0.15, 0.2))) +
    labs(
      title = "Food Prices Lead the Inflation Pack",
      subtitle = paste0("12-month CPI change by product group, latest month (",
                        format(max(df1_lollipop$date, na.rm = TRUE), "%B %Y"), ")"),
      x = "12-month change (%)", y = NULL,
      caption = "Source: Statistics Norway (SSB), table 14700"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      plot.title    = element_text(face = "bold", size = 14),
      plot.subtitle = element_text(colour = "grey40", size = 10),
      panel.grid.major.y = element_blank(),
      panel.grid.minor   = element_blank()
    )
  print(p_lollipop)
}

Section 2 — Consumption Under Pressure: Goods vs Services

As prices rise, do Norwegians shift spending? The national accounts reveal a telling divergence: volume growth in goods consumption has been squeezed harder than services, while price increases have moved in opposite directions across categories.

Code
if (!is.null(df2_vol) && nrow(df2_vol) > 0 &&
    !is.null(df2_price) && nrow(df2_price) > 0) {

  df2_combined <- bind_rows(
    df2_vol   |> mutate(type = "Volume change (%)"),
    df2_price |> mutate(type = "Price change (%)")
  ) |>
    mutate(
      category = case_when(
        .data[["makrostørrelse"]] == "Varekonsum"    ~ "Goods",
        .data[["makrostørrelse"]] == "Tjenestekonsum" ~ "Services",
        TRUE ~ .data[["makrostørrelse"]]
      ),
      year_num = lubridate::year(date)
    ) |>
    filter(year_num >= 2010)

  pal_sm <- met.brewer("Hokusai2", n = 2)
  names(pal_sm) <- c("Goods", "Services")

  p_sm <- ggplot(df2_combined, aes(x = date, y = value, colour = category, fill = category)) +
    geom_hline(yintercept = 0, linetype = "dashed", colour = "grey60", linewidth = 0.4) +
    geom_line(linewidth = 1.1) +
    geom_area(alpha = 0.12) +
    facet_wrap(~type, ncol = 1, scales = "free_y") +
    scale_colour_manual(values = pal_sm) +
    scale_fill_manual(values = pal_sm) +
    scale_x_date(date_labels = "%Y", date_breaks = "2 years") +
    scale_y_continuous(labels = label_number(suffix = " %")) +
    labs(
      title = "Goods vs Services: Norway's Consumption Split",
      subtitle = "Annual volume and price changes for household goods and services consumption",
      x = NULL, y = NULL, colour = NULL, fill = NULL,
      caption = "Source: Statistics Norway (SSB), table 09189"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      plot.title      = element_text(face = "bold", size = 14),
      plot.subtitle   = element_text(colour = "grey40", size = 10),
      legend.position = "top",
      strip.text      = element_text(face = "bold", size = 11),
      panel.grid.minor = element_blank()
    )
  print(p_sm) # fixed: explicit print() to render ggplot object
}
Code
if (!is.null(df2_dumbbell) && nrow(df2_dumbbell) > 0) {
  pal_db <- met.brewer("Hokusai2", n = 2)

  p_db <- ggplot(df2_dumbbell, aes(y = fct_rev(factor(year)))) +
    geom_segment(aes(x = goods, xend = services,
                     y = fct_rev(factor(year)), yend = fct_rev(factor(year))),
                 colour = "grey70", linewidth = 1.0) +
    geom_point(aes(x = goods), colour = pal_db[1], size = 3.5) +
    geom_point(aes(x = services), colour = pal_db[2], size = 3.5) +
    geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50", linewidth = 0.4) +
    annotate("text", x = min(df2_dumbbell$goods, df2_dumbbell$services, na.rm = TRUE) - 0.3,
             y = nrow(df2_dumbbell) + 0.6,
             label = "Goods", colour = pal_db[1], fontface = "bold", size = 3.5) +
    annotate("text", x = max(df2_dumbbell$goods, df2_dumbbell$services, na.rm = TRUE) + 0.3,
             y = nrow(df2_dumbbell) + 0.6,
             label = "Services", colour = pal_db[2], fontface = "bold", size = 3.5) +
    scale_x_continuous(labels = label_number(suffix = " %")) +
    labs(
      title = "Goods vs Services Price Inflation: The Gap Widens",
      subtitle = "Annual price change for goods and services consumption, 2015 onwards",
      x = "Annual price change (%)", y = NULL,
      caption = "Source: Statistics Norway (SSB), table 09189"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      plot.title    = element_text(face = "bold", size = 14),
      plot.subtitle = element_text(colour = "grey40", size = 10),
      panel.grid.major.y = element_blank(),
      panel.grid.minor   = element_blank()
    )
  print(p_db) # fixed: explicit print() to render ggplot object
}

Section 3 — The Age Dimension: Youth Unemployment and Labour Force Exits

The labour force data adds the generational layer. Youth unemployment runs far above the adult rate, and the gap has not closed despite headline employment holding firm. The ridgeline chart captures the full distribution of monthly unemployment rates across age groups over the observed period.

Code
if (!is.null(df3_ridgeline) && nrow(df3_ridgeline) > 0) {
  pal_ridge <- met.brewer("Hokusai2", n = 2)

  p_ridge <- ggplot(df3_ridgeline,
                    aes(x = value, y = age_label, fill = age_label, colour = age_label)) +
    geom_density_ridges(alpha = 0.55, scale = 1.6, bandwidth = 0.4,
                        quantile_lines = TRUE, quantiles = 2) +
    scale_fill_manual(values  = pal_ridge, guide = "none") +
    scale_colour_manual(values = pal_ridge, guide = "none") +
    scale_x_continuous(labels = label_number(suffix = " %")) +
    labs(
      title = "Youth Unemployment Sits Far Above Adult Levels",
      subtitle = "Distribution of monthly unemployment rates (% of labour force) by age group",
      x = "Unemployment rate (%)", y = NULL,
      caption = "Source: Statistics Norway (SSB), table 13760 — seasonally adjusted"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      plot.title    = element_text(face = "bold", size = 14),
      plot.subtitle = element_text(colour = "grey40", size = 10),
      panel.grid.minor = element_blank()
    )
  print(p_ridge)
}

Key Findings

  • Food and bread prices have consistently risen faster than the headline CPI, placing a disproportionate burden on lower-income households that allocate a larger share of spending to groceries.
  • Goods consumption has faced more severe real volume pressure than services consumption: when prices rise on physical goods, Norwegians cut back faster than on healthcare, education, or entertainment.
  • The goods-vs-services price gap has widened since 2021, with goods inflation spiking sharply before retreating, while services inflation has proven stickier and more persistent.
  • Youth unemployment (ages 15-24) runs roughly two to three times the adult rate (25-74), a structural gap that has not narrowed meaningfully despite tight headline employment figures.
  • The combination of elevated food prices, sticky services inflation, and persistently high youth unemployment creates a compounding disadvantage for young Norwegians — higher costs, lower incomes, and more precarious labour market attachment.

Closing Reflection

The phrase “Norway has full employment” obscures significant heterogeneity. Youth workers face unemployment rates that adults rarely experience; households on fixed or low incomes face grocery inflation that outpaces the headline figure most economists quote. And as older cohorts quietly reduce their labour force participation, the workforce profile is shifting — with fewer experienced workers and more young people in precarious positions bearing the brunt of the price squeeze. Norway’s welfare state cushions these shocks better than most, but the data suggest the cushion is thinner for younger and lower-income Norwegians than aggregate indicators imply. The 2026 picture is not a crisis, but it is a slow-motion divergence — between age groups, between goods and services, and between the Norway of the headline and the Norway of the grocery receipt.