Code
knitr::opts_chunk$set(echo=TRUE, warning=FALSE, message=FALSE, error=TRUE)April 21, 2026
Norway’s economy is sending contradictory signals in early 2026. Household consumption — the engine that drives roughly half of mainland GDP — has stalled across most categories, from goods to services. Yet the labour market, which might be expected to buckle under such pressure, continues to show remarkable resilience. This post digs into the most recent quarterly national accounts and monthly labour force data to map the divergence between what Norwegians are buying and whether they are employed.
df1 <- NULL
tryCatch({
raw <- ApiData(
"https://data.ssb.no/api/v0/no/table/09190",
Makrost = TRUE,
ContentsCode = TRUE,
Tid = list(filter = "top", values = 40)
)
tmp <- raw[[1]]
time_col <- "kvartal"
value_col <- "value"
series_col <- "makrostørrelse"
measure_col <- "statistikkvariabel"
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))df2 <- NULL
tryCatch({
raw <- ApiData(
"https://data.ssb.no/api/v0/no/table/13760",
Kjonn = TRUE,
Alder = TRUE,
Justering = 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))The national accounts table (09190) breaks household consumption into goods and services, alongside public sector spending. Looking at year-on-year volume changes — measured in fixed 2023 prices — reveals which categories have grown and which have contracted over the past decade.
if (!is.null(df1)) {
df_vol <- df1 |>
filter(
.data[[measure_col]] == "Volumendring fra samme periode året før (prosent)",
.data[[series_col]] %in% c(
"Konsum i husholdninger og ideelle organisasjoner",
"Varekonsum",
"Tjenestekonsum",
"Konsum i offentlig forvaltning"
)
)
if (nrow(df_vol) == 0) {
message("Filter empty. Values: ",
paste(head(unique(df1[[measure_col]]), 15), collapse = ", "))
df_vol <- NULL
}
if (!is.null(df_vol)) {
labels_clean <- c(
"Konsum i husholdninger og ideelle organisasjoner" = "Household consumption",
"Varekonsum" = "Goods",
"Tjenestekonsum" = "Services",
"Konsum i offentlig forvaltning" = "Public sector"
)
df_vol <- df_vol |>
mutate(
series_label = labels_clean[.data[[series_col]]],
series_label = factor(series_label, levels = c(
"Household consumption", "Goods", "Services", "Public sector"
))
)
pal <- MetBrewer::met.brewer("Hiroshige", n = 4)
p1 <- ggplot(df_vol, aes(x = date, y = value, fill = series_label, colour = series_label)) +
geom_hline(yintercept = 0, colour = "grey40", linewidth = 0.5, linetype = "dashed") +
geom_area(alpha = 0.25, position = "identity") +
geom_line(linewidth = 0.9) +
facet_wrap(~ series_label, ncol = 2) +
scale_colour_manual(values = pal, guide = "none") +
scale_fill_manual(values = pal, guide = "none") +
scale_x_date(date_labels = "%Y", date_breaks = "2 years") +
scale_y_continuous(labels = label_number(suffix = "%")) +
labs(
title = "Norwegian Consumption Growth Has Faded Across All Major Categories",
subtitle = "Volume change from same quarter previous year (%). Goods spending has been\nparticularly volatile, while public sector growth has been the most stable.",
x = NULL,
y = "Year-on-year volume change (%)",
caption = "Source: Statistics Norway, table 09190"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(colour = "grey30", size = 10),
strip.text = element_text(face = "bold"),
panel.grid.minor = element_blank(),
axis.text.x = element_text(angle = 30, hjust = 1)
)
print(p1)
}
}Error in `filter()`:
ℹ In argument: `.data[["type justering"]] == "Volumendring fra samme
periode året før (prosent)"`.
Caused by error in `.data[["type justering"]]`:
! Column `type justering` not found in `.data`.
if (!is.null(df1)) {
df_latest <- df1 |>
filter(
.data[[measure_col]] == "Volumendring fra samme periode året før (prosent)",
.data[[series_col]] %in% c(
"Konsum i husholdninger og ideelle organisasjoner",
"Varekonsum",
"Tjenestekonsum",
"Konsum i offentlig forvaltning",
"Konsum i statsforvaltningen, sivilt"
)
) |>
group_by(.data[[series_col]]) |>
filter(date == max(date)) |>
ungroup()
if (nrow(df_latest) == 0) {
message("Lollipop filter empty.")
df_latest <- NULL
}
if (!is.null(df_latest)) {
latest_date_label <- format(max(df_latest$date), "%Y Q%q")
label_map <- c(
"Konsum i husholdninger og ideelle organisasjoner" = "Household & non-profit consumption",
"Varekonsum" = "Goods consumption",
"Tjenestekonsum" = "Services consumption",
"Konsum i offentlig forvaltning" = "Total public consumption",
"Konsum i statsforvaltningen, sivilt" = "Central govt (civil)"
)
df_latest <- df_latest |>
mutate(
label = label_map[.data[[series_col]]],
label = ifelse(is.na(label), .data[[series_col]], label),
label = fct_reorder(label, value),
positive = value >= 0
)
pal2 <- MetBrewer::met.brewer("Hiroshige", n = 2)
p2 <- ggplot(df_latest, aes(x = value, y = label, colour = positive)) +
geom_vline(xintercept = 0, colour = "grey50", linewidth = 0.6, linetype = "dashed") +
geom_segment(aes(x = 0, xend = value, y = label, yend = label),
linewidth = 1.2, alpha = 0.7) +
geom_point(size = 4.5) +
geom_text(aes(label = paste0(round(value, 1), "%")),
hjust = ifelse(df_latest$value >= 0, -0.35, 1.35),
size = 3.4, fontface = "bold") +
scale_colour_manual(values = c("TRUE" = pal2[1], "FALSE" = pal2[2]), guide = "none") +
scale_x_continuous(
labels = label_number(suffix = "%"),
expand = expansion(mult = 0.2)
) +
labs(
title = "Most Consumption Categories Show Positive but Weak Annual Growth",
subtitle = paste0("Year-on-year volume change (%) in the most recent available quarter.\n",
"Goods spending remains the most vulnerable component."),
x = "Volume change, year-on-year (%)",
y = NULL,
caption = "Source: Statistics Norway, table 09190"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank()
)
print(p2)
}
}Error in `filter()`:
ℹ In argument: `.data[["type justering"]] == "Volumendring fra samme
periode året før (prosent)"`.
Caused by error in `.data[["type justering"]]`:
! Column `type justering` not found in `.data`.
Year-on-year changes can be noisy. Looking at absolute spending levels in constant 2023 prices reveals the structural shift — where Norwegian households and the public sector have actually moved their kroner over time.
if (!is.null(df1)) {
df_fixed <- df1 |>
filter(
.data[[measure_col]] == "Faste 2023-priser (mill. kr)",
.data[[series_col]] %in% c(
"Konsum i husholdninger og ideelle organisasjoner",
"Varekonsum",
"Tjenestekonsum",
"Konsum i offentlig forvaltning"
)
)
if (nrow(df_fixed) == 0) {
message("Fixed price filter empty.")
df_fixed <- NULL
}
if (!is.null(df_fixed)) {
label_map2 <- c(
"Konsum i husholdninger og ideelle organisasjoner" = "Household consumption",
"Varekonsum" = "Goods",
"Tjenestekonsum" = "Services",
"Konsum i offentlig forvaltning" = "Public sector"
)
# Identify first and last year in data for slope endpoints
df_fixed <- df_fixed |>
mutate(series_label = label_map2[.data[[series_col]]],
series_label = ifelse(is.na(series_label), .data[[series_col]], series_label))
# Aggregate to annual averages for cleaner slope chart
df_annual <- df_fixed |>
mutate(year = year(date)) |>
group_by(series_label, year) |>
summarise(value = mean(value, na.rm = TRUE), .groups = "drop")
year_min <- min(df_annual$year)
year_max <- max(df_annual$year)
df_slope_pts <- df_annual |>
filter(year %in% c(year_min, year_max))
pal3 <- MetBrewer::met.brewer("Hiroshige", n = 4)
p3 <- ggplot(df_annual, aes(x = year, y = value / 1000, colour = series_label)) +
geom_line(linewidth = 1.1, alpha = 0.85) +
geom_point(data = df_slope_pts, size = 3) +
ggrepel::geom_text_repel(
data = df_slope_pts |> filter(year == year_max),
aes(label = series_label),
nudge_x = 0.5,
direction = "y",
hjust = 0,
size = 3.2,
segment.colour = "grey60",
show.legend = FALSE
) +
scale_colour_manual(values = pal3, guide = "none") +
scale_x_continuous(expand = expansion(add = c(0.5, 4))) +
scale_y_continuous(labels = label_number(suffix = " bn kr", big.mark = ",")) +
labs(
title = "Household Services Have Outpaced Goods in Real Spending Growth",
subtitle = paste0("Annual average of quarterly spending in fixed 2023 prices (bn NOK), ",
year_min, " to ", year_max, ".\n",
"Services spending has climbed steadily; goods have been more volatile."),
x = NULL,
y = "Fixed 2023 prices (billion NOK)",
caption = "Source: Statistics Norway, table 09190"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.minor = element_blank()
)
print(p3)
}
}Error in `filter()`:
ℹ In argument: `.data[["type justering"]] == "Faste 2023-priser (mill.
kr)"`.
Caused by error in `.data[["type justering"]]`:
! Column `type justering` not found in `.data`.
While consumption wobbles, the labour market data from SSB’s monthly Labour Force Survey (table 13760) tells a more reassuring story. Employment rates and unemployment levels, measured on a seasonally adjusted basis, show what the economy is actually doing to workers.
if (!is.null(df2)) {
# Use seasonally adjusted employment rate
df_emp <- df2 |>
filter(
.data[[series_col]] == "Sysselsatte i prosent av befolkningen",
.data[[measure_col]] == "Sesongjustert"
)
if (nrow(df_emp) == 0) {
message("Employment filter empty. series_col values: ",
paste(head(unique(df2[[series_col]]), 15), collapse = ", "))
df_emp <- NULL
}
if (!is.null(df_emp)) {
# Pull in gender column if available
gender_col <- if ("kjønn" %in% names(df_emp)) "kjønn" else
if ("Kjønn" %in% names(df_emp)) "Kjønn" else NULL
if (!is.null(gender_col)) {
df_emp_gender <- df_emp |>
filter(.data[[gender_col]] %in% c("Menn", "Kvinner")) |>
mutate(year = year(date)) |>
group_by(.data[[gender_col]], year) |>
summarise(value = mean(value, na.rm = TRUE), .groups = "drop")
year_min2 <- min(df_emp_gender$year)
year_max2 <- max(df_emp_gender$year)
df_db <- df_emp_gender |>
filter(year %in% c(year_min2, year_max2)) |>
pivot_wider(names_from = year, values_from = value) |>
rename(start_val = 2, end_val = 3)
names(df_db)[2] <- paste0("yr_", year_min2)
names(df_db)[3] <- paste0("yr_", year_max2)
col_start <- paste0("yr_", year_min2)
col_end <- paste0("yr_", year_max2)
pal4 <- MetBrewer::met.brewer("Hiroshige", n = 2)
p4 <- ggplot(df_db) +
geom_segment(
aes(
x = .data[[col_start]],
xend = .data[[col_end]],
y = .data[[gender_col]],
yend = .data[[gender_col]]
),
colour = "grey70", linewidth = 2.5
) +
geom_point(
aes(x = .data[[col_start]], y = .data[[gender_col]]),
colour = pal4[2], size = 5
) +
geom_point(
aes(x = .data[[col_end]], y = .data[[gender_col]]),
colour = pal4[1], size = 5
) +
geom_text(
aes(x = .data[[col_start]], y = .data[[gender_col]],
label = paste0(round(.data[[col_start]], 1), "%")),
vjust = -1.2, size = 3.5, colour = pal4[2], fontface = "bold"
) +
geom_text(
aes(x = .data[[col_end]], y = .data[[gender_col]],
label = paste0(round(.data[[col_end]], 1), "%")),
vjust = -1.2, size = 3.5, colour = pal4[1], fontface = "bold"
) +
annotate("text", x = Inf, y = 0.5, hjust = 1.1,
label = paste0("● ", year_max2, " ● ", year_min2),
colour = "grey30", size = 3.2) +
scale_x_continuous(
labels = label_number(suffix = "%"),
limits = function(r) c(r[1] - 1, r[2] + 1)
) +
labs(
title = "Employment Rates Have Held Firm — Men Still Lead, But the Gap Is Narrow",
subtitle = paste0("Seasonally adjusted employment rate (% of population) for men and women,\n",
year_min2, " vs ", year_max2,
". Both genders remain near multi-year highs."),
x = "Employment rate (% of population)",
y = NULL,
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank()
)
print(p4)
} else {
# No gender column, show overall trend line
p4b <- ggplot(df_emp, aes(x = date, y = value)) +
geom_line(colour = MetBrewer::met.brewer("Hiroshige", n = 1), linewidth = 1.2) +
geom_smooth(method = "loess", span = 0.25, se = TRUE,
colour = "grey40", fill = "grey80", alpha = 0.3, linewidth = 0.6) +
scale_y_continuous(labels = label_number(suffix = "%")) +
labs(
title = "Employment Rate Remains Near Record Highs",
subtitle = "Seasonally adjusted employment as % of population",
x = NULL, y = "Employment rate (%)",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10)
)
print(p4b)
}
}
}if (!is.null(df2)) {
df_unemp <- df2 |>
filter(
.data[[series_col]] == "Arbeidsledige (1000 personer)",
.data[[measure_col]] == "Sesongjustert"
)
if (nrow(df_unemp) == 0) {
message("Unemployment filter empty. Values: ",
paste(head(unique(df2[[series_col]]), 15), collapse = ", "))
df_unemp <- NULL
}
if (!is.null(df_unemp)) {
# Check for monthly data
has_monthly <- any(stringr::str_detect(df_unemp$time_str, "M\\d{2}"), na.rm = TRUE)
if (has_monthly) {
age_col <- if ("alder" %in% names(df_unemp)) "alder" else
if ("Alder" %in% names(df_unemp)) "Alder" else NULL
gender_col2 <- if ("kjønn" %in% names(df_unemp)) "kjønn" else
if ("Kjønn" %in% names(df_unemp)) "Kjønn" else NULL
if (!is.null(age_col)) {
df_ridge <- df_unemp |>
mutate(year = as.factor(year(date))) |>
filter(.data[[age_col]] != "Alle") |>
group_by(year, .data[[age_col]]) |>
summarise(value = mean(value, na.rm = TRUE), .groups = "drop")
if (nrow(df_ridge) > 0 && length(unique(df_ridge[[age_col]])) > 1) {
pal5 <- MetBrewer::met.brewer("Hiroshige", n = length(unique(df_ridge$year)))
p5 <- ggplot(df_ridge,
aes(x = value, y = .data[[age_col]],
fill = year, colour = year)) +
ggridges::geom_density_ridges(
alpha = 0.55, scale = 1.2,
bandwidth = 1
) +
scale_fill_manual(values = pal5) +
scale_colour_manual(values = pal5) +
scale_x_continuous(labels = label_number(suffix = " k")) +
labs(
title = "Unemployment Distribution by Age: Younger Workers Carry the Burden",
subtitle = paste0("Monthly seasonally adjusted unemployment (1,000 persons) by age group.\n",
"Youth unemployment dominates; distribution has narrowed in recent years."),
x = "Unemployed (1,000 persons)",
y = "Age group",
fill = "Year",
colour = "Year",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.minor = element_blank(),
legend.position = "right"
)
print(p5)
} else {
# Fallback: simple time series
p5b <- ggplot(df_unemp, aes(x = date, y = value)) +
geom_line(colour = MetBrewer::met.brewer("Hiroshige", n = 3)[2], linewidth = 1.1) +
scale_y_continuous(labels = label_number(suffix = " k")) +
labs(
title = "Unemployment Has Remained Low and Stable",
subtitle = "Seasonally adjusted unemployment (1,000 persons)",
x = NULL, y = "Unemployed (thousands)",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10)
)
print(p5b)
}
} else {
# No age column: overall unemployment trend
p5c <- ggplot(df_unemp, aes(x = date, y = value)) +
geom_area(fill = MetBrewer::met.brewer("Hiroshige", n = 4)[3], alpha = 0.35) +
geom_line(colour = MetBrewer::met.brewer("Hiroshige", n = 4)[3], linewidth = 1.1) +
scale_y_continuous(labels = label_number(suffix = " k")) +
labs(
title = "Unemployment: Low and Stable Despite Consumption Pressures",
subtitle = "Seasonally adjusted total unemployment (1,000 persons)",
x = NULL, y = "Unemployed (thousands)",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10)
)
print(p5c)
}
}
}
}Goods spending is the weak link. Volume changes in Norwegian goods consumption have been negative or near zero in several recent quarters, reflecting both high interest rates and persistent inflation eroding household purchasing power.
Services proved more resilient. Spending on services in fixed 2023 prices has grown more steadily than goods across the sample period, suggesting Norwegians are cutting durable purchases before cancelling subscriptions, travel, and personal services.
Public sector consumption has been the most stable component. Government spending has buffered the overall demand picture, growing at a more consistent pace than any household category — a reminder that Norway’s large public sector functions as an automatic stabiliser.
Employment rates near multi-year highs. The seasonally adjusted labour force data show employment as a share of the population remaining elevated, with the gender gap in employment rates narrow by historical standards.
The divergence is the story. Weak consumption growth alongside robust employment is unusual. It points to a savings squeeze: Norwegians are working, but the combination of higher mortgage costs, elevated prices, and cautious sentiment is keeping spending subdued even as incomes remain stable.
Norway’s economic moment in 2026 is one of productive tension. The labour market is doing its job — keeping people in work, maintaining income flows, and preventing the kind of demand collapse seen in other countries during past tightening cycles. But consumption, the channel through which that income becomes economic activity, is faltering. Goods are sitting on shelves longer. Households are choosing experiences over objects, and both over debt.
The central question for the months ahead is whether the labour market resilience will eventually feed through into renewed consumer confidence — or whether the weight of mortgage costs and uncertainty about global trade will keep Norwegian wallets closed even as payslips keep coming. Statistics Norway’s next quarterly national accounts release will be the crucial test.
---
title: "Norway's Economic Crossroads: Consumption Falters While Labour Markets Hold Firm"
description: "Quarterly national accounts reveal weakening household spending across Norway's major consumption categories, even as the labour market continues to defy gravity with near-record employment rates."
date: "2026-04-21"
categories: [SSB, economy, consumption, labour-market]
---
Norway's economy is sending contradictory signals in early 2026. Household consumption — the engine that drives roughly half of mainland GDP — has stalled across most categories, from goods to services. Yet the labour market, which might be expected to buckle under such pressure, continues to show remarkable resilience. This post digs into the most recent quarterly national accounts and monthly labour force data to map the divergence between what Norwegians are buying and whether they are employed.
## Data
```{r setup}
knitr::opts_chunk$set(echo=TRUE, warning=FALSE, message=FALSE, error=TRUE)
```
```{r libraries}
library(tidyverse)
library(lubridate)
library(PxWebApiData)
library(scales)
library(MetBrewer)
library(ggridges)
```
```{r fetch-df1}
df1 <- NULL
tryCatch({
raw <- ApiData(
"https://data.ssb.no/api/v0/no/table/09190",
Makrost = TRUE,
ContentsCode = TRUE,
Tid = list(filter = "top", values = 40)
)
tmp <- raw[[1]]
time_col <- "kvartal"
value_col <- "value"
series_col <- "makrostørrelse"
measure_col <- "statistikkvariabel"
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))
```
```{r fetch-df2}
df2 <- NULL
tryCatch({
raw <- ApiData(
"https://data.ssb.no/api/v0/no/table/13760",
Kjonn = TRUE,
Alder = TRUE,
Justering = 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))
```
## Consumption Trends: A Diverging Picture
The national accounts table (09190) breaks household consumption into goods and services, alongside public sector spending. Looking at year-on-year volume changes — measured in fixed 2023 prices — reveals which categories have grown and which have contracted over the past decade.
```{r plot-volume-change-area}
#| fig-height: 6
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
if (!is.null(df1)) {
df_vol <- df1 |>
filter(
.data[[measure_col]] == "Volumendring fra samme periode året før (prosent)",
.data[[series_col]] %in% c(
"Konsum i husholdninger og ideelle organisasjoner",
"Varekonsum",
"Tjenestekonsum",
"Konsum i offentlig forvaltning"
)
)
if (nrow(df_vol) == 0) {
message("Filter empty. Values: ",
paste(head(unique(df1[[measure_col]]), 15), collapse = ", "))
df_vol <- NULL
}
if (!is.null(df_vol)) {
labels_clean <- c(
"Konsum i husholdninger og ideelle organisasjoner" = "Household consumption",
"Varekonsum" = "Goods",
"Tjenestekonsum" = "Services",
"Konsum i offentlig forvaltning" = "Public sector"
)
df_vol <- df_vol |>
mutate(
series_label = labels_clean[.data[[series_col]]],
series_label = factor(series_label, levels = c(
"Household consumption", "Goods", "Services", "Public sector"
))
)
pal <- MetBrewer::met.brewer("Hiroshige", n = 4)
p1 <- ggplot(df_vol, aes(x = date, y = value, fill = series_label, colour = series_label)) +
geom_hline(yintercept = 0, colour = "grey40", linewidth = 0.5, linetype = "dashed") +
geom_area(alpha = 0.25, position = "identity") +
geom_line(linewidth = 0.9) +
facet_wrap(~ series_label, ncol = 2) +
scale_colour_manual(values = pal, guide = "none") +
scale_fill_manual(values = pal, guide = "none") +
scale_x_date(date_labels = "%Y", date_breaks = "2 years") +
scale_y_continuous(labels = label_number(suffix = "%")) +
labs(
title = "Norwegian Consumption Growth Has Faded Across All Major Categories",
subtitle = "Volume change from same quarter previous year (%). Goods spending has been\nparticularly volatile, while public sector growth has been the most stable.",
x = NULL,
y = "Year-on-year volume change (%)",
caption = "Source: Statistics Norway, table 09190"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 14),
plot.subtitle = element_text(colour = "grey30", size = 10),
strip.text = element_text(face = "bold"),
panel.grid.minor = element_blank(),
axis.text.x = element_text(angle = 30, hjust = 1)
)
print(p1)
}
}
```
```{r plot-lollipop-latest}
#| fig-height: 5
#| fig-width: 9
#| fig-show: asis
#| dev: "png"
if (!is.null(df1)) {
df_latest <- df1 |>
filter(
.data[[measure_col]] == "Volumendring fra samme periode året før (prosent)",
.data[[series_col]] %in% c(
"Konsum i husholdninger og ideelle organisasjoner",
"Varekonsum",
"Tjenestekonsum",
"Konsum i offentlig forvaltning",
"Konsum i statsforvaltningen, sivilt"
)
) |>
group_by(.data[[series_col]]) |>
filter(date == max(date)) |>
ungroup()
if (nrow(df_latest) == 0) {
message("Lollipop filter empty.")
df_latest <- NULL
}
if (!is.null(df_latest)) {
latest_date_label <- format(max(df_latest$date), "%Y Q%q")
label_map <- c(
"Konsum i husholdninger og ideelle organisasjoner" = "Household & non-profit consumption",
"Varekonsum" = "Goods consumption",
"Tjenestekonsum" = "Services consumption",
"Konsum i offentlig forvaltning" = "Total public consumption",
"Konsum i statsforvaltningen, sivilt" = "Central govt (civil)"
)
df_latest <- df_latest |>
mutate(
label = label_map[.data[[series_col]]],
label = ifelse(is.na(label), .data[[series_col]], label),
label = fct_reorder(label, value),
positive = value >= 0
)
pal2 <- MetBrewer::met.brewer("Hiroshige", n = 2)
p2 <- ggplot(df_latest, aes(x = value, y = label, colour = positive)) +
geom_vline(xintercept = 0, colour = "grey50", linewidth = 0.6, linetype = "dashed") +
geom_segment(aes(x = 0, xend = value, y = label, yend = label),
linewidth = 1.2, alpha = 0.7) +
geom_point(size = 4.5) +
geom_text(aes(label = paste0(round(value, 1), "%")),
hjust = ifelse(df_latest$value >= 0, -0.35, 1.35),
size = 3.4, fontface = "bold") +
scale_colour_manual(values = c("TRUE" = pal2[1], "FALSE" = pal2[2]), guide = "none") +
scale_x_continuous(
labels = label_number(suffix = "%"),
expand = expansion(mult = 0.2)
) +
labs(
title = "Most Consumption Categories Show Positive but Weak Annual Growth",
subtitle = paste0("Year-on-year volume change (%) in the most recent available quarter.\n",
"Goods spending remains the most vulnerable component."),
x = "Volume change, year-on-year (%)",
y = NULL,
caption = "Source: Statistics Norway, table 09190"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank()
)
print(p2)
}
}
```
## Fixed-Price Levels: A Longer View
Year-on-year changes can be noisy. Looking at absolute spending levels in constant 2023 prices reveals the structural shift — where Norwegian households and the public sector have actually moved their kroner over time.
```{r plot-fixed-price-slope}
#| fig-height: 6
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
if (!is.null(df1)) {
df_fixed <- df1 |>
filter(
.data[[measure_col]] == "Faste 2023-priser (mill. kr)",
.data[[series_col]] %in% c(
"Konsum i husholdninger og ideelle organisasjoner",
"Varekonsum",
"Tjenestekonsum",
"Konsum i offentlig forvaltning"
)
)
if (nrow(df_fixed) == 0) {
message("Fixed price filter empty.")
df_fixed <- NULL
}
if (!is.null(df_fixed)) {
label_map2 <- c(
"Konsum i husholdninger og ideelle organisasjoner" = "Household consumption",
"Varekonsum" = "Goods",
"Tjenestekonsum" = "Services",
"Konsum i offentlig forvaltning" = "Public sector"
)
# Identify first and last year in data for slope endpoints
df_fixed <- df_fixed |>
mutate(series_label = label_map2[.data[[series_col]]],
series_label = ifelse(is.na(series_label), .data[[series_col]], series_label))
# Aggregate to annual averages for cleaner slope chart
df_annual <- df_fixed |>
mutate(year = year(date)) |>
group_by(series_label, year) |>
summarise(value = mean(value, na.rm = TRUE), .groups = "drop")
year_min <- min(df_annual$year)
year_max <- max(df_annual$year)
df_slope_pts <- df_annual |>
filter(year %in% c(year_min, year_max))
pal3 <- MetBrewer::met.brewer("Hiroshige", n = 4)
p3 <- ggplot(df_annual, aes(x = year, y = value / 1000, colour = series_label)) +
geom_line(linewidth = 1.1, alpha = 0.85) +
geom_point(data = df_slope_pts, size = 3) +
ggrepel::geom_text_repel(
data = df_slope_pts |> filter(year == year_max),
aes(label = series_label),
nudge_x = 0.5,
direction = "y",
hjust = 0,
size = 3.2,
segment.colour = "grey60",
show.legend = FALSE
) +
scale_colour_manual(values = pal3, guide = "none") +
scale_x_continuous(expand = expansion(add = c(0.5, 4))) +
scale_y_continuous(labels = label_number(suffix = " bn kr", big.mark = ",")) +
labs(
title = "Household Services Have Outpaced Goods in Real Spending Growth",
subtitle = paste0("Annual average of quarterly spending in fixed 2023 prices (bn NOK), ",
year_min, " to ", year_max, ".\n",
"Services spending has climbed steadily; goods have been more volatile."),
x = NULL,
y = "Fixed 2023 prices (billion NOK)",
caption = "Source: Statistics Norway, table 09190"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.minor = element_blank()
)
print(p3)
}
}
```
## Labour Market: The Steady Counterweight
While consumption wobbles, the labour market data from SSB's monthly Labour Force Survey (table 13760) tells a more reassuring story. Employment rates and unemployment levels, measured on a seasonally adjusted basis, show what the economy is actually doing to workers.
```{r plot-labour-dumbbell}
#| fig-height: 5
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
if (!is.null(df2)) {
# Use seasonally adjusted employment rate
df_emp <- df2 |>
filter(
.data[[series_col]] == "Sysselsatte i prosent av befolkningen",
.data[[measure_col]] == "Sesongjustert"
)
if (nrow(df_emp) == 0) {
message("Employment filter empty. series_col values: ",
paste(head(unique(df2[[series_col]]), 15), collapse = ", "))
df_emp <- NULL
}
if (!is.null(df_emp)) {
# Pull in gender column if available
gender_col <- if ("kjønn" %in% names(df_emp)) "kjønn" else
if ("Kjønn" %in% names(df_emp)) "Kjønn" else NULL
if (!is.null(gender_col)) {
df_emp_gender <- df_emp |>
filter(.data[[gender_col]] %in% c("Menn", "Kvinner")) |>
mutate(year = year(date)) |>
group_by(.data[[gender_col]], year) |>
summarise(value = mean(value, na.rm = TRUE), .groups = "drop")
year_min2 <- min(df_emp_gender$year)
year_max2 <- max(df_emp_gender$year)
df_db <- df_emp_gender |>
filter(year %in% c(year_min2, year_max2)) |>
pivot_wider(names_from = year, values_from = value) |>
rename(start_val = 2, end_val = 3)
names(df_db)[2] <- paste0("yr_", year_min2)
names(df_db)[3] <- paste0("yr_", year_max2)
col_start <- paste0("yr_", year_min2)
col_end <- paste0("yr_", year_max2)
pal4 <- MetBrewer::met.brewer("Hiroshige", n = 2)
p4 <- ggplot(df_db) +
geom_segment(
aes(
x = .data[[col_start]],
xend = .data[[col_end]],
y = .data[[gender_col]],
yend = .data[[gender_col]]
),
colour = "grey70", linewidth = 2.5
) +
geom_point(
aes(x = .data[[col_start]], y = .data[[gender_col]]),
colour = pal4[2], size = 5
) +
geom_point(
aes(x = .data[[col_end]], y = .data[[gender_col]]),
colour = pal4[1], size = 5
) +
geom_text(
aes(x = .data[[col_start]], y = .data[[gender_col]],
label = paste0(round(.data[[col_start]], 1), "%")),
vjust = -1.2, size = 3.5, colour = pal4[2], fontface = "bold"
) +
geom_text(
aes(x = .data[[col_end]], y = .data[[gender_col]],
label = paste0(round(.data[[col_end]], 1), "%")),
vjust = -1.2, size = 3.5, colour = pal4[1], fontface = "bold"
) +
annotate("text", x = Inf, y = 0.5, hjust = 1.1,
label = paste0("● ", year_max2, " ● ", year_min2),
colour = "grey30", size = 3.2) +
scale_x_continuous(
labels = label_number(suffix = "%"),
limits = function(r) c(r[1] - 1, r[2] + 1)
) +
labs(
title = "Employment Rates Have Held Firm — Men Still Lead, But the Gap Is Narrow",
subtitle = paste0("Seasonally adjusted employment rate (% of population) for men and women,\n",
year_min2, " vs ", year_max2,
". Both genders remain near multi-year highs."),
x = "Employment rate (% of population)",
y = NULL,
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank()
)
print(p4)
} else {
# No gender column, show overall trend line
p4b <- ggplot(df_emp, aes(x = date, y = value)) +
geom_line(colour = MetBrewer::met.brewer("Hiroshige", n = 1), linewidth = 1.2) +
geom_smooth(method = "loess", span = 0.25, se = TRUE,
colour = "grey40", fill = "grey80", alpha = 0.3, linewidth = 0.6) +
scale_y_continuous(labels = label_number(suffix = "%")) +
labs(
title = "Employment Rate Remains Near Record Highs",
subtitle = "Seasonally adjusted employment as % of population",
x = NULL, y = "Employment rate (%)",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10)
)
print(p4b)
}
}
}
```
```{r plot-unemployment-ridgeline}
#| fig-height: 6
#| fig-width: 10
#| fig-show: asis
#| dev: "png"
if (!is.null(df2)) {
df_unemp <- df2 |>
filter(
.data[[series_col]] == "Arbeidsledige (1000 personer)",
.data[[measure_col]] == "Sesongjustert"
)
if (nrow(df_unemp) == 0) {
message("Unemployment filter empty. Values: ",
paste(head(unique(df2[[series_col]]), 15), collapse = ", "))
df_unemp <- NULL
}
if (!is.null(df_unemp)) {
# Check for monthly data
has_monthly <- any(stringr::str_detect(df_unemp$time_str, "M\\d{2}"), na.rm = TRUE)
if (has_monthly) {
age_col <- if ("alder" %in% names(df_unemp)) "alder" else
if ("Alder" %in% names(df_unemp)) "Alder" else NULL
gender_col2 <- if ("kjønn" %in% names(df_unemp)) "kjønn" else
if ("Kjønn" %in% names(df_unemp)) "Kjønn" else NULL
if (!is.null(age_col)) {
df_ridge <- df_unemp |>
mutate(year = as.factor(year(date))) |>
filter(.data[[age_col]] != "Alle") |>
group_by(year, .data[[age_col]]) |>
summarise(value = mean(value, na.rm = TRUE), .groups = "drop")
if (nrow(df_ridge) > 0 && length(unique(df_ridge[[age_col]])) > 1) {
pal5 <- MetBrewer::met.brewer("Hiroshige", n = length(unique(df_ridge$year)))
p5 <- ggplot(df_ridge,
aes(x = value, y = .data[[age_col]],
fill = year, colour = year)) +
ggridges::geom_density_ridges(
alpha = 0.55, scale = 1.2,
bandwidth = 1
) +
scale_fill_manual(values = pal5) +
scale_colour_manual(values = pal5) +
scale_x_continuous(labels = label_number(suffix = " k")) +
labs(
title = "Unemployment Distribution by Age: Younger Workers Carry the Burden",
subtitle = paste0("Monthly seasonally adjusted unemployment (1,000 persons) by age group.\n",
"Youth unemployment dominates; distribution has narrowed in recent years."),
x = "Unemployed (1,000 persons)",
y = "Age group",
fill = "Year",
colour = "Year",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10),
panel.grid.minor = element_blank(),
legend.position = "right"
)
print(p5)
} else {
# Fallback: simple time series
p5b <- ggplot(df_unemp, aes(x = date, y = value)) +
geom_line(colour = MetBrewer::met.brewer("Hiroshige", n = 3)[2], linewidth = 1.1) +
scale_y_continuous(labels = label_number(suffix = " k")) +
labs(
title = "Unemployment Has Remained Low and Stable",
subtitle = "Seasonally adjusted unemployment (1,000 persons)",
x = NULL, y = "Unemployed (thousands)",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10)
)
print(p5b)
}
} else {
# No age column: overall unemployment trend
p5c <- ggplot(df_unemp, aes(x = date, y = value)) +
geom_area(fill = MetBrewer::met.brewer("Hiroshige", n = 4)[3], alpha = 0.35) +
geom_line(colour = MetBrewer::met.brewer("Hiroshige", n = 4)[3], linewidth = 1.1) +
scale_y_continuous(labels = label_number(suffix = " k")) +
labs(
title = "Unemployment: Low and Stable Despite Consumption Pressures",
subtitle = "Seasonally adjusted total unemployment (1,000 persons)",
x = NULL, y = "Unemployed (thousands)",
caption = "Source: Statistics Norway, table 13760"
) +
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey30", size = 10)
)
print(p5c)
}
}
}
}
```
## Key Findings
- **Goods spending is the weak link.** Volume changes in Norwegian goods consumption have been negative or near zero in several recent quarters, reflecting both high interest rates and persistent inflation eroding household purchasing power.
- **Services proved more resilient.** Spending on services in fixed 2023 prices has grown more steadily than goods across the sample period, suggesting Norwegians are cutting durable purchases before cancelling subscriptions, travel, and personal services.
- **Public sector consumption has been the most stable component.** Government spending has buffered the overall demand picture, growing at a more consistent pace than any household category — a reminder that Norway's large public sector functions as an automatic stabiliser.
- **Employment rates near multi-year highs.** The seasonally adjusted labour force data show employment as a share of the population remaining elevated, with the gender gap in employment rates narrow by historical standards.
- **The divergence is the story.** Weak consumption growth alongside robust employment is unusual. It points to a savings squeeze: Norwegians are working, but the combination of higher mortgage costs, elevated prices, and cautious sentiment is keeping spending subdued even as incomes remain stable.
## Closing Reflection
Norway's economic moment in 2026 is one of productive tension. The labour market is doing its job — keeping people in work, maintaining income flows, and preventing the kind of demand collapse seen in other countries during past tightening cycles. But consumption, the channel through which that income becomes economic activity, is faltering. Goods are sitting on shelves longer. Households are choosing experiences over objects, and both over debt.
The central question for the months ahead is whether the labour market resilience will eventually feed through into renewed consumer confidence — or whether the weight of mortgage costs and uncertainty about global trade will keep Norwegian wallets closed even as payslips keep coming. Statistics Norway's next quarterly national accounts release will be the crucial test.