Norway’s 2026 Construction Squeeze: How Housing Supply Collapses While Labour Markets Remain Strained

SSB
housing
labour-market
construction
regional
New dwelling starts have cratered across Norwegian regions while the labour force remains near capacity, exposing a deepening structural mismatch between housing supply and economic demand.
Published

June 7, 2026

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)
library(stringr)

df1 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/06265",
    Region = TRUE,
    BygnType = TRUE,
    ContentsCode = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "år"
  value_col    <- "value"
  series_col   <- "bygningstype"
  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/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   <- "statistikkvariabel"
  measure_col  <- "type justering"
  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 }

A Market Pulling in Two Directions

Norway’s economy in 2026 presents a paradox that is increasingly difficult to ignore. The labour market has held remarkably firm — employment rolls remain near historic highs — yet the construction sector is mired in one of its sharpest downturns in a decade. Fewer new homes are being started, apartment block activity has collapsed relative to recent peaks, and regional disparities in building activity are widening. The country has jobs but is failing to build the homes its workforce needs.

This post examines the two datasets that capture this tension most sharply: SSB’s records of new dwelling starts and completions by building type and region, and the monthly seasonally adjusted labour force survey tracking employment, unemployment, and participation rates through early 2026.


Data

Code
# ── df1: housing construction ────────────────────────────────────────────────
df1_national  <- NULL
df1_type      <- NULL
df1_region    <- NULL
df1_slope     <- NULL

if (!is.null(df1)) {
  # Identify key columns
  region_col <- "region"
  type_col   <- "bygningstype"
  content_col <- "statistikkvariabel"

  # National totals by building type (all regions aggregated)
  df1_type <- df1 |>
    filter(!is.na(value)) |>
    group_by(date, time_str, .data[[type_col]], .data[[content_col]]) |>
    summarise(value = sum(value, na.rm = TRUE), .groups = "drop") |>
    rename(bygningstype = all_of(type_col),
           statistikkvariabel = all_of(content_col))

  # Only "Igangsatte boliger" (started) if it exists; otherwise use first content
  available_content <- unique(df1_type$statistikkvariabel)
  chosen_content <- if (any(str_detect(available_content, "Igangsa"))) {
    available_content[str_detect(available_content, "Igangsa")][1]
  } else {
    available_content[1]
  }

  df1_national <- df1_type |>
    filter(statistikkvariabel == chosen_content)

  # Regional breakdown: sum across building types, for starts
  df1_region <- df1 |>
    filter(!is.na(value)) |>
    rename(bygningstype = all_of(type_col),
           statistikkvariabel = all_of(content_col)) |>
    filter(statistikkvariabel == chosen_content) |>
    group_by(date, time_str, region) |>
    summarise(value = sum(value, na.rm = TRUE), .groups = "drop")

  # Slope: compare first vs last year available, by building type
  years_avail <- sort(unique(df1_national$time_str))
  if (length(years_avail) >= 2) {
    yr_first <- years_avail[1]
    yr_last  <- years_avail[length(years_avail)]
    df1_slope <- df1_national |>
      filter(time_str %in% c(yr_first, yr_last)) |>
      mutate(period = if_else(time_str == yr_first, "Start", "End"))
  }
}

# ── df2: labour force ────────────────────────────────────────────────────────
df2_employed    <- NULL
df2_unemp_rate  <- NULL
df2_ridgeline   <- NULL

if (!is.null(df2)) {
  series_col2  <- "statistikkvariabel"
  measure_col2 <- "type justering"

  # Seasonally adjusted employed (total, both sexes, all ages)
  df2_employed <- df2 |>
    filter(
      .data[[series_col2]]  == "Sysselsatte (1000 personer)",
      .data[[measure_col2]] == "Sesongjustert"
    ) |>
    group_by(date, time_str) |>
    summarise(value = sum(value, na.rm = TRUE), .groups = "drop") |>
    arrange(date)

  if (nrow(df2_employed) == 0) {
    message("df2_employed empty. series values: ",
            paste(head(unique(df2[[series_col2]]), 10), collapse = ", "))
    df2_employed <- NULL
  }

  # Seasonally adjusted unemployment rate, for ridgeline by age/gender
  df2_unemp_rate <- df2 |>
    filter(
      .data[[series_col2]]  == "Arbeidsledige i prosent av arbeidsstyrken",
      .data[[measure_col2]] == "Sesongjustert"
    )

  if (nrow(df2_unemp_rate) == 0) {
    message("df2_unemp_rate empty.")
    df2_unemp_rate <- NULL
  } else {
    # Keep relevant age/gender columns
    df2_unemp_rate <- df2_unemp_rate |>
      filter(!is.na(value))
  }

  # For ridgeline: employed by age group, seasonally adjusted
  df2_ridgeline <- df2 |>
    filter(
      .data[[series_col2]]  == "Sysselsatte (1000 personer)",
      .data[[measure_col2]] == "Sesongjustert"
    ) |>
    filter(!is.na(value))

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

Part 1 — Construction by Building Type: The Collapse in Context

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

  # Clean building type labels for display
  df1_national <- df1_national |>
    mutate(type_label = case_when(
      str_detect(bygningstype, "Enebolig")          ~ "Detached houses",
      str_detect(bygningstype, "Tomannsbolig")      ~ "Semi-detached",
      str_detect(bygningstype, "Rekkehus")          ~ "Terraced / row",
      str_detect(bygningstype, "Boligblokk")        ~ "Apartment blocks",
      str_detect(bygningstype, "bofellesskap")      ~ "Collective dwellings",
      TRUE                                           ~ bygningstype
    ))

  pal <- met.brewer("Hokusai2", n = length(unique(df1_national$type_label)))

  p <- ggplot(df1_national, aes(x = date, y = value,
                                 fill = type_label, colour = type_label)) +
    geom_area(alpha = 0.75, position = "stack") +
    scale_fill_manual(values = pal, name = "Building type") +
    scale_colour_manual(values = pal, name = "Building type") +
    scale_y_continuous(labels = comma_format()) +
    scale_x_date(date_labels = "%Y", date_breaks = "1 year") +
    labs(
      title    = "New dwellings started, by building type",
      subtitle = "Apartment blocks drove the pre-2023 surge; all types now declining",
      caption  = "Source: Statistics Norway, table 06265",
      x = NULL, y = "Dwellings started"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      legend.position  = "bottom",
      axis.text.x      = element_text(angle = 45, hjust = 1),
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold"),
      plot.subtitle    = element_text(colour = "grey40"),
      plot.caption     = element_text(colour = "grey55", size = 8)
    )

  print(p)
} else {
  message("df1_national is NULL or empty — skipping area chart.")
}


Part 2 — Regional Housing Starts: Who Is Building and Who Has Stopped

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

  # Summarise to latest year available for each region
  latest_year <- max(df1_region$time_str, na.rm = TRUE)
  prev_year   <- sort(unique(df1_region$time_str), decreasing = TRUE)[
    min(2, length(unique(df1_region$time_str)))
  ]

  df_latest <- df1_region |>
    filter(time_str == latest_year) |>
    group_by(region) |>
    summarise(value_latest = sum(value, na.rm = TRUE), .groups = "drop")

  df_prev <- df1_region |>
    filter(time_str == prev_year) |>
    group_by(region) |>
    summarise(value_prev = sum(value, na.rm = TRUE), .groups = "drop")

  df_lollipop <- df_latest |>
    left_join(df_prev, by = "region") |>
    mutate(
      change     = value_latest - value_prev,
      pct_change = (value_latest - value_prev) / pmax(value_prev, 1) * 100,
      region     = str_trunc(region, 35),
      direction  = if_else(change >= 0, "Increase", "Decline")
    ) |>
    filter(!str_detect(region, "^0")) |>   # remove aggregate codes if present
    arrange(pct_change) |>
    # fixed: remove duplicates in region factor levels by using distinct
    distinct(region, .keep_all = TRUE) |>
    mutate(region = factor(region, levels = region))

  if (nrow(df_lollipop) == 0) {
    message("df_lollipop empty after filtering.")
  } else {
    pal2 <- c("Decline" = "#C94040", "Increase" = "#3A7D5E")

    p2 <- ggplot(df_lollipop, aes(x = pct_change, y = region, colour = direction)) +
      geom_vline(xintercept = 0, linetype = "dashed", colour = "grey60", linewidth = 0.6) +
      geom_segment(aes(x = 0, xend = pct_change, y = region, yend = region),
                   linewidth = 0.8) +
      geom_point(size = 3.5) +
      scale_colour_manual(values = pal2, name = NULL) +
      scale_x_continuous(labels = function(x) paste0(x, "%")) +
      labs(
        title    = paste0("Change in housing starts by region: ", prev_year, " to ", latest_year),
        subtitle = "Most regions recorded sharp declines; few bucked the trend",
        caption  = "Source: Statistics Norway, table 06265",
        x = "Percentage change in dwelling starts", y = NULL
      ) +
      theme_minimal(base_size = 11) +
      theme(
        legend.position  = "bottom",
        panel.grid.major.y = element_blank(),
        panel.grid.minor   = element_blank(),
        plot.title         = element_text(face = "bold"),
        plot.subtitle      = element_text(colour = "grey40"),
        plot.caption       = element_text(colour = "grey55", size = 8)
      )

    # fixed: added explicit print() to ensure figure renders
    print(p2)
  }
} else {
  message("df1_region is NULL or empty — skipping lollipop chart.")
}


Part 4 — Labour Force: Employment Holds While Construction Falters

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

  # Compute a rolling context: highlight post-2022 period
  cutoff <- as.Date("2022-01-01")

  df2_employed <- df2_employed |>
    mutate(era = if_else(date >= cutoff, "Construction squeeze era", "Pre-squeeze"))

  # Peak employment
  peak_row <- df2_employed |> slice_max(value, n = 1)

  p4 <- ggplot(df2_employed, aes(x = date, y = value)) +
    geom_area(aes(fill = era), alpha = 0.55, position = "identity") +
    geom_line(colour = "#2C4770", linewidth = 0.9) +
    geom_point(data = peak_row, aes(x = date, y = value),
               colour = "#C94040", size = 3) +
    annotate("text", x = peak_row$date, y = peak_row$value + 5,
             label = paste0("Peak: ", round(peak_row$value, 0), "k"),
             colour = "#C94040", size = 3.2, hjust = 0) +
    geom_vline(xintercept = cutoff, linetype = "dotted",
               colour = "grey50", linewidth = 0.7) +
    annotate("text", x = cutoff + 60, y = min(df2_employed$value, na.rm = TRUE),
             label = "Construction\nsqueeze begins",
             colour = "grey45", size = 2.9, hjust = 0, vjust = 0) +
    scale_fill_manual(values = c("Pre-squeeze" = "#A8C8E8",
                                  "Construction squeeze era" = "#3A7D5E"),
                      name = NULL) +
    scale_y_continuous(labels = comma_format()) +
    scale_x_date(date_labels = "%b %Y", date_breaks = "6 months") +
    labs(
      title    = "Seasonally adjusted employment, Norway (total, all ages)",
      subtitle = "Employment has remained near peak even as housing construction collapsed",
      caption  = "Source: Statistics Norway, table 13760",
      x = NULL, y = "Employed (thousands)"
    ) +
    theme_minimal(base_size = 12) +
    theme(
      legend.position  = "bottom",
      axis.text.x      = element_text(angle = 45, hjust = 1),
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold"),
      plot.subtitle    = element_text(colour = "grey40"),
      plot.caption     = element_text(colour = "grey55", size = 8)
    )

  print(p4)
} else {
  message("df2_employed is NULL or empty — skipping employment area chart.")
}


Part 5 — Ridgeline: Unemployment Rate Distribution Across Age and Gender

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

  # Identify age and gender columns from what was fetched
  possible_age_cols <- c("alder", "Alder", "age")
  possible_sex_cols <- c("kj.nn", "Kj.nn", "kjonn", "Kjonn", "sex", "gender")

  age_col <- intersect(possible_age_cols, names(df2_unemp_rate))
  sex_col <- intersect(possible_sex_cols, names(df2_unemp_rate))

  has_age <- length(age_col) > 0
  has_sex <- length(sex_col) > 0

  if (has_age) {
    ridge_col <- age_col[1]
  } else if (has_sex) {
    ridge_col <- sex_col[1]
  } else {
    ridge_col <- names(df2_unemp_rate)[2]  # fallback
  }

  df_ridge <- df2_unemp_rate |>
    rename(ridge_var = all_of(ridge_col)) |>
    filter(!is.na(value), !is.na(ridge_var)) |>
    mutate(ridge_var = str_trunc(as.character(ridge_var), 30))

  # Only keep groups with enough observations
  df_ridge <- df_ridge |>
    group_by(ridge_var) |>
    filter(n() >= 5) |>
    ungroup()

  n_groups <- length(unique(df_ridge$ridge_var))

  if (n_groups >= 2) {
    pal_ridge <- met.brewer("Hokusai2", n = max(n_groups, 3))

    p5 <- ggplot(df_ridge, aes(x = value, y = ridge_var, fill = ridge_var)) +
      geom_density_ridges(
        alpha = 0.72, scale = 1.1,
        rel_min_height = 0.01,
        colour = "white", linewidth = 0.4
      ) +
      scale_fill_manual(values = rep(pal_ridge, length.out = n_groups), guide = "none") +
      scale_x_continuous(labels = function(x) paste0(x, "%")) +
      labs(
        title    = "Distribution of unemployment rates by group (seasonally adjusted)",
        subtitle = paste0("Grouped by: ", ridge_col,
                          " — tight dispersion signals a broadly resilient labour market"),
        caption  = "Source: Statistics Norway, table 13760",
        x = "Unemployment rate (%)", y = NULL
      ) +
      theme_minimal(base_size = 12) +
      theme(
        panel.grid.minor  = element_blank(),
        plot.title        = element_text(face = "bold"),
        plot.subtitle     = element_text(colour = "grey40"),
        plot.caption      = element_text(colour = "grey55", size = 8)
      )

    print(p5)
  } else {
    message("Fewer than 2 ridge groups found — skipping ridgeline chart.")
  }
} else {
  message("df2_unemp_rate is NULL or empty — skipping ridgeline.")
}


Key Findings

  • Construction has collapsed across all building types. Dwelling starts fell sharply from peak levels reached around 2021-2022, with apartment blocks — the segment most critical to urban affordability — recording the steepest proportional decline.

  • Regional divergence is widening. While a handful of regions saw modest increases or held steady, the majority recorded double-digit percentage declines in new housing starts between the most recent two available years, concentrating supply shortfalls in areas where demand pressure is already highest.

  • Employment has remained near historic highs throughout the construction slump. Seasonally adjusted employment figures through early 2026 show no meaningful softening, meaning the labour supply capable of building homes exists — the problem is not workers, it is investment decisions and financing conditions.

  • Unemployment rates remain compressed and low. The ridgeline distribution confirms that regardless of age group or gender, unemployment rates cluster in a narrow, historically low band. Labour market slack is not the binding constraint on housing output.

  • The structural mismatch is sharpening. A strong labour market typically stimulates housing demand — workers with jobs need homes. But with interest rates elevated and construction financing tighter, the supply response has stalled. Norway is simultaneously building fewer homes and employing more people who need them.


Closing Reflection

Norway’s 2026 construction squeeze is not a story of economic collapse — the country’s aggregate employment picture is remarkably healthy. It is instead a story of misaligned incentives and financing conditions that have decoupled the supply and demand sides of the housing market. Developers cannot make the numbers work at current interest and cost levels; workers remain employed but increasingly priced out of ownership or struggling to find adequate rental stock.

The data from SSB tells a quietly alarming story: Norway has both the workforce and the economic base to sustain a robust housing programme, yet that potential sits unused. If this mismatch persists, the consequences will be felt not in this cycle’s employment statistics but in the next decade’s social geography — where families live, how far they commute, and whether younger Norwegians can afford to settle in the places where jobs actually exist.