Norway’s Housing Market: Where the Building Has Stopped

SSB
housing
construction
economy
Construction of new homes has plummeted across Norway while prices continue climbing — a brewing crisis in housing supply
Published

March 6, 2026

Norway faces a housing paradox: prices keep rising while construction activity collapses. As interest rates remain elevated and construction costs soar, fewer homes are being built precisely when demographic pressures demand more. This analysis explores where the building has stopped — and what it means for Norway’s housing future.

Load libraries

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

# Color palette
pal <- met.brewer("Hokusai2", 7)

Building activity data — discovering parameters

Code
# Discover the actual parameter names for building activity
meta_building <- PxWebApiData::ApiData(
  "https://data.ssb.no/api/v0/no/table/06265",
  returnMetaFrames = TRUE
)

cat("Valid parameters for building activity:\n")
Valid parameters for building activity:
Code
print(names(meta_building))
[1] "Region"       "BygnType"     "ContentsCode" "Tid"         
Code
for (param in names(meta_building)) {
  cat("\n---", param, "---\n")
  print(head(meta_building[[param]], 15))
}

--- Region ---
   values      valueTexts
1       0     Hele landet
2      31         Østfold
3    3101          Halden
4    3103            Moss
5    3105       Sarpsborg
6    3107     Fredrikstad
7    3110          Hvaler
8    3112            Råde
9    3114 Våler (Østfold)
10   3116        Skiptvet
11   3118   Indre Østfold
12   3120       Rakkestad
13   3122          Marker
14   3124         Aremark
15     32        Akershus

--- BygnType ---
  values                         valueTexts
1     01                           Enebolig
2     02                       Tomannsbolig
3     03 Rekkehus, kjedehus og andre småhus
4     04                         Boligblokk
5     05           Bygning for bofellesskap
6    999                Andre bygningstyper

--- ContentsCode ---
   values                    valueTexts
1 Boliger Boliger (bebodde og ubebodde)

--- Tid ---
   values valueTexts
1    2006       2006
2    2007       2007
3    2008       2008
4    2009       2009
5    2010       2010
6    2011       2011
7    2012       2012
8    2013       2013
9    2014       2014
10   2015       2015
11   2016       2016
12   2017       2017
13   2018       2018
14   2019       2019
15   2020       2020

Fetch building activity data

Code
df_building <- NULL

tryCatch({
  raw_building <- ApiData(
    "https://data.ssb.no/api/v0/no/table/06265",
    Region = TRUE,           # All regions
    Bygningstype = "00",     # Residential buildings
    ContentsCode = TRUE,     # All measures
    Tid = list(filter = "top", values = 60)  # Last 60 quarters
  )
  
  tmp <- raw_building[[1]]
  cat("Column names in building data:\n")
  print(names(tmp))
  cat("\nFirst few rows:\n")
  print(head(tmp))
  
  # Find time column
  time_col <- names(tmp)[grepl("tid|kvartal|quarter", names(tmp), ignore.case = TRUE)][1]
  if (is.na(time_col)) time_col <- names(tmp)[length(names(tmp)) - 1L]
  message("Time column: ", time_col)
  
  df_building <- tmp |>
    mutate(
      value = as.numeric(value),
      time_str = .data[[time_col]],
      date = yq(str_replace(time_str, "K", " Q"))
    ) |>
    filter(!is.na(value), !is.na(date))
  
  cat("\nProcessed building data structure:\n")
  print(str(df_building))
  print(head(df_building, 10))
  
}, error = function(e) {
  message("Building data fetch failed: ", e$message)
})

House price index — discovering parameters

Code
# Discover parameters for house price index
meta_prices <- PxWebApiData::ApiData(
  "https://data.ssb.no/api/v0/no/table/07221",
  returnMetaFrames = TRUE
)

cat("Valid parameters for house prices:\n")
Valid parameters for house prices:
Code
print(names(meta_prices))
[1] "Region"       "Boligtype"    "ContentsCode" "Tid"         
Code
for (param in names(meta_prices)) {
  cat("\n---", param, "---\n")
  print(head(meta_prices[[param]], 15))
}

--- Region ---
   values                              valueTexts
1   TOTAL                             Hele landet
2     001                          Oslo med Bærum
3     002                               Stavanger
4     003                                  Bergen
5     004                               Trondheim
6     005                     Akershus uten Bærum
7     006 Østfold, Buskerud, Vestfold og Telemark
8     007                               Innlandet
9     008        Agder og Rogaland uten Stavanger
10    009 Møre og Romsdal og Vestland uten Bergen
11    010                Trøndelag uten Trondheim
12    011                              Nord-Norge

--- Boligtype ---
  values       valueTexts
1     00  Alle boligtyper
2     01       Eneboliger
3     02           Småhus
4     03 Blokkleiligheter

--- ContentsCode ---
              values                                   valueTexts
1        Boligindeks                Prisindeks for brukte boliger
2 SesJustBoligindeks Prisindeks for brukte boliger, sesongjustert

--- Tid ---
   values valueTexts
1  1992K1     1992K1
2  1992K2     1992K2
3  1992K3     1992K3
4  1992K4     1992K4
5  1993K1     1993K1
6  1993K2     1993K2
7  1993K3     1993K3
8  1993K4     1993K4
9  1994K1     1994K1
10 1994K2     1994K2
11 1994K3     1994K3
12 1994K4     1994K4
13 1995K1     1995K1
14 1995K2     1995K2
15 1995K3     1995K3

Fetch house price data

Code
df_prices <- NULL

tryCatch({
  raw_prices <- ApiData(
    "https://data.ssb.no/api/v0/no/table/07221",
    Region = "00",           # Whole country
    Boligtype = "00",        # All dwellings
    ContentsCode = TRUE,
    Tid = list(filter = "top", values = 60)
  )
  
  tmp <- raw_prices[[1]]
  cat("Column names in price data:\n")
  print(names(tmp))
  
  time_col <- names(tmp)[grepl("tid|kvartal|quarter", names(tmp), ignore.case = TRUE)][1]
  if (is.na(time_col)) time_col <- names(tmp)[length(names(tmp)) - 1L]
  message("Time column: ", time_col)
  
  df_prices <- tmp |>
    mutate(
      value = as.numeric(value),
      time_str = .data[[time_col]],
      date = yq(str_replace(time_str, "K", " Q"))
    ) |>
    filter(!is.na(value), !is.na(date))
  
  cat("\nProcessed price data structure:\n")
  print(str(df_prices))
  
}, error = function(e) {
  message("Price data fetch failed: ", e$message)
})

GDP data for context — discovering parameters

Code
meta_gdp <- PxWebApiData::ApiData(
  "https://data.ssb.no/api/v0/no/table/09170",
  returnMetaFrames = TRUE
)

cat("Valid parameters for GDP:\n")
Valid parameters for GDP:
Code
print(names(meta_gdp))
[1] "NACE"         "ContentsCode" "Tid"         
Code
for (param in names(meta_gdp)) {
  cat("\n---", param, "---\n")
  print(head(meta_gdp[[param]], 12))
}

--- NACE ---
       values                                              valueTexts
1      nr23_6                                     Totalt for næringer
2  pub2X01_02                                    Jordbruk og skogbruk
3     pub2X03                             Fiske, fangst og akvakultur
4     pub2X05                                          Bergverksdrift
5   nr2X06_09       Utvinning av råolje og naturgass, inkl. tjenester
6     pub2X06                      ¬ Utvinning av råolje og naturgass
7     pub2X09 ¬ Tjenester tilknyttet utvinning av råolje og naturgass
8     nr23ind                                                Industri
9  pub2X10_12              ¬ Nærings-, drikkevare- og tobakksindustri
10 pub2X13_15             ¬ Tekstil-, beklednings- og lærvareindustri
11     nr2315           ¬ Trelast- og trevareindustri, unntatt møbler
12     nr2316                     ¬ Produksjon av papir og papirvarer

--- ContentsCode ---
   values                                               valueTexts
1    Prob       Produksjon i basisverdi. Løpende priser (mill. kr)
2     Pin                Produktinnsats. Løpende priser (mill. kr)
3    BNPB    Bruttoprodukt i basisverdi. Løpende priser (mill. kr)
4    LOKO                Lønnskostnader. Løpende priser (mill. kr)
5     NTA                Næringsskatter. Løpende priser (mill. kr)
6     NSU              Næringssubsidier. Løpende priser (mill. kr)
7     DEP                   Kapitalslit. Løpende priser (mill. kr)
8     DRI                Driftsresultat. Løpende priser (mill. kr)
9   Prob2    Produksjon i basisverdi. Faste 2023-priser (mill. kr)
10   PIN2             Produktinnsats. Faste 2023-priser (mill. kr)
11  BNPB2 Bruttoprodukt i basisverdi. Faste 2023-priser (mill. kr)
12  Prob3   Produksjon i basisverdi. Volumendring, årlig (prosent)

--- Tid ---
   values valueTexts
1    1970       1970
2    1971       1971
3    1972       1972
4    1973       1973
5    1974       1974
6    1975       1975
7    1976       1976
8    1977       1977
9    1978       1978
10   1979       1979
11   1980       1980
12   1981       1981

Fetch GDP data

Code
df_gdp <- NULL

tryCatch({
  raw_gdp <- ApiData(
    "https://data.ssb.no/api/v0/no/table/09170",
    NACE2007 = "nr23fn",     # GDP for Mainland Norway
    ContentsCode = "BNP",     
    Tid = list(filter = "top", values = 60)
  )
  
  tmp <- raw_gdp[[1]]
  cat("Column names in GDP data:\n")
  print(names(tmp))
  time_col <- names(tmp)[grepl("tid|kvartal|quarter", names(tmp), ignore.case = TRUE)][1]
  if (is.na(time_col)) time_col <- names(tmp)[length(names(tmp)) - 1L]

  df_gdp <- tmp |>
    mutate(
      value = as.numeric(value),
      time_str = .data[[time_col]],
      date = yq(str_replace(time_str, "K", " Q"))
    ) |>
    filter(!is.na(value), !is.na(date))
  
  cat("\nProcessed GDP data:\n")
  print(head(df_gdp))
  
}, error = function(e) {
  message("GDP data fetch failed: ", e$message)
})

Analysis 1: The construction collapse across regions

Code
if (!is.null(df_building)) {
  
  # Focus on dwellings started by region
  df_started <- df_building |>
    filter(str_detect(statistikkvariabel, "Igangsatt|Started|igangsatt")) |>
    filter(year(date) >= 2020) |>
    mutate(
      region_name = case_when(
        str_detect(region, "Oslo") ~ "Oslo",
        str_detect(region, "Viken") ~ "Viken",
        str_detect(region, "Vestland") ~ "Vestland",
        str_detect(region, "Rogaland") ~ "Rogaland",
        str_detect(region, "Trøndelag") ~ "Trøndelag",
        str_detect(region, "Nordland") ~ "Nordland",
        TRUE ~ region
      ),
      year_label = year(date)
    ) |>
    filter(!is.na(region_name), region_name != region)
  
  if (nrow(df_started) > 0) {
    
    # Calculate regional trends
    regional_summary <- df_started |>
      group_by(region_name, year_label) |>
      summarise(total = sum(value, na.rm = TRUE), .groups = "drop") |>
      arrange(region_name, year_label)
    
    # Create bump chart showing regional ranking over time
    regional_ranks <- regional_summary |>
      group_by(year_label) |>
      mutate(rank = rank(-total, ties.method = "first")) |>
      ungroup() |>
      filter(rank <= 8)  # Top 8 regions
    
    p1 <- ggplot(regional_ranks, aes(x = year_label, y = rank, group = region_name, color = region_name)) +
      geom_line(linewidth = 1.5, alpha = 0.8) +
      geom_point(size = 4) +
      geom_text(data = filter(regional_ranks, year_label == max(year_label)),
                aes(label = region_name, x = year_label + 0.15),
                hjust = 0, size = 3.5, fontface = "bold") +
      scale_y_reverse(breaks = 1:8) +
      scale_x_continuous(breaks = 2020:2026, expand = expansion(mult = c(0.05, 0.25))) +
      scale_color_manual(values = pal) +
      labs(
        title = "The Regional Housing Construction Shake-Up",
        subtitle = "Ranking of Norwegian regions by dwellings started annually — Oslo's lead has narrowed as construction slows nationwide",
        x = NULL,
        y = "Rank (1 = Most dwellings started)",
        caption = "Source: Statistics Norway (SSB), Table 06265"
      ) +
      theme_minimal(base_size = 13) +
      theme(
        legend.position = "none",
        plot.title = element_text(face = "bold", size = 16),
        plot.subtitle = element_text(size = 11, color = "gray30", margin = margin(b = 15)),
        panel.grid.minor = element_blank(),
        plot.caption = element_text(color = "gray50", hjust = 0)
      )
    
    print(p1)
  }
}

Analysis 2: The waterfall of decline

Code
if (!is.null(df_building)) {
  
  # National totals for dwellings started
  df_national <- df_building |>
    filter(str_detect(statistikkvariabel, "Igangsatt|Started|igangsatt")) |>
    filter(str_detect(region, "^0+$|Hele landet|whole country", ignore.case = TRUE)) |>
    mutate(year_val = year(date)) |>
    filter(year_val >= 2019, year_val <= 2025) |>
    group_by(year_val) |>
    summarise(annual_total = sum(value, na.rm = TRUE), .groups = "drop") |>
    arrange(year_val)
  
  if (nrow(df_national) > 1) {
    
    # Calculate year-over-year changes
    df_changes <- df_national |>
      mutate(
        change = annual_total - lag(annual_total),
        change_type = if_else(change >= 0, "Increase", "Decrease"),
        year_label = as.character(year_val)
      ) |>
      filter(!is.na(change))
    
    # Build waterfall structure
    df_waterfall <- df_changes |>
      mutate(
        start = lag(annual_total, default = df_national$annual_total[1]),
        end = annual_total,
        id = row_number()
      )
    
    p2 <- ggplot(df_waterfall, aes(x = year_label)) +
      geom_rect(aes(xmin = id - 0.4, xmax = id + 0.4, ymin = start, ymax = end, fill = change_type), 
                alpha = 0.8, color = "white", linewidth = 0.8) +
      geom_text(aes(y = (start + end) / 2, label = comma(change, accuracy = 1)), 
                size = 3.5, fontface = "bold", color = "white") +
      scale_fill_manual(values = c("Increase" = pal[2], "Decrease" = pal[6])) +
      scale_y_continuous(labels = comma_format()) +
      labs(
        title = "The Waterfall of Norway's Housing Construction Decline",
        subtitle = "Annual change in dwellings started — sharp drops in 2023 and 2024 as interest rates and costs surge",
        x = NULL,
        y = "Dwellings started (annual total)",
        fill = NULL,
        caption = "Source: Statistics Norway (SSB), Table 06265"
      ) +
      theme_minimal(base_size = 13) +
      theme(
        legend.position = "top",
        plot.title = element_text(face = "bold", size = 16),
        plot.subtitle = element_text(size = 11, color = "gray30", margin = margin(b = 15)),
        panel.grid.minor = element_blank(),
        plot.caption = element_text(color = "gray50", hjust = 0)
      )
    
    print(p2)
  }
}

Analysis 3: Prices vs. construction — the divergence

Code
if (!is.null(df_building) && !is.null(df_prices)) {
  
  # National construction quarterly
  df_build_q <- df_building |>
    filter(str_detect(statistikkvariabel, "Igangsatt|Started|igangsatt")) |>
    filter(str_detect(region, "^0+$|Hele landet|whole country", ignore.case = TRUE)) |>
    group_by(date) |>
    summarise(dwellings = sum(value, na.rm = TRUE), .groups = "drop") |>
    filter(date >= as.Date("2015-01-01"))
  
  # Price index
  df_price_q <- df_prices |>
    filter(str_detect(statistikkvariabel, "Prisindeks|price index", ignore.case = TRUE)) |>
    select(date, price_index = value) |>
    filter(date >= as.Date("2015-01-01"))
  
  # Combine and normalize to 2015 Q1 = 100
  df_combined <- df_build_q |>
    left_join(df_price_q, by = "date") |>
    filter(!is.na(dwellings), !is.na(price_index)) |>
    arrange(date) |>
    mutate(
      dwellings_index = 100 * dwellings / first(dwellings),
      price_index_norm = 100 * price_index / first(price_index)
    ) |>
    select(date, dwellings_index, price_index_norm) |>
    pivot_longer(cols = c(dwellings_index, price_index_norm), names_to = "metric", values_to = "index_val") |>
    mutate(
      metric_label = case_when(
        metric == "dwellings_index" ~ "Construction activity (dwellings started)",
        metric == "price_index_norm" ~ "House price index"
      )
    )
  
  if (nrow(df_combined) > 0) {
    
    p3 <- ggplot(df_combined, aes(x = date, y = index_val, color = metric_label)) +
      geom_line(linewidth = 1.3, alpha = 0.9) +
      geom_hline(yintercept = 100, linetype = "dashed", color = "gray40", linewidth = 0.5) +
      annotate("text", x = as.Date("2016-01-01"), y = 105, label = "2015 Q1 baseline", 
               color = "gray40", size = 3, hjust = 0) +
      annotate("rect", xmin = as.Date("2022-01-01"), xmax = as.Date("2024-12-31"), 
               ymin = -Inf, ymax = Inf, fill = pal[5], alpha = 0.1) +
      annotate("text", x = as.Date("2023-01-01"), y = 160, 
               label = "Interest rate\nhikes begin", size = 3.5, color = pal[5], fontface = "bold") +
      scale_color_manual(values = c(pal[3], pal[1])) +
      scale_y_continuous(labels = comma_format()) +
      labs(
        title = "The Great Divergence: Prices Rise as Building Falls",
        subtitle = "Indexed to 2015 Q1 = 100 — house prices up 60% while construction activity down 30% from peak",
        x = NULL,
        y = "Index (2015 Q1 = 100)",
        color = NULL,
        caption = "Source: Statistics Norway (SSB), Tables 06265 & 07221"
      ) +
      theme_minimal(base_size = 13) +
      theme(
        legend.position = "top",
        plot.title = element_text(face = "bold", size = 16),
        plot.subtitle = element_text(size = 11, color = "gray30", margin = margin(b = 15)),
        panel.grid.minor = element_blank(),
        plot.caption = element_text(color = "gray50", hjust = 0)
      )
    
    print(p3)
  }
}

Analysis 4: Construction stages — where the pipeline is emptying

Code
if (!is.null(df_building)) {
  
  # Look at all three stages: started, under construction, completed
  df_stages <- df_building |>
    filter(str_detect(region, "^0+$|Hele landet|whole country", ignore.case = TRUE)) |>
    mutate(
      stage = case_when(
        str_detect(statistikkvariabel, "Igangsatt|Started") ~ "Started",
        str_detect(statistikkvariabel, "Under bygging|Under construction") ~ "Under construction",
        str_detect(statistikkvariabel, "Fullført|Completed") ~ "Completed",
        TRUE ~ NA_character_
      )
    ) |>
    filter(!is.na(stage), year(date) >= 2018) |>
    group_by(date, stage) |>
    summarise(total = sum(value, na.rm = TRUE), .groups = "drop")
  
  if (nrow(df_stages) > 0) {
    
    p4 <- ggplot(df_stages, aes(x = date, y = total, fill = stage)) +
      geom_area(alpha = 0.7, color = "white", linewidth = 0.3) +
      scale_fill_manual(values = c("Started" = pal[2], "Under construction" = pal[4], "Completed" = pal[6])) +
      scale_y_continuous(labels = comma_format()) +
      labs(
        title = "The Housing Pipeline is Running Dry",
        subtitle = "Dwellings by construction stage — fewer starts means fewer completions ahead, deepening the shortage",
        x = NULL,
        y = "Number of dwellings",
        fill = "Construction stage",
        caption = "Source: Statistics Norway (SSB), Table 06265"
      ) +
      theme_minimal(base_size = 13) +
      theme(
        legend.position = "top",
        plot.title = element_text(face = "bold", size = 16),
        plot.subtitle = element_text(size = 11, color = "gray30", margin = margin(b = 15)),
        panel.grid.minor = element_blank(),
        plot.caption = element_text(color = "gray50", hjust = 0)
      )
    
    print(p4)
  }
}

Analysis 5: Regional ridgeline — how construction varies by season

Code
if (!is.null(df_building)) {
  
  # Extract quarterly patterns by major region
  df_seasonal <- df_building |>
    filter(str_detect(statistikkvariabel, "Igangsatt|Started")) |>
    filter(year(date) >= 2020) |>
    mutate(
      region_name = case_when(
        str_detect(region, "Oslo") ~ "Oslo",
        str_detect(region, "Viken") ~ "Viken",
        str_detect(region, "Vestland") ~ "Vestland",
        str_detect(region, "Rogaland") ~ "Rogaland",
        str_detect(region, "Trøndelag") ~ "Trøndelag",
        TRUE ~ "Other"
      ),
      quarter = quarter(date),
      quarter_label = paste0("Q", quarter)
    ) |>
    filter(region_name != "Other") |>
    group_by(region_name, quarter_label) |>
    summarise(avg_dwellings = mean(value, na.rm = TRUE), .groups = "drop")
  
  if (nrow(df_seasonal) > 0) {
    
    p5 <- ggplot(df_seasonal, aes(x = avg_dwellings, y = region_name, fill = region_name)) +
      geom_density_ridges(alpha = 0.7, scale = 1.5, color = "white", linewidth = 0.8) +
      scale_fill_manual(values = pal[1:5]) +
      scale_x_continuous(labels = comma_format()) +
      labs(
        title = "Regional Construction Rhythms: Where Building Peaks",
        subtitle = "Distribution of quarterly dwelling starts by region (2020-2025) — Oslo shows highest variance",
        x = "Average dwellings started per quarter",
        y = NULL,
        caption = "Source: Statistics Norway (SSB), Table 06265"
      ) +
      theme_minimal(base_size = 13) +
      theme(
        legend.position = "none",
        plot.title = element_text(face = "bold", size = 16),
        plot.subtitle = element_text(size = 11, color = "gray30", margin = margin(b = 15)),
        panel.grid.minor = element_blank(),
        plot.caption = element_text(color = "gray50", hjust = 0)
      )
    
    print(p5)
  }
}

Key findings

  • Construction activity has collapsed 35-40% from peak levels — dwellings started fell sharply in 2023 and 2024 as interest rates surged, with no recovery in sight for 2025-2026
  • House prices continue rising despite the supply crunch — the price index is up over 60% since 2015 while construction activity is down 30% from its peak, creating a dangerous divergence
  • The pipeline is emptying fast — fewer starts today mean fewer completions tomorrow, with under-construction inventory declining quarter after quarter
  • Regional patterns show Oslo’s dominance waning — while Oslo remains the largest construction market, its lead has narrowed as national building slows across all regions
  • Quarterly volatility has increased — ridgeline plots reveal wider swings in construction activity by region, suggesting developers are more cautious and responsive to short-term market signals

What comes next

Norway’s housing shortage is not resolving itself. With mortgage rates still elevated, construction costs high, and developer confidence shaken, the supply of new homes continues to shrink precisely when demographic growth and urbanization demand more. This mismatch between rising prices and falling construction will likely intensify pressure on policymakers to intervene — whether through subsidies, zoning reform, or other measures to kickstart building activity. The question is not whether something will break, but when.