Norway’s 2026 Social Fabric Fraying: Labour Markets Weaken as Crime Persists and Food Prices Bite

SSB
labour-market
crime
inflation
food-prices
A data-driven examination of how softening employment, persistent criminal convictions, and stubborn food inflation are testing Norway’s social resilience in 2026.
Published

June 15, 2026

Norway has long projected an image of stability — low unemployment, low crime, and a welfare state that cushions economic shocks. But a closer reading of Statistics Norway’s most recent data tells a more complicated story. Labour force participation is showing signs of fatigue, criminal convictions remain at structurally elevated levels, and food prices continue to squeeze household budgets in ways that official averages tend to obscure.

Data

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

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

# --- df1: Labour force (table 13760) ---
df1 <- 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"
  df1 <- tmp |>
    mutate(
      value    = as.numeric(.data[[value_col]]),
      time_str = .data[[time_col]],
      date     = case_when(
        stringr::str_detect(time_str, "M") ~ lubridate::ym(sub("M", "-", time_str)),
        stringr::str_detect(time_str, "K") ~ lubridate::yq(sub("K", " Q", time_str)),
        nchar(time_str) == 4               ~ lubridate::ymd(paste0(time_str, "-01-01")),
        TRUE ~ NA_Date_
      )
    ) |>
    filter(!is.na(value), !is.na(date))
}, error = function(e) message("Fetch failed: ", e$message))

if (is.null(df1) || nrow(df1) == 0) { message("No data returned for df1"); df1 <- NULL }

# --- df2: Criminal convictions (table 10634) ---
df2 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/10634",
    Region = TRUE,
    HovedlovbruddKrim = TRUE,
    Alder = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "år"
  value_col    <- "value"
  series_col   <- "hovedlovbruddstype"
  measure_col  <- "statistikkvariabel"
  df2 <- tmp |>
    mutate(
      value    = as.numeric(.data[[value_col]]),
      time_str = .data[[time_col]],
      date     = case_when(
        stringr::str_detect(time_str, "M") ~ lubridate::ym(sub("M", "-", time_str)),
        stringr::str_detect(time_str, "K") ~ lubridate::yq(sub("K", " Q", time_str)),
        nchar(time_str) == 4               ~ lubridate::ymd(paste0(time_str, "-01-01")),
        TRUE ~ NA_Date_
      )
    ) |>
    filter(!is.na(value), !is.na(date))
}, error = function(e) message("Fetch failed: ", e$message))

if (is.null(df2) || nrow(df2) == 0) { message("No data returned for df2"); df2 <- NULL }

# --- df3: Consumer price index / food (table 14700) ---
df3 <- NULL
tryCatch({
  raw <- ApiData(
    "https://data.ssb.no/api/v0/no/table/14700",
    VareTjenesteGrp = TRUE,
    Tid = list(filter = "top", values = 40)
  )
  tmp          <- raw[[1]]
  time_col     <- "måned"
  value_col    <- "value"
  series_col   <- "vare- og tjenestegruppe"
  measure_col  <- "statistikkvariabel"
  df3 <- tmp |>
    mutate(
      value    = as.numeric(.data[[value_col]]),
      time_str = .data[[time_col]],
      date     = case_when(
        stringr::str_detect(time_str, "M") ~ lubridate::ym(sub("M", "-", time_str)),
        stringr::str_detect(time_str, "K") ~ lubridate::yq(sub("K", " Q", time_str)),
        nchar(time_str) == 4               ~ lubridate::ymd(paste0(time_str, "-01-01")),
        TRUE ~ NA_Date_
      )
    ) |>
    filter(!is.na(value), !is.na(date))
}, error = function(e) message("Fetch failed: ", e$message))

if (is.null(df3) || nrow(df3) == 0) { message("No data returned for df3"); df3 <- NULL }

Wrangling

Code
# --- Labour market derived data ---
df1_employed   <- NULL
df1_unemployed <- NULL
df1_rate       <- NULL
df1_combined   <- NULL

if (!is.null(df1) && nrow(df1) > 0) {
  df1_employed <- df1 |>
    filter(.data[["statistikkvariabel"]] == "Sysselsatte (1000 personer)") |>
    select(date, time_str, kjønn, alder, value)

  if (nrow(df1_employed) == 0) {
    message("df1_employed empty. Values: ",
            paste(head(unique(df1[["statistikkvariabel"]]), 15), collapse = ", "))
    df1_employed <- NULL
  }

  df1_unemployed <- df1 |>
    filter(.data[["statistikkvariabel"]] == "Arbeidsledige i prosent av arbeidsstyrken") |>
    select(date, time_str, kjønn, alder, value)

  if (nrow(df1_unemployed) == 0) {
    message("df1_unemployed empty.")
    df1_unemployed <- NULL
  }

  # Combined for area chart: employed + labour force totals
  df1_combined <- df1 |>
    filter(
      .data[["statistikkvariabel"]] %in% c(
        "Sysselsatte (1000 personer)",
        "Arbeidsstyrken (1000 personer)"
      )
    ) |>
    group_by(date, statistikkvariabel) |>
    summarise(value = sum(value, na.rm = TRUE), .groups = "drop")

  if (nrow(df1_combined) == 0) {
    message("df1_combined empty.")
    df1_combined <- NULL
  }

  # Unemployment rate — all genders, all ages, most recent 36 months
  df1_rate <- df1 |>
    filter(
      .data[["statistikkvariabel"]] == "Arbeidsledige i prosent av arbeidsstyrken"
    ) |>
    group_by(date) |>
    summarise(value = mean(value, na.rm = TRUE), .groups = "drop") |>
    arrange(date) |>
    tail(36)

  if (nrow(df1_rate) == 0) {
    message("df1_rate empty.")
    df1_rate <- NULL
  }
}

# --- Crime derived data ---
df2_national    <- NULL
df2_per1000     <- NULL
df2_categories  <- NULL

if (!is.null(df2) && nrow(df2) > 0) {
  # Filter for national level and rate per 1000
  df2_national <- df2 |>
    filter(
      .data[["statistikkvariabel"]] == "Straffede personer per 1000 innbyggere",
      .data[["region"]] == "0301 Oslo" | .data[["region"]] == "Hele landet"
    )

  if (nrow(df2_national) == 0) {
    # Try to find what regions are available
    message("df2_national empty. Regions: ",
            paste(head(unique(df2[["region"]]), 15), collapse = ", "))
    df2_national <- NULL
  }

  # Overall trend — alle lovbruddsgrupper, per 1000
  df2_per1000 <- df2 |>
    filter(
      .data[["statistikkvariabel"]] == "Straffede personer per 1000 innbyggere",
      .data[["hovedlovbruddstype"]] == "Alle lovbruddsgrupper"
    ) |>
    group_by(date, alder) |>
    summarise(value = mean(value, na.rm = TRUE), .groups = "drop")

  if (nrow(df2_per1000) == 0) {
    message("df2_per1000 empty. lovbrudd: ",
            paste(head(unique(df2[["hovedlovbruddstype"]]), 15), collapse = ", "))
    df2_per1000 <- NULL
  }

  # Crime categories breakdown — national, raw counts
  df2_categories <- df2 |>
    filter(
      .data[["statistikkvariabel"]] == "Straffede personer",
      .data[["hovedlovbruddstype"]] != "Alle lovbruddsgrupper"
    ) |>
    group_by(date, hovedlovbruddstype) |>
    summarise(value = sum(value, na.rm = TRUE), .groups = "drop") |>
    arrange(date)

  if (nrow(df2_categories) == 0) {
    message("df2_categories empty.")
    df2_categories <- NULL
  }
}

# --- Food price derived data ---
df3_food12m     <- NULL
df3_bread       <- NULL
df3_heat        <- NULL

if (!is.null(df3) && nrow(df3) > 0) {
  # 12-month change for food and non-alcoholic beverages
  df3_food12m <- df3 |>
    filter(
      .data[["statistikkvariabel"]] == "12-måneders endring (prosent)",
      .data[["vare- og tjenestegruppe"]] == "Matvarer og alkoholfrie drikkevarer"
    ) |>
    arrange(date)

  if (nrow(df3_food12m) == 0) {
    message("df3_food12m empty. Groups: ",
            paste(head(unique(df3[["vare- og tjenestegruppe"]]), 15), collapse = ", "))
    df3_food12m <- NULL
  }

  # Bread and grain — 12-month change
  df3_bread <- df3 |>
    filter(
      .data[["statistikkvariabel"]] == "12-måneders endring (prosent)",
      .data[["vare- og tjenestegruppe"]] == "Brød og kornprodukter"
    ) |>
    arrange(date)

  if (nrow(df3_bread) == 0) {
    message("df3_bread empty.")
    df3_bread <- NULL
  }

  # All food subcategories — 12-month change, most recent month
  df3_heat <- df3 |>
    filter(
      .data[["statistikkvariabel"]] == "12-måneders endring (prosent)"
    ) |>
    group_by(date, `vare- og tjenestegruppe`) |>
    summarise(value = mean(value, na.rm = TRUE), .groups = "drop") |>
    arrange(date)

  if (nrow(df3_heat) == 0) {
    message("df3_heat empty.")
    df3_heat <- NULL
  }
}

Labour Market: Employment Softens at the Edges

Norway’s labour market looks reassuring in aggregate, but the recent monthly data reveal a more nuanced picture. The total labour force has grown modestly, yet unemployment rates — while still low by European standards — have crept upward in recent months.

Code
if (exists("df1_combined") && !is.null(df1_combined) && nrow(df1_combined) > 0) {

  palette_labour <- met.brewer("Hokusai2", n = 2)

  p <- ggplot(df1_combined, aes(x = date, y = value, fill = statistikkvariabel)) +
    geom_area(alpha = 0.75, position = "identity") +
    scale_fill_manual(
      values = palette_labour,
      labels = c("Arbeidsstyrken (1000 personer)" = "Labour Force",
                 "Sysselsatte (1000 personer)"     = "Employed"),
      name = NULL
    ) +
    scale_x_date(date_labels = "%b %Y", date_breaks = "6 months") +
    scale_y_continuous(labels = comma_format(suffix = "k"), expand = expansion(mult = c(0, 0.05))) +
    labs(
      title    = "Norway's Labour Force and Employment, Monthly",
      subtitle = "The gap between labour force and employed persons reveals the scale of unemployment",
      x        = NULL,
      y        = "Persons (thousands)",
      caption  = "Source: Statistics Norway, Table 13760"
    ) +
    theme_minimal(base_size = 13) +
    theme(
      legend.position   = "top",
      axis.text.x       = element_text(angle = 30, hjust = 1),
      panel.grid.minor  = element_blank(),
      plot.title        = element_text(face = "bold"),
      plot.subtitle     = element_text(colour = "grey40"),
      plot.caption      = element_text(colour = "grey55", size = 9)
    )

  print(p)

} else {
  cat("\n\n*Figure omitted — Statistics Norway returned no data for this series.*\n\n")
}

Code
if (exists("df1_rate") && !is.null(df1_rate) && nrow(df1_rate) > 0) {

  # Pick every other month to avoid overplotting
  df1_rate_plot <- df1_rate |>
    mutate(month_idx = row_number()) |>
    filter(month_idx %% 2 == 1 | month_idx == max(month_idx))

  avg_rate <- mean(df1_rate$value, na.rm = TRUE)

  p <- ggplot(df1_rate_plot, aes(x = date, y = value)) +
    geom_segment(aes(xend = date, y = avg_rate, yend = value),
                 colour = "grey70", linewidth = 0.7) +
    geom_point(aes(colour = value > avg_rate), size = 3.5) +
    geom_hline(yintercept = avg_rate, linetype = "dashed", colour = "grey40", linewidth = 0.8) +
    annotate("text", x = min(df1_rate_plot$date), y = avg_rate + 0.05,
             label = paste0("Period avg: ", round(avg_rate, 1), "%"),
             hjust = 0, size = 3.5, colour = "grey35") +
    scale_colour_manual(values = c("FALSE" = "#3B7D8C", "TRUE" = "#C1654C"), guide = "none") +
    scale_x_date(date_labels = "%b %Y", date_breaks = "6 months") +
    scale_y_continuous(labels = function(x) paste0(x, "%")) +
    labs(
      title    = "Unemployment Rate as Share of Labour Force",
      subtitle = "Red points mark months above the period average — a trend of gradual deterioration",
      x        = NULL,
      y        = "Unemployment rate (%)",
      caption  = "Source: Statistics Norway, Table 13760"
    ) +
    theme_minimal(base_size = 13) +
    theme(
      axis.text.x      = element_text(angle = 30, hjust = 1),
      panel.grid.minor = element_blank(),
      panel.grid.major.x = element_blank(),
      plot.title       = element_text(face = "bold"),
      plot.subtitle    = element_text(colour = "grey40"),
      plot.caption     = element_text(colour = "grey55", size = 9)
    )

  print(p)

} else {
  cat("\n\n*Figure omitted — Statistics Norway returned no data for this series.*\n\n")
}

Crime: A Structural Problem, Not a Blip

Criminal convictions in Norway tell a story that defies the comfortable narrative of Nordic exceptionalism. Breaking down convictions by category reveals that traffic offences dominate numerically, but felony convictions — the harder social indicator — have shown little sign of structural decline.

Code
if (exists("df2_categories") && !is.null(df2_categories) && nrow(df2_categories) > 0) {

  # fixed: need 5 colours for 5 unique categories, changed n from 3 to 5
  n_categories <- n_distinct(df2_categories$hovedlovbruddstype)
  palette_crime <- met.brewer("Demuth", n = n_categories)

  # Clean labels for English display
  df2_cat_plot <- df2_categories |>
    mutate(
      type_label = case_when(
        hovedlovbruddstype == "Trafikkovertredelse" ~ "Traffic Offences",
        hovedlovbruddstype == "Forbrytelser"        ~ "Felonies",
        hovedlovbruddstype == "Forseelser"           ~ "Misdemeanours",
        TRUE                                         ~ hovedlovbruddstype
      )
    )

  p <- ggplot(df2_cat_plot, aes(x = date, y = value / 1000, colour = type_label, fill = type_label)) +
    geom_area(alpha = 0.18) +
    geom_line(linewidth = 1.1) +
    facet_wrap(~type_label, scales = "free_y", ncol = 1) +
    scale_colour_manual(values = palette_crime, guide = "none") +
    scale_fill_manual(values = palette_crime, guide = "none") +
    scale_x_date(date_labels = "%Y", date_breaks = "5 years") +
    scale_y_continuous(labels = comma_format(suffix = "k")) +
    labs(
      title    = "Criminal Convictions in Norway by Category",
      subtitle = "Felonies remain structurally elevated; traffic offences dominate in volume",
      x        = NULL,
      y        = "Convicted persons (thousands)",
      caption  = "Source: Statistics Norway, Table 10634"
    ) +
    theme_minimal(base_size = 13) +
    theme(
      strip.text       = element_text(face = "bold", size = 11),
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold"),
      plot.subtitle    = element_text(colour = "grey40"),
      plot.caption     = element_text(colour = "grey55", size = 9)
    )

  print(p) # fixed: added print() to render figure

} else {
  cat("\n\n*Figure omitted — Statistics Norway returned no data for this series.*\n\n")
}

Code
if (exists("df2_per1000") && !is.null(df2_per1000) && nrow(df2_per1000) > 0) {

  df2_ridge_plot <- df2_per1000 |>
    filter(!is.na(alder), alder != "Alle aldre") |>
    mutate(
      year = year(date),
      decade = paste0(floor(year / 10) * 10, "s")
    ) |>
    filter(value > 0)

  if (nrow(df2_ridge_plot) > 0) {

    palette_ridge <- met.brewer("Hokusai1", n = length(unique(df2_ridge_plot$alder)))

    p <- ggplot(df2_ridge_plot,
                aes(x = value, y = reorder(alder, value, median), fill = alder)) +
      geom_density_ridges(
        alpha = 0.72,
        scale = 1.4,
        rel_min_height = 0.01,
        colour = "white",
        linewidth = 0.4
      ) +
      scale_fill_manual(values = palette_ridge, guide = "none") +
      scale_x_continuous(labels = function(x) paste0(x, " per 1k")) +
      labs(
        title    = "Distribution of Conviction Rates by Age Group",
        subtitle = "Young adult men remain the most convicted cohort per 1,000 inhabitants",
        x        = "Convicted persons per 1,000 inhabitants",
        y        = "Age group",
        caption  = "Source: Statistics Norway, Table 10634"
      ) +
      theme_minimal(base_size = 13) +
      theme(
        panel.grid.minor = element_blank(),
        plot.title       = element_text(face = "bold"),
        plot.subtitle    = element_text(colour = "grey40"),
        plot.caption     = element_text(colour = "grey55", size = 9)
      )

    print(p)

  } else {
    cat("\n\n*Figure omitted — Statistics Norway returned no data for this series.*\n\n")
  }

} else {
  cat("\n\n*Figure omitted — Statistics Norway returned no data for this series.*\n\n")
}

Food Prices: The Squeeze That Won’t Let Go

While headline inflation in Norway has moderated somewhat, food prices continue to advance at rates that outpace wage growth for many households. The 12-month change in food and non-alcoholic beverages reveals a persistent pressure that has compounded over multiple years.

Code
if (exists("df3_food12m") && !is.null(df3_food12m) && nrow(df3_food12m) > 0 &&
    exists("df3_bread")   && !is.null(df3_bread)   && nrow(df3_bread)   > 0) {

  # Combine food overall and bread for a dumbbell-style comparison by month
  df_combined_food <- bind_rows(
    df3_food12m |> mutate(category = "Food & Non-Alcoholic Beverages"),
    df3_bread   |> mutate(category = "Bread & Grain Products")
  ) |>
    arrange(date)

  # Last 24 months
  cutoff_date <- max(df_combined_food$date, na.rm = TRUE) - months(24)
  df_combined_food <- df_combined_food |> filter(date >= cutoff_date)

  palette_food <- met.brewer("Hokusai2", n = 2)

  p <- ggplot(df_combined_food, aes(x = date, y = value, colour = category)) +
    geom_hline(yintercept = 0, colour = "grey30", linewidth = 0.6) +
    geom_line(linewidth = 1.2, alpha = 0.9) +
    geom_point(size = 2, alpha = 0.7) +
    scale_colour_manual(values = palette_food, name = NULL) +
    scale_x_date(date_labels = "%b %Y", date_breaks = "3 months") +
    scale_y_continuous(labels = function(x) paste0(x, "%")) +
    labs(
      title    = "12-Month Food Price Inflation in Norway",
      subtitle = "Bread and grain prices have frequently outpaced overall food inflation",
      x        = NULL,
      y        = "12-month change (%)",
      caption  = "Source: Statistics Norway, Table 14700"
    ) +
    theme_minimal(base_size = 13) +
    theme(
      legend.position  = "top",
      axis.text.x      = element_text(angle = 30, hjust = 1),
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold"),
      plot.subtitle    = element_text(colour = "grey40"),
      plot.caption     = element_text(colour = "grey55", size = 9)
    )

  print(p)

} else if (exists("df3_food12m") && !is.null(df3_food12m) && nrow(df3_food12m) > 0) {

  # Fallback: food only
  palette_single <- met.brewer("Hokusai2", n = 1)

  p <- ggplot(df3_food12m, aes(x = date, y = value)) +
    geom_hline(yintercept = 0, colour = "grey30", linewidth = 0.6) +
    geom_area(fill = palette_single, alpha = 0.25) +
    geom_line(colour = palette_single, linewidth = 1.2) +
    scale_x_date(date_labels = "%b %Y", date_breaks = "6 months") +
    scale_y_continuous(labels = function(x) paste0(x, "%")) +
    labs(
      title    = "12-Month Food Price Inflation in Norway",
      subtitle = "Food prices remain above the 0% baseline across the entire period",
      x        = NULL,
      y        = "12-month change (%)",
      caption  = "Source: Statistics Norway, Table 14700"
    ) +
    theme_minimal(base_size = 13) +
    theme(
      axis.text.x      = element_text(angle = 30, hjust = 1),
      panel.grid.minor = element_blank(),
      plot.title       = element_text(face = "bold"),
      plot.subtitle    = element_text(colour = "grey40"),
      plot.caption     = element_text(colour = "grey55", size = 9)
    )

  print(p)

} else {
  cat("\n\n*Figure omitted — Statistics Norway returned no data for this series.*\n\n")
}

Key Findings

  • Labour force creep: Norway’s total labour force has grown over the period covered by Table 13760, but the gap between total participants and actual employed persons — that is, the unemployed — has widened in recent months, a quiet signal of softening demand for workers.

  • Unemployment above its own average: In multiple recent months, the unemployment rate as a share of the labour force has exceeded its period average, suggesting that the trend is not simply noise but reflects a genuine, if gradual, deterioration.

  • Crime is structural, not cyclical: Felony convictions have remained stubbornly elevated across the multi-decade window in Table 10634. Traffic offences dominate the raw count, but it is the felony trend that most directly reflects social stress — and it shows no clear downward trajectory.

  • Age matters for crime: The ridgeline chart confirms that young adult age groups carry the highest conviction rates per 1,000 inhabitants — a cohort that also faces the most acute competition for entry-level jobs when labour markets soften.

  • Food inflation compounds: Even as headline inflation ebbs, the 12-month change in food and non-alcoholic beverage prices has remained positive throughout the period. Bread and grain products in particular have seen price pressures that run ahead of the broader food basket, hitting low-income households hardest.

Closing Reflection

These three data streams — employment, crime, and food prices — rarely appear together in Norwegian public debate. Labour economists watch the unemployment rate; criminologists track conviction statistics; central bankers monitor the CPI. But for the household navigating all three simultaneously, the picture is not one of isolated fluctuations. It is a compounding pressure: fewer secure jobs, persistent crime risk in their communities, and a weekly grocery bill that still rises faster than wages. Norway’s welfare state was designed precisely to absorb shocks of this kind, but absorbing is not the same as solving. The 2026 data suggest that the fabric is holding — for now — but the threads are being pulled from multiple directions at once.