Norway’s 2026 Labour-Inflation Squeeze: Youth, Food, and Housing Costs in the Vise

SSB
labour market
inflation
youth
housing
As unemployment stays low and employment rates edge up, Norwegian households face a simultaneous surge in food and housing prices — a squeeze that falls hardest on younger age groups.
Published

May 27, 2026

The Uncomfortable Truth Behind Norway’s Strong Employment Numbers

Norway’s headline labour statistics look reassuring: unemployment hovers near historic lows, and employment rates remain among the highest in Europe. But dig into the monthly and quarterly data from Statistics Norway and a more uncomfortable picture emerges. At the same time that more Norwegians are working, the prices they pay for food, transport and housing have surged — eroding the real value of those paycheques. For younger workers entering the labour market, the arithmetic is particularly punishing.

This post combines three SSB datasets — monthly labour force adjustments, quarterly employment status by age, and the consumer price index — to trace how the squeeze has tightened through 2025 and into 2026.


Data

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

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

# --- df1: table 13760 monthly labour force indicators ---
df1 <- 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"
  measure_col  <- "type justering"
  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: table 05110 quarterly employment status ---
df2 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/05110",
    arbeidsstyrkestatus = TRUE,
    kjønn = TRUE,
    alder = TRUE,
    statistikkvariabel = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "kvartal"
  value_col    <- "value"
  series_col   <- "arbeidsstyrkestatus"
  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: table 03013 monthly CPI ---
df3 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/03013",
    konsumgruppe = TRUE,
    statistikkvariabel = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "måned"
  value_col    <- "value"
  series_col   <- "konsumgruppe"
  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

Code
# ---- df1 wrangling: monthly unemployment trend ----
df1_unemp   <- NULL
df1_emp     <- NULL
df1_wide    <- NULL

if (!is.null(df1)) {
  # Unemployment rate, trend
  df1_unemp <- df1 |>
    filter(
      .data[["statistikkvariabel"]] == "Arbeidsledige i prosent av arbeidsstyrken",
      .data[["type justering"]]     == "Trend"
    )
  if (nrow(df1_unemp) == 0) {
    message("df1_unemp empty. statistikkvariabel values: ",
            paste(head(unique(df1[["statistikkvariabel"]]), 10), collapse = ", "))
    df1_unemp <- NULL
  }

  # Employment rate as % of population, trend
  df1_emp <- df1 |>
    filter(
      .data[["statistikkvariabel"]] == "Sysselsatte i prosent av befolkningen",
      .data[["type justering"]]     == "Trend"
    )
  if (nrow(df1_emp) == 0) {
    message("df1_emp empty. statistikkvariabel values: ",
            paste(head(unique(df1[["statistikkvariabel"]]), 10), collapse = ", "))
    df1_emp <- NULL
  }

  # Combined for dual-series area chart
  if (!is.null(df1_unemp) && !is.null(df1_emp)) {
    df1_wide <- bind_rows(
      df1_unemp |> mutate(indicator = "Unemployment rate (%)"),
      df1_emp   |> mutate(indicator = "Employment rate (% of pop.)")
    ) |>
      filter(!is.na(kjønn) | TRUE) |>   # keep all sex categories
      # Keep total (both sexes) if alder/kjønn aggregates available
      filter(kjønn == "Begge kjønn" | kjønn == "I alt" |
               !(kjønn %in% c("Menn", "Kvinner")))
  }
}

# ---- df2 wrangling: quarterly unemployment by age group ----
df2_unemp_age  <- NULL
df2_dumbbell   <- NULL

if (!is.null(df2)) {
  df2_unemp_age <- df2 |>
    filter(
      .data[["arbeidsstyrkestatus"]] == "Arbeidsledige",
      .data[["statistikkvariabel"]]  == "Personer (prosent)"
    )
  if (nrow(df2_unemp_age) == 0) {
    message("df2_unemp_age empty. series values: ",
            paste(head(unique(df2[["arbeidsstyrkestatus"]]), 10), collapse = ", "))
    df2_unemp_age <- NULL
  }

  # Dumbbell: compare earliest vs latest quarter, by age group (total sex)
  if (!is.null(df2_unemp_age) && nrow(df2_unemp_age) > 0) {
    age_totals <- df2_unemp_age |>
      filter(kjønn == "Begge kjønn" | kjønn == "I alt" |
               !(kjønn %in% c("Menn", "Kvinner")))

    if (nrow(age_totals) > 0) {
      q_range   <- range(age_totals$date, na.rm = TRUE)
      q_early   <- age_totals |> filter(date == q_range[1])
      q_late    <- age_totals |> filter(date == q_range[2])

      df2_dumbbell <- inner_join(
        q_early |> select(alder, value) |> rename(early = value),
        q_late  |> select(alder, value) |> rename(late  = value),
        by = "alder"
      ) |>
        mutate(
          change = late - early,
          alder  = fct_reorder(alder, late)
        ) |>
        filter(!is.na(early), !is.na(late))
    }
  }
}

# ---- df3 wrangling: CPI 12-month change by category ----
df3_12m      <- NULL
df3_heatmap  <- NULL
df3_total    <- NULL
df3_latest   <- NULL

if (!is.null(df3)) {
  df3_12m <- df3 |>
    filter(.data[["statistikkvariabel"]] == "12-måneders endring (prosent)") |>
    filter(.data[["konsumgruppe"]] %in% c(
      "Totalindeks",
      "Matvarer og alkoholfrie drikkevarer",
      "Bolig, lys og brensel",
      "Transport",
      "Helsepleie"
    ))
  if (nrow(df3_12m) == 0) {
    message("df3_12m empty. konsumgruppe values: ",
            paste(head(unique(df3[["konsumgruppe"]]), 15), collapse = ", "))
    df3_12m <- NULL
  }

  # Heatmap data: all categories, 12m change
  df3_heatmap <- df3_12m

  # Total index for area chart overlay
  df3_total <- df3 |>
    filter(
      .data[["statistikkvariabel"]] == "12-måneders endring (prosent)",
      .data[["konsumgruppe"]]       == "Totalindeks"
    )
  if (nrow(df3_total) == 0) {
    message("df3_total empty.")
    df3_total <- NULL
  }
}

The Trend Line: Employment Up, Unemployment Stubbornly Low — but the Picture Is Incomplete

Norway’s trend unemployment rate has remained remarkably compressed across the entire 40-month window available from SSB. Employment as a share of the population has nudged upward. On the surface, the labour market looks healthy. But the aggregate conceals who is working and at what wages — and it says nothing about what workers pay for the basics.

Code
if (!is.null(df1_wide) && nrow(df1_wide) > 0) {
  pal <- met.brewer("Hiroshige", n = 2)

  p <- ggplot(df1_wide, aes(x = date, y = value, colour = indicator, fill = indicator)) +
    geom_area(alpha = 0.18, position = "identity") +
    geom_line(linewidth = 1.1) +
    scale_colour_manual(values = pal) +
    scale_fill_manual(values = pal) +
    scale_x_date(date_breaks = "6 months", date_labels = "%b %Y") +
    scale_y_continuous(labels = label_number(suffix = "%")) +
    labs(
      title    = "Employment holds firm, unemployment stays compressed",
      subtitle = "Trend estimates: employment as % of population (upper band) vs unemployment rate (lower band)",
      x        = NULL,
      y        = "Per cent",
      colour   = NULL,
      fill     = NULL,
      caption  = "Source: SSB table 13760"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      legend.position  = "top",
      axis.text.x      = element_text(angle = 35, hjust = 1),
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold")
    )
  print(p) # fixed: added explicit print() to ensure plot is rendered
} else {
  message("df1_wide is NULL or empty — skipping area labour chart.")
}

The Age Dimension: Youth Bear a Disproportionate Unemployment Burden

Aggregate statistics mask a persistent structural divide: younger Norwegians face unemployment rates far above the national average. The dumbbell chart below compares age-group unemployment rates (as a share of the labour force in each group) between the earliest and latest available quarters in the SSB dataset.

Code
if (!is.null(df2_dumbbell) && nrow(df2_dumbbell) > 0) {
  pal2 <- met.brewer("Hiroshige", n = 3)
  col_early <- pal2[1]
  col_late  <- pal2[3]

  p2 <- ggplot(df2_dumbbell, aes(y = alder)) +
    geom_segment(
      aes(x = early, xend = late, yend = alder),
      colour = "grey70", linewidth = 1.2
    ) +
    geom_point(aes(x = early), colour = col_early, size = 4) +
    geom_point(aes(x = late),  colour = col_late,  size = 4) +
    geom_text(
      aes(x = late, label = sprintf("%.1f%%", late)),
      nudge_x = 0.25, size = 3.2, colour = col_late
    ) +
    annotate(
      "point", x = -Inf, y = -Inf, colour = col_early, size = 4
    ) +
    scale_x_continuous(labels = label_number(suffix = "%")) +
    labs(
      title    = "Youth unemployment remains well above the national average",
      subtitle = paste0(
        "Circles: earliest quarter (", pal2[1], " = older) vs latest quarter (darker) — unemployed as % of age-group labour force"
      ),
      x        = "Unemployment rate (%)",
      y        = "Age group",
      caption  = "Source: SSB table 05110"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold")
    )
  print(p2) # fixed: added explicit print() to ensure plot is rendered
} else {
  message("df2_dumbbell is NULL or empty — skipping dumbbell chart.")
}

The Price Squeeze: Food and Housing Costs Surge Month After Month

While jobs remain plentiful, the cost of living has climbed relentlessly. The ridgeline chart below shows the distribution of 12-month inflation readings across four key spending categories — food, housing and energy, transport, and healthcare — over the most recent 40 months. Each ridge represents how often each inflation rate was recorded, revealing not just averages but the spread and persistence of price pressure.

Code
if (!is.null(df3_12m) && nrow(df3_12m) > 0) {
  df3_ridge <- df3_12m |>
    filter(konsumgruppe != "Totalindeks") |>
    mutate(
      category = case_when(
        konsumgruppe == "Matvarer og alkoholfrie drikkevarer" ~ "Food & soft drinks",
        konsumgruppe == "Bolig, lys og brensel"              ~ "Housing, energy & fuel",
        konsumgruppe == "Transport"                           ~ "Transport",
        konsumgruppe == "Helsepleie"                         ~ "Healthcare",
        TRUE ~ konsumgruppe
      ),
      category = fct_reorder(category, value, .fun = median, na.rm = TRUE)
    ) |>
    filter(!is.na(value))

  if (nrow(df3_ridge) > 0) {
    pal3 <- met.brewer("Hiroshige", n = 4)

    p3 <- ggplot(df3_ridge, aes(x = value, y = category, fill = category)) +
      geom_density_ridges(
        alpha = 0.75, scale = 1.2,
        quantile_lines = TRUE, quantiles = 2,
        colour = "white"
      ) +
      scale_fill_manual(values = pal3) +
      scale_x_continuous(labels = label_number(suffix = "%")) +
      geom_vline(xintercept = 0, linetype = "dashed", colour = "grey40") +
      labs(
        title    = "Housing and food prices dominate the inflation experience",
        subtitle = "Distribution of 12-month CPI change readings per category, last 40 months. Vertical line = median.",
        x        = "12-month price change (%)",
        y        = NULL,
        fill     = NULL,
        caption  = "Source: SSB table 03013"
      ) +
      theme_minimal(base_size = 12) +
      theme(
        legend.position  = "none",
        panel.grid.minor = element_blank(),
        plot.title       = element_text(face = "bold")
      )
    print(p3) # fixed: added explicit print() to ensure plot is rendered
  } else {
    message("df3_ridge empty after filtering — skipping ridgeline.")
  }
} else {
  message("df3_12m is NULL or empty — skipping ridgeline chart.")
}

The Monthly Inflation Path: A Heatmap of Category Price Pressure Over Time

To see exactly when and where price pressure intensified, the heatmap below maps 12-month inflation for each category across every month in the dataset. Dark colours signal the hottest price surges; pale or negative readings mark relief periods.

Code
if (!is.null(df3_heatmap) && nrow(df3_heatmap) > 0) {
  df3_heat <- df3_heatmap |>
    filter(konsumgruppe != "Totalindeks") |>
    mutate(
      category = case_when(
        konsumgruppe == "Matvarer og alkoholfrie drikkevarer" ~ "Food & soft drinks",
        konsumgruppe == "Bolig, lys og brensel"              ~ "Housing, energy & fuel",
        konsumgruppe == "Transport"                           ~ "Transport",
        konsumgruppe == "Helsepleie"                         ~ "Healthcare",
        TRUE ~ konsumgruppe
      ),
      month_label = format(date, "%b %Y")
    ) |>
    filter(!is.na(value))

  if (nrow(df3_heat) > 0) {
    # Order dates chronologically on x-axis
    date_order   <- df3_heat |> distinct(date, month_label) |> arrange(date) |> pull(month_label)
    df3_heat     <- df3_heat |>
      mutate(month_label = factor(month_label, levels = date_order))

    p4 <- ggplot(df3_heat, aes(x = month_label, y = category, fill = value)) +
      geom_tile(colour = "white", linewidth = 0.3) +
      scale_fill_gradientn(
        colours  = c("#2166ac", "#f7f7f7", "#d6604d", "#b2182b"),
        values   = scales::rescale(c(min(df3_heat$value, na.rm = TRUE), 0, 5, max(df3_heat$value, na.rm = TRUE))),
        name     = "12-month\nchange (%)",
        labels   = label_number(suffix = "%")
      ) +
      scale_x_discrete(breaks = date_order[seq(1, length(date_order), by = 4)]) +
      labs(
        title    = "Price pressure concentrated in housing and food — then spread to transport",
        subtitle = "12-month CPI change by category and month. Red = strong inflation; blue = deflation.",
        x        = NULL,
        y        = NULL,
        caption  = "Source: SSB table 03013"
      ) +
      theme_minimal(base_size = 11) +
      theme(
        axis.text.x      = element_text(angle = 40, hjust = 1, size = 9),
        panel.grid       = element_blank(),
        plot.title       = element_text(face = "bold"),
        legend.position  = "right"
      )
    print(p4) # fixed: added explicit print() to ensure plot is rendered
  } else {
    message("df3_heat empty — skipping heatmap.")
  }
} else {
  message("df3_heatmap is NULL or empty — skipping heatmap.")
}

The Lollipop: Most Recent Month’s Inflation Snapshot

A concise lollipop chart captures the latest available 12-month price change across categories, anchoring the analysis in the most current data point.

Code
# fixed: initialize df3_latest at top of chunk, before any conditional logic
df3_latest <- NULL

if (!is.null(df3_12m) && nrow(df3_12m) > 0) {
  latest_date <- max(df3_12m$date, na.rm = TRUE)
  df3_latest  <- df3_12m |>
    filter(date == latest_date) |>
    mutate(
      category = case_when(
        konsumgruppe == "Matvarer og alkoholfrie drikkevarer" ~ "Food & soft drinks",
        konsumgruppe == "Bolig, lys og brensel"              ~ "Housing, energy & fuel",
        konsumgruppe == "Transport"                           ~ "Transport",
        konsumgruppe == "Helsepleie"                         ~ "Healthcare",
        konsumgruppe == "Totalindeks"                        ~ "Total CPI",
        TRUE ~ konsumgruppe
      ),
      category    = fct_reorder(category, value),
      positive    = value >= 0
    ) |>
    filter(!is.na(value))
}

if (!is.null(df3_latest) && nrow(df3_latest) > 0) {
  pal5     <- met.brewer("Hiroshige", n = 5)
  col_pos  <- pal5[5]
  col_neg  <- pal5[1]

  p5 <- ggplot(df3_latest, aes(x = value, y = category, colour = positive)) +
    geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50") +
    geom_segment(
      aes(x = 0, xend = value, yend = category),
      linewidth = 1.2
    ) +
    geom_point(size = 5) +
    geom_text(
      aes(label = sprintf("%+.1f%%", value)),
      nudge_x = ifelse(df3_latest$value >= 0, 0.15, -0.15),
      hjust   = ifelse(df3_latest$value >= 0, 0, 1),
      size    = 3.8, fontface = "bold"
    ) +
    scale_colour_manual(values = c("TRUE" = col_pos, "FALSE" = col_neg)) +
    scale_x_continuous(labels = label_number(suffix = "%"), expand = expansion(mult = c(0.15, 0.15))) +
    labs(
      title    = paste0("Food and housing remain the sharpest price pressures — ", format(max(df3_latest$date, na.rm = TRUE), "%B %Y")),
      subtitle = "12-month CPI change by spending category in the most recent available month",
      x        = "12-month change (%)",
      y        = NULL,
      caption  = "Source: SSB table 03013",
      colour   = NULL
    ) +
    theme_minimal(base_size = 12) +
    theme(
      legend.position  = "none",
      panel.grid.minor = element_blank(),
      panel.grid.major.y = element_blank(),
      plot.title       = element_text(face = "bold")
    )
  print(p5) # fixed: added explicit print() to ensure plot is rendered
} else {
  message("df3_latest is NULL or empty — skipping lollipop chart.")
}

Key Findings

  • Unemployment is low but not evenly distributed. Trend data from SSB table 13760 confirms the aggregate rate remains compressed, yet quarterly breakdowns (table 05110) show youth age groups face unemployment rates consistently two to three times the national headline figure.

  • Food and housing costs have been the most persistent inflators. Across the 40-month window in the CPI dataset, both categories recorded above-average 12-month inflation in nearly every month — with housing, energy and fuel swinging wildly, dragging the total index sharply higher during energy price spikes.

  • Transport inflation is increasingly a secondary pressure. The heatmap reveals that while food and housing initiated the inflation surge, transport costs have added a third layer of pressure on household budgets from 2024 onward.

  • Healthcare inflation has been relatively mild. Healthcare (helsepleie) displayed the most contained price increases among the four categories tracked — offering limited relief given its smaller share of typical household spending.

  • The squeeze is structural, not cyclical. The combination of persistently high employment-to-population ratios and sustained above-target inflation across key categories suggests the squeeze on real incomes is not a transient shock but a durable feature of the 2024–2026 Norwegian economic landscape.


Closing Reflection

Norway’s macroeconomic surface — high employment, low unemployment — masks a household-level reality that is considerably harsher. The price of food in a Norwegian supermarket, the cost of heating an apartment, the fuel bill for a commute: all have climbed faster than wages for many workers, particularly the young. Statistics Norway’s data do not yet show a labour market cracking under the pressure, but they clearly show the vice tightening. If wage negotiations in 2026 fail to deliver real increases, the squeeze will deepen — and the workers most exposed will be those who entered the market just as the inflation era began.