Code
library(tidyverse)
library(PxWebApiData)
library(lubridate)
library(scales)
library(ggridges)
library(ggalluvial)
library(MetBrewer)
# Color palette
pal <- met.brewer("Hokusai2", 7)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.
Valid parameters for building activity:
[1] "Region" "BygnType" "ContentsCode" "Tid"
--- 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
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)
})Valid parameters for house prices:
[1] "Region" "Boligtype" "ContentsCode" "Tid"
--- 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
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)
})Valid parameters for GDP:
[1] "NACE" "ContentsCode" "Tid"
--- 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
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)
})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)
}
}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)
}
}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)
}
}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)
}
}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)
}
}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.
---
title: "Norway's Housing Market: Where the Building Has Stopped"
description: "Construction of new homes has plummeted across Norway while prices continue climbing — a brewing crisis in housing supply"
date: "2026-03-06"
categories: [SSB, housing, construction, economy]
---
```{r setup}
#| echo: false
knitr::opts_chunk$set(echo = TRUE, warning = FALSE, message = FALSE, error = TRUE)
```
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
```{r libraries}
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
```{r discover-building}
# 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")
print(names(meta_building))
for (param in names(meta_building)) {
cat("\n---", param, "---\n")
print(head(meta_building[[param]], 15))
}
```
## Fetch building activity data
```{r fetch-building}
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
```{r discover-prices}
# 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")
print(names(meta_prices))
for (param in names(meta_prices)) {
cat("\n---", param, "---\n")
print(head(meta_prices[[param]], 15))
}
```
## Fetch house price data
```{r fetch-prices}
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
```{r discover-gdp}
meta_gdp <- PxWebApiData::ApiData(
"https://data.ssb.no/api/v0/no/table/09170",
returnMetaFrames = TRUE
)
cat("Valid parameters for GDP:\n")
print(names(meta_gdp))
for (param in names(meta_gdp)) {
cat("\n---", param, "---\n")
print(head(meta_gdp[[param]], 12))
}
```
## Fetch GDP data
```{r fetch-gdp}
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
```{r plot-regional-collapse}
#| fig-height: 7
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
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
```{r plot-waterfall}
#| fig-height: 6
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
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
```{r plot-divergence}
#| fig-height: 6
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
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
```{r plot-stages}
#| fig-height: 7
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
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
```{r plot-ridgeline}
#| fig-height: 8
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
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.