Cost of a Healthy Diet: US vs. Sweden

FAO CoAHD Data Analysis

Author

Mark Hamer

Published

March 8, 2026

Introduction

Food prices have become a political issue in both the United States and Sweden over the past few years. Inflation hit grocery bills hard, wages haven’t always kept up, and there’s a growing sense that eating well has become a luxury rather than a baseline.

But how do the two countries actually compare? And what does “affordable healthy food” even mean when the definition of healthy — and the regulatory standards behind it — differ so significantly between them?

This analysis starts with FAO data on the cost of a healthy diet, then digs into what that data is actually measuring, and builds an alternative comparison that better reflects real-world food quality standards. We look at national trends from 2017 to 2024, city-level grocery prices across the three largest cities in each country, and finally what a premium healthy basket costs relative to income — from high earners down to those living at the poverty line.

The goal isn’t to declare a winner. It’s to understand what healthy eating actually costs, for whom, and whether that’s been getting better or worse.

Code
library(tidyverse)
library(scales)
Code
raw <- read_csv("Cost_Affordability_Healthy_Diet_(CoAHD)_E_All_Data.csv")

df <- raw |>
  # Filter to our two countries and the main SOFI release
  filter(
    Area %in% c("United States of America", "Sweden"),
    Release == "July 2025 (SOFI report)"
  ) |>
  # Drop flag columns (Y2017F, Y2018F, etc.)
  select(Area, Item, Unit, matches("^Y\\d{4}$")) |>
  # Pivot to long format
  pivot_longer(
    cols      = matches("^Y\\d{4}$"),
    names_to  = "Year",
    values_to = "Value"
  ) |>
  mutate(
    Year  = as.integer(str_remove(Year, "^Y")),
    Value = as.numeric(Value)
  ) |>
  filter(!is.na(Value))

glimpse(df)
Rows: 88
Columns: 5
$ Area  <chr> "Sweden", "Sweden", "Sweden", "Sweden", "Sweden", "Sweden", "Swe…
$ Item  <chr> "Cost of a healthy diet (CoHD), LCU per person per day", "Cost o…
$ Unit  <chr> "LCU/cap/d", "LCU/cap/d", "LCU/cap/d", "LCU/cap/d", "LCU/cap/d",…
$ Year  <int> 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2017, 2018, 2019…
$ Value <dbl> 25.66, 26.27, 27.00, 27.58, 27.69, 30.78, 34.57, 35.07, 2.71, 2.…
Code
# PPP cost of healthy diet over time
df_ppp <- df |>
  filter(
    str_detect(Item, "Cost of a healthy diet"),
    str_detect(Unit, "PPP")
  )

# Affordability - prevalence of unaffordability
df_afford <- df |>
  filter(str_detect(Item, "Prevalence of unaffordability"))

# Quick check
df_ppp |> count(Area, Year)
# A tibble: 16 × 3
   Area                      Year     n
   <chr>                    <int> <int>
 1 Sweden                    2017     1
 2 Sweden                    2018     1
 3 Sweden                    2019     1
 4 Sweden                    2020     1
 5 Sweden                    2021     1
 6 Sweden                    2022     1
 7 Sweden                    2023     1
 8 Sweden                    2024     1
 9 United States of America  2017     1
10 United States of America  2018     1
11 United States of America  2019     1
12 United States of America  2020     1
13 United States of America  2021     1
14 United States of America  2022     1
15 United States of America  2023     1
16 United States of America  2024     1
Code
df_afford |> count(Area, Year)
# A tibble: 16 × 3
   Area                      Year     n
   <chr>                    <int> <int>
 1 Sweden                    2017     1
 2 Sweden                    2018     1
 3 Sweden                    2019     1
 4 Sweden                    2020     1
 5 Sweden                    2021     1
 6 Sweden                    2022     1
 7 Sweden                    2023     1
 8 Sweden                    2024     1
 9 United States of America  2017     1
10 United States of America  2018     1
11 United States of America  2019     1
12 United States of America  2020     1
13 United States of America  2021     1
14 United States of America  2022     1
15 United States of America  2023     1
16 United States of America  2024     1
Code
pal <- c("United States of America" = "#1f77b4", "Sweden" = "#e6a817")

ggplot(df_ppp, aes(x = Year, y = Value, colour = Area, group = Area)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  scale_colour_manual(values = pal, labels = c("Sweden", "United States")) +
  scale_x_continuous(breaks = 2017:2024) +
  scale_y_continuous(labels = dollar_format(prefix = "$"), limits = c(0, NA)) +
  labs(
    title   = "Cost of a Healthy Diet (PPP $/person/day)",
    subtitle = "United States vs. Sweden, 2017–2024",
    x       = NULL,
    y       = "PPP $ per person per day",
    colour  = NULL,
    caption = "Source: FAOSTAT CoAHD, July 2025 SOFI report"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    legend.position  = "bottom",
    panel.grid.minor = element_blank()
  )
Figure 1: Cost of a healthy diet in PPP dollars per person per day, 2017–2024.
Code
df |> distinct(Item) |> print(n = 30)
# A tibble: 16 × 1
   Item                                                           
   <chr>                                                          
 1 Cost of a healthy diet (CoHD), LCU per person per day          
 2 Cost of a healthy diet (CoHD), PPP dollar per person per day   
 3 Cost of starchy staples, LCU per person per day                
 4 Cost of starchy staples, PPP dollar per person per day         
 5 Cost of animal source foods, LCU per person per day            
 6 Cost of animal source foods, PPP dollar per person per day     
 7 Cost of legumes, nuts and seeds, LCU per person per day        
 8 Cost of legumes, nuts and seeds, PPP dollar per person per day 
 9 Cost of vegetables, LCU per person per day                     
10 Cost of vegetables, PPP dollar per person per day              
11 Cost of fruits, LCU per person per day                         
12 Cost of fruits, PPP dollar per person per day                  
13 Cost of oils and fats, LCU per person per day                  
14 Cost of oils and fats, PPP dollar per person per day           
15 Prevalence of unaffordability (PUA), percent                   
16 Number of people unable to afford a healthy diet (NUA), million
Code
# OECD median annual wages in local currency
# Source: OECD Average Annual Wages dataset
wages <- tribble(
  ~Area,                      ~median_annual_wage_lcu, ~currency,
  "United States of America",  56000,                  "USD",
  "Sweden",                    370000,                 "SEK"
) |>
  mutate(
    median_daily_wage_lcu  = median_annual_wage_lcu / 365,
    median_hourly_wage_lcu = median_annual_wage_lcu / (52 * 40)  # 52 weeks, 40hr week
  )

wages
# A tibble: 2 × 5
  Area                     median_annual_wage_lcu currency median_daily_wage_lcu
  <chr>                                     <dbl> <chr>                    <dbl>
1 United States of America                  56000 USD                       153.
2 Sweden                                   370000 SEK                      1014.
# ℹ 1 more variable: median_hourly_wage_lcu <dbl>
Code
# Get LCU cost of healthy diet only
df_lcu <- df |>
  filter(
    str_detect(Item, "Cost of a healthy diet"),
    str_detect(Unit, "LCU")
  )

# Join and calculate both metrics
df_metrics <- df_lcu |>
  left_join(wages, by = "Area") |>
  mutate(
    # % of annual income spent on healthy diet
    pct_income = (Value * 365) / median_annual_wage_lcu * 100,
    
    # Minutes of work to afford one day's healthy diet
    minutes_of_work = (Value / median_hourly_wage_lcu) * 60
  )

df_metrics |> select(Area, Year, Value, pct_income, minutes_of_work)
# A tibble: 16 × 5
   Area                      Year Value pct_income minutes_of_work
   <chr>                    <int> <dbl>      <dbl>           <dbl>
 1 Sweden                    2017 25.7        2.53            8.66
 2 Sweden                    2018 26.3        2.59            8.86
 3 Sweden                    2019 27          2.66            9.11
 4 Sweden                    2020 27.6        2.72            9.30
 5 Sweden                    2021 27.7        2.73            9.34
 6 Sweden                    2022 30.8        3.04           10.4 
 7 Sweden                    2023 34.6        3.41           11.7 
 8 Sweden                    2024 35.1        3.46           11.8 
 9 United States of America  2017  2.17       1.41            4.84
10 United States of America  2018  2.18       1.42            4.86
11 United States of America  2019  2.2        1.43            4.90
12 United States of America  2020  2.28       1.49            5.08
13 United States of America  2021  2.36       1.54            5.26
14 United States of America  2022  2.63       1.71            5.86
15 United States of America  2023  2.76       1.80            6.15
16 United States of America  2024  2.79       1.82            6.22
Code
ggplot(df_metrics, aes(x = Year, y = pct_income, colour = Area, group = Area)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  scale_colour_manual(values = pal, labels = c("Sweden", "United States")) +
  scale_x_continuous(breaks = 2017:2024) +
  scale_y_continuous(labels = percent_format(scale = 1), limits = c(0, NA)) +
  labs(
    title    = "Healthy Diet Cost as % of Median Annual Wage",
    subtitle = "United States vs. Sweden, 2017–2024",
    x        = NULL,
    y        = "% of median annual wage",
    colour   = NULL,
    caption  = "Sources: FAOSTAT CoAHD; OECD Average Annual Wages"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    legend.position  = "bottom",
    panel.grid.minor = element_blank()
  )
Figure 2: Annual cost of a healthy diet as a share of median wage, 2017–2024.
Code
ggplot(df_metrics, aes(x = Year, y = minutes_of_work, colour = Area, group = Area)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  scale_colour_manual(values = pal, labels = c("Sweden", "United States")) +
  scale_x_continuous(breaks = 2017:2024) +
  scale_y_continuous(limits = c(0, NA)) +
  labs(
    title    = "Minutes of Work to Afford One Day's Healthy Diet",
    subtitle = "United States vs. Sweden, 2017–2024",
    x        = NULL,
    y        = "Minutes of work",
    colour   = NULL,
    caption  = "Sources: FAOSTAT CoAHD; OECD Average Annual Wages"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    legend.position  = "bottom",
    panel.grid.minor = element_blank()
  )
Figure 3: Minutes of work at median wage to afford one day’s healthy diet, 2017–2024.

OBSERVATION

My hypothesis was that Sweden would be cheaper, so I was surprised by this chart, but then I wondered how the prices for the “healthy meal” was calculated. I found that the FAO uses the cheapest option available. I know that Swedish and EU standards are higher for the quality of food that they can sell, so I wanted to find data that would better represent a healthy meal, not only in terms of nutrition, but also food quality.

So, using Numbeo I got some data that better represented US grocery prices. I made my own “food nutrition basket based of:” and pulled prices using Numbeo for both countries, and for fun compared nyc to stockholm too.

Code
# Hardcoded from last successful Numbeo scrape
food_raw <- tribble(
  ~city,         ~country, ~item,                            ~price_usd,
  "Stockholm",   "Sweden", "Milk (Regular, 1 Liter)",        1.55,
  "Stockholm",   "Sweden", "Fresh White Bread (1 lb Loaf)",  3.09,
  "Stockholm",   "Sweden", "White Rice (1 lb)",              1.60,
  "Stockholm",   "Sweden", "Eggs (12, Large Size)",          4.32,
  "Stockholm",   "Sweden", "Chicken Fillets (1 lb)",         5.52,
  "Stockholm",   "Sweden", "Apples (1 lb)",                  1.41,
  "Stockholm",   "Sweden", "Bananas (1 lb)",                 1.17,
  "Stockholm",   "Sweden", "Tomatoes (1 lb)",                1.87,
  "Stockholm",   "Sweden", "Potatoes (1 lb)",                0.77,
  "Stockholm",   "Sweden", "Onions (1 lb)",                  0.77,
  "Stockholm",   "Sweden", "Lettuce (1 Head)",               1.37,
  "Gothenburg",  "Sweden", "Milk (Regular, 1 Liter)",        1.45,
  "Gothenburg",  "Sweden", "Fresh White Bread (1 lb Loaf)",  2.80,
  "Gothenburg",  "Sweden", "White Rice (1 lb)",              1.45,
  "Gothenburg",  "Sweden", "Eggs (12, Large Size)",          3.90,
  "Gothenburg",  "Sweden", "Chicken Fillets (1 lb)",         5.10,
  "Gothenburg",  "Sweden", "Apples (1 lb)",                  1.28,
  "Gothenburg",  "Sweden", "Bananas (1 lb)",                 1.05,
  "Gothenburg",  "Sweden", "Tomatoes (1 lb)",                1.70,
  "Gothenburg",  "Sweden", "Potatoes (1 lb)",                0.68,
  "Gothenburg",  "Sweden", "Onions (1 lb)",                  0.68,
  "Gothenburg",  "Sweden", "Lettuce (1 Head)",               1.25,
  "Malmo",       "Sweden", "Milk (Regular, 1 Liter)",        1.42,
  "Malmo",       "Sweden", "Fresh White Bread (1 lb Loaf)",  2.75,
  "Malmo",       "Sweden", "White Rice (1 lb)",              1.42,
  "Malmo",       "Sweden", "Eggs (12, Large Size)",          3.85,
  "Malmo",       "Sweden", "Chicken Fillets (1 lb)",         5.00,
  "Malmo",       "Sweden", "Apples (1 lb)",                  1.25,
  "Malmo",       "Sweden", "Bananas (1 lb)",                 1.02,
  "Malmo",       "Sweden", "Tomatoes (1 lb)",                1.65,
  "Malmo",       "Sweden", "Potatoes (1 lb)",                0.65,
  "Malmo",       "Sweden", "Onions (1 lb)",                  0.65,
  "Malmo",       "Sweden", "Lettuce (1 Head)",               1.20,
  "New-York",    "USA",    "Milk (Regular, 1 Liter)",        1.20,
  "New-York",    "USA",    "Fresh White Bread (1 lb Loaf)",  3.90,
  "New-York",    "USA",    "White Rice (1 lb)",              2.10,
  "New-York",    "USA",    "Eggs (12, Large Size)",          4.80,
  "New-York",    "USA",    "Chicken Fillets (1 lb)",         6.20,
  "New-York",    "USA",    "Apples (1 lb)",                  2.20,
  "New-York",    "USA",    "Bananas (1 lb)",                 0.75,
  "New-York",    "USA",    "Tomatoes (1 lb)",                2.50,
  "New-York",    "USA",    "Potatoes (1 lb)",                1.50,
  "New-York",    "USA",    "Onions (1 lb)",                  1.40,
  "New-York",    "USA",    "Lettuce (1 Head)",               2.80,
  "Los-Angeles", "USA",    "Milk (Regular, 1 Liter)",        1.15,
  "Los-Angeles", "USA",    "Fresh White Bread (1 lb Loaf)",  3.70,
  "Los-Angeles", "USA",    "White Rice (1 lb)",              1.90,
  "Los-Angeles", "USA",    "Eggs (12, Large Size)",          4.50,
  "Los-Angeles", "USA",    "Chicken Fillets (1 lb)",         5.80,
  "Los-Angeles", "USA",    "Apples (1 lb)",                  2.00,
  "Los-Angeles", "USA",    "Bananas (1 lb)",                 0.70,
  "Los-Angeles", "USA",    "Tomatoes (1 lb)",                2.30,
  "Los-Angeles", "USA",    "Potatoes (1 lb)",                1.30,
  "Los-Angeles", "USA",    "Onions (1 lb)",                  1.20,
  "Los-Angeles", "USA",    "Lettuce (1 Head)",               2.50,
  "Chicago",     "USA",    "Milk (Regular, 1 Liter)",        1.05,
  "Chicago",     "USA",    "Fresh White Bread (1 lb Loaf)",  3.50,
  "Chicago",     "USA",    "White Rice (1 lb)",              1.80,
  "Chicago",     "USA",    "Eggs (12, Large Size)",          4.20,
  "Chicago",     "USA",    "Chicken Fillets (1 lb)",         5.50,
  "Chicago",     "USA",    "Apples (1 lb)",                  1.90,
  "Chicago",     "USA",    "Bananas (1 lb)",                 0.65,
  "Chicago",     "USA",    "Tomatoes (1 lb)",                2.10,
  "Chicago",     "USA",    "Potatoes (1 lb)",                1.20,
  "Chicago",     "USA",    "Onions (1 lb)",                  1.10,
  "Chicago",     "USA",    "Lettuce (1 Head)",               2.30,
)
Code
scrape_numbeo_food <- function(city, country) {
  url <- paste0("https://www.numbeo.com/food-prices/in/", city)
  page <- read_html(url)
  table <- page |>
    html_elements("table") |>
    pluck(4) |>
    html_table(fill = TRUE)
  names(table) <- c("item", "price", "range")
  table |>
    filter(!is.na(price), price != "", item != "") |>
    mutate(
      city      = city,
      country   = country,
      price_usd = as.numeric(str_extract(price, "[0-9]+\\.?[0-9]*"))
    ) |>
    select(city, country, item, price_usd)
}

cities <- tribble(
  ~city,         ~country,
  "Stockholm",   "Sweden",
  "Gothenburg",  "Sweden",
  "Malmo",       "Sweden",
  "New-York",    "USA",
  "Los-Angeles", "USA",
  "Chicago",     "USA"
)

food_raw <- map2_dfr(cities$city, cities$country, scrape_numbeo_food)

Can’t find csv for the data I want

Had to scrape the site because I couldn’t download a csv. It took the SEK so need to convert that scraped data to usd.

Code
# Current approximate exchange rate
sek_to_usd <- 0.091

# Items we want to keep - map to FAO food groups
items_keep <- c(
  "Milk (Regular, 1 Liter)",
  "Fresh White Bread (1 lb Loaf)",
  "White Rice (1 lb)",
  "Eggs (12, Large Size)",
  "Chicken Fillets (1 lb)",
  "Apples (1 lb)",
  "Bananas (1 lb)",
  "Tomatoes (1 lb)",
  "Potatoes (1 lb)",
  "Onions (1 lb)",
  "Lettuce (1 Head)"
)

food_clean <- food_raw |>
  filter(item %in% items_keep) |>
  mutate(
    # Prices already in USD (pre-converted in hardcoded data)
price_usd = price_usd,
    # Convert lb prices to per kg (1 lb = 0.453 kg, so per kg = price / 0.453)
    price_per_kg_usd = case_when(
      str_detect(item, "1 lb") ~ price_usd / 0.453,
      TRUE                     ~ price_usd  # milk per liter, eggs per dozen stay as-is
    ),
    # Assign FAO food groups
    food_group = case_when(
      str_detect(item, "Chicken")           ~ "Animal source foods",
      str_detect(item, "Eggs")              ~ "Animal source foods",
      str_detect(item, "Milk")              ~ "Animal source foods",
      str_detect(item, "Rice|Bread")        ~ "Starchy staples",
      str_detect(item, "Apples|Bananas")    ~ "Fruits",
      str_detect(item, "Tomatoes|Lettuce|Potatoes|Onions") ~ "Vegetables",
      TRUE ~ "Other"
    ),
    # Clean city names for display
    city = str_replace_all(city, "-", " ")
  )

food_clean |> filter(city == "Stockholm")
# A tibble: 11 × 6
   city      country item                  price_usd price_per_kg_usd food_group
   <chr>     <chr>   <chr>                     <dbl>            <dbl> <chr>     
 1 Stockholm Sweden  Milk (Regular, 1 Lit…      1.55             1.55 Animal so…
 2 Stockholm Sweden  Fresh White Bread (1…      3.09             6.82 Starchy s…
 3 Stockholm Sweden  White Rice (1 lb)          1.6              3.53 Starchy s…
 4 Stockholm Sweden  Eggs (12, Large Size)      4.32             4.32 Animal so…
 5 Stockholm Sweden  Chicken Fillets (1 l…      5.52            12.2  Animal so…
 6 Stockholm Sweden  Apples (1 lb)              1.41             3.11 Fruits    
 7 Stockholm Sweden  Bananas (1 lb)             1.17             2.58 Fruits    
 8 Stockholm Sweden  Tomatoes (1 lb)            1.87             4.13 Vegetables
 9 Stockholm Sweden  Potatoes (1 lb)            0.77             1.70 Vegetables
10 Stockholm Sweden  Onions (1 lb)              0.77             1.70 Vegetables
11 Stockholm Sweden  Lettuce (1 Head)           1.37             1.37 Vegetables

Prices Look More Accurate

Now so I’m going to compare the third largest Swedish cities to the third largest American cities.

Code
# Order cities geographically/logically
city_order <- c("Stockholm", "Gothenburg", "Malmo",
                "New York",  "Los Angeles", "Chicago")

city_labels <- c(
  "Stockholm"   = "Stockholm\n(Sweden #1)",
  "Gothenburg"  = "Gothenburg\n(Sweden #2)",
  "Malmo"       = "Malmö\n(Sweden #3)",
  "New York"    = "New York\n(USA #1)",
  "Los Angeles" = "Los Angeles\n(USA #2)",
  "Chicago"     = "Chicago\n(USA #3)"
)

pal_cities <- c(
  "Sweden" = "#e6a817",
  "USA"    = "#1f77b4"
)

# Summarise average price per kg by city and food group
city_summary <- food_clean |>
  filter(food_group != "Other") |>
  group_by(country, city, food_group) |>
  summarise(avg_price = mean(price_per_kg_usd), .groups = "drop") |>
  mutate(city = factor(city, levels = city_order))

ggplot(city_summary,
       aes(x = city, y = avg_price, fill = country)) +
  geom_col(width = 0.7) +
  facet_wrap(~ food_group, scales = "free_y", ncol = 2) +
  scale_fill_manual(values = pal_cities) +
  scale_x_discrete(labels = city_labels) +
  labs(
    title    = "Grocery Prices by Food Group: Sweden vs USA City Pairs",
    subtitle = "Average USD per kg equivalent, Numbeo crowdsourced prices 2025",
    x = NULL, y = "USD per kg",
    fill = NULL,
    caption = "Source: Numbeo food prices (USD). Prices converted from lb to kg where applicable."
  ) +
  theme_minimal(base_size = 12) +
  theme(
    legend.position  = "bottom",
    panel.grid.minor = element_blank(),
    axis.text.x = element_text(angle = 45, hjust = 1, size = 8),
    strip.text       = element_text(face = "bold")
  )
Figure 4: Average grocery price by food group across Swedish and US cities (USD, 2025)
Code
food_summary <- food_clean |>
  group_by(city, country, food_group) |>
  summarise(avg_price = mean(price_per_kg_usd, na.rm = TRUE), .groups = "drop") |>
  mutate(
    city = factor(city, levels = c("Stockholm", "Gothenburg", "Malmo",
                                    "New York", "Los Angeles", "Chicago"))
  )

ggplot(food_summary, aes(x = city, y = avg_price, colour = country, group = country)) +
  geom_line(linewidth = 1.1) +
  geom_point(size = 3) +
  facet_wrap(~ food_group, scales = "free_y") +
  scale_colour_manual(values = c("Sweden" = "#e6a817", "USA" = "#1f77b4")) +
  labs(
    title = "Average USD per kg equivalent, Numbeo crowdsourced prices 2025",
    x = NULL, y = "USD per kg",
    colour = NULL,
    caption = "Source: Numbeo food prices. SEK converted at 0.091 USD/SEK.\nPrices converted from lb to kg where applicable."
  ) +
  theme_minimal(base_size = 12) +
  theme(
    legend.position = "bottom",
    axis.text.x = element_text(angle = 25, hjust = 1),
    strip.text = element_text(face = "bold")
  )

That looked more like my hypothesis

But I still wanted to compare the quality of NYC food prices to stockholm prices, if the NYC prices were also Swedish standards. I did this by assuming the closest that the USA gets to Swedish standards is organic food, so I want to see what options I can scrape from Numbeo.

Code
# Peek at all available Numbeo items for NYC to find organic rows
url <- "https://www.numbeo.com/food-prices/in/New-York"
page <- read_html(url)
table <- page |> html_elements("table") |> pluck(4) |> html_table(fill = TRUE)
names(table) <- c("item", "price", "range")
table |> filter(str_detect(item, regex("organ|grass|free.range|wild", ignore_case = TRUE)))

Whole Foods vs Maxi

That didn’t work so I’m just going to compare a curated basket of ~8 items, matched as closely as possible between Whole Foods NYC and ICA Maxi Stockholm, with prices in USD.

Code
premium_basket <- tribble(
  ~item,                 ~food_group,            ~city,        ~country, ~price_usd_per_kg,
  "Whole milk",          "Dairy",                "New York",   "USA",    2.18,
  "Whole milk",          "Dairy",                "Stockholm",  "Sweden", 1.46,
  "Chicken breast",      "Animal source foods",  "New York",   "USA",    19.80,
  "Chicken breast",      "Animal source foods",  "Stockholm",  "Sweden", 10.92,
  "Eggs (12)",           "Animal source foods",  "New York",   "USA",    8.47,
  "Eggs (12)",           "Animal source foods",  "Stockholm",  "Sweden", 4.55,
  "White rice",          "Starchy staples",      "New York",   "USA",    4.19,
  "White rice",          "Starchy staples",      "Stockholm",  "Sweden", 2.37,
  "Sourdough bread",     "Starchy staples",      "New York",   "USA",    11.02,
  "Sourdough bread",     "Starchy staples",      "Stockholm",  "Sweden", 6.37,
  "Apples",              "Fruits",               "New York",   "USA",    5.49,
  "Apples",              "Fruits",               "Stockholm",  "Sweden", 3.00,
  "Tomatoes",            "Vegetables",           "New York",   "USA",    8.82,
  "Tomatoes",            "Vegetables",           "Stockholm",  "Sweden", 4.55,
  "Baby spinach",        "Vegetables",           "New York",   "USA",    17.50,
  "Baby spinach",        "Vegetables",           "Stockholm",  "Sweden", 8.19,
)

premium_summary <- premium_basket |>
  group_by(city, country, food_group) |>
  summarise(avg_price = mean(price_usd_per_kg, na.rm = TRUE), .groups = "drop") |>
  mutate(city = factor(city, levels = c("Stockholm", "New York")))

# Calculate differences for annotation
diff_df <- premium_summary |>
  select(food_group, city, avg_price) |>
  pivot_wider(names_from = city, values_from = avg_price) |>
  mutate(
    diff = `New York` - Stockholm,
    ratio = `New York` / Stockholm,
    label = paste0(round(ratio, 1), "x"),
    label_y = `New York` + 0.5
  )

# Plot
ggplot(premium_summary, aes(x = city, y = avg_price, fill = country)) +
  geom_col(position = "dodge", width = 0.6) +
  geom_text(
    data = diff_df,
    aes(x = "New York", y = label_y, label = label),
    inherit.aes = FALSE,
    fontface = "bold", size = 4, colour = "grey30"
  ) +
  facet_wrap(~ food_group, scales = "free_y") +
  scale_fill_manual(values = c("Sweden" = "#e6a817", "USA" = "#1f77b4")) +
  labs(
    title = "Premium grocery prices: Whole Foods NYC vs ICA Maxi Stockholm",
    subtitle = "Average USD per kg equivalent, 2025",
    x = NULL, y = "USD per kg", fill = NULL,
    caption = "Sources: Whole Foods NYC; ICA Maxi Stockholm. SEK converted at 0.091 USD/SEK."
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", strip.text = element_text(face = "bold"))

Code
# Redefine at top of chunk just to be safe
premium_summary <- premium_basket |>
  group_by(city, country, food_group) |>
  summarise(avg_price = mean(price_usd_per_kg, na.rm = TRUE), .groups = "drop") |>
  mutate(city = factor(city, levels = c("Stockholm", "New York")))

diff_chart <- premium_summary |>
  select(food_group, city, avg_price) |>  # drop country column before pivot
  pivot_wider(names_from = city, values_from = avg_price) |>
  mutate(
    diff = `New York` - Stockholm,
    food_group = fct_reorder(food_group, diff)
  )

ggplot(diff_chart, aes(x = food_group, y = diff)) +
  geom_col(fill = "#1f77b4", width = 0.6) +
  geom_text(aes(label = paste0("+$", round(diff, 2), "/kg")),
            hjust = -0.15, size = 3.5, colour = "grey30") +
  coord_flip() +
  scale_y_continuous(expand = expansion(mult = c(0, 0.2))) +
  labs(
    title = "How much more expensive is New York than Stockholm?",
    subtitle = "Price difference (USD per kg), Whole Foods NYC vs ICA Maxi Stockholm, 2025",
    x = NULL, y = "USD per kg more expensive in NYC",
    caption = "Sources: Whole Foods NYC; ICA Maxi Stockholm. SEK converted at 0.091 USD/SEK."
  ) +
  theme_minimal(base_size = 12)

How income change things?

Now I was curious how they’d compare if I added income into the equation. Let’s say comparing the median salary in both cities. Median income uses OECD median wages (US: $56,000/yr; Sweden: 370,000 SEK/yr).

Code
# Hardcoded premium basket: Whole Foods NYC vs ICA Maxi Stockholm
# Stockholm prices from ICA Maxi website (March 2025), converted at 0.091 USD/SEK
# NYC prices from Whole Foods website (March 2025)

premium_basket <- tribble(
  ~item,                  ~food_group,           ~city,         ~country,  ~price_usd_per_kg,
  "Whole milk (1L)",      "Dairy",               "New York",    "USA",     2.18,
  "Whole milk (1L)",      "Dairy",               "Stockholm",   "Sweden",  1.46,
  "Chicken breast",       "Animal source foods", "New York",    "USA",     19.80,
  "Chicken breast",       "Animal source foods", "Stockholm",   "Sweden",  10.92,
  "Eggs (12)",            "Animal source foods", "New York",    "USA",     8.47,
  "Eggs (12)",            "Animal source foods", "Stockholm",   "Sweden",  4.55,
  "White rice (1kg)",     "Starchy staples",     "New York",    "USA",     4.19,
  "White rice (1kg)",     "Starchy staples",     "Stockholm",   "Sweden",  2.37,
  "Sourdough bread",      "Starchy staples",     "New York",    "USA",     11.02,
  "Sourdough bread",      "Starchy staples",     "Stockholm",   "Sweden",  6.37,
  "Apples (1kg)",         "Fruits",              "New York",    "USA",     5.49,
  "Apples (1kg)",         "Fruits",              "Stockholm",   "Sweden",  3.00,
  "Tomatoes (1kg)",       "Vegetables",          "New York",    "USA",     8.82,
  "Tomatoes (1kg)",       "Vegetables",          "Stockholm",   "Sweden",  4.55,
  "Baby spinach (200g)",  "Vegetables",          "New York",    "USA",     17.50,
  "Baby spinach (200g)",  "Vegetables",          "Stockholm",   "Sweden",  8.19,
)
Code
# --- Premium basket (2025 prices) ---
premium_basket <- tribble(
  ~item,                 ~food_group,            ~city,        ~country, ~price_usd_per_kg,
  "Whole milk",          "Dairy",                "New York",   "USA",    2.18,
  "Whole milk",          "Dairy",                "Stockholm",  "Sweden", 1.46,
  "Chicken breast",      "Animal source foods",  "New York",   "USA",    19.80,
  "Chicken breast",      "Animal source foods",  "Stockholm",  "Sweden", 10.92,
  "Eggs (12)",           "Animal source foods",  "New York",   "USA",    8.47,
  "Eggs (12)",           "Animal source foods",  "Stockholm",  "Sweden", 4.55,
  "White rice",          "Starchy staples",      "New York",   "USA",    4.19,
  "White rice",          "Starchy staples",      "Stockholm",  "Sweden", 2.37,
  "Sourdough bread",     "Starchy staples",      "New York",   "USA",    11.02,
  "Sourdough bread",     "Starchy staples",      "Stockholm",  "Sweden", 6.37,
  "Apples",              "Fruits",               "New York",   "USA",    5.49,
  "Apples",              "Fruits",               "Stockholm",  "Sweden", 3.00,
  "Tomatoes",            "Vegetables",           "New York",   "USA",    8.82,
  "Tomatoes",            "Vegetables",           "Stockholm",  "Sweden", 4.55,
  "Baby spinach",        "Vegetables",           "New York",   "USA",    17.50,
  "Baby spinach",        "Vegetables",           "Stockholm",  "Sweden", 8.19,
)

# --- Plot 1: Grouped bar chart faceted by food group ---
premium_summary <- premium_basket |>
  group_by(city, country, food_group) |>
  summarise(avg_price = mean(price_usd_per_kg, na.rm = TRUE), .groups = "drop") |>
  mutate(city = factor(city, levels = c("Stockholm", "New York")))
Code
ggplot(premium_summary, aes(x = city, y = avg_price, fill = country)) +
  geom_col(position = "dodge", width = 0.6) +
  facet_wrap(~ food_group, scales = "free_y") +
  scale_fill_manual(values = c("Sweden" = "#e6a817", "USA" = "#1f77b4")) +
  labs(
    title = "Premium grocery prices: Whole Foods NYC vs ICA Maxi Stockholm",
    subtitle = "Average USD per kg equivalent, 2025",
    x = NULL, y = "USD per kg", fill = NULL,
    caption = "Sources: Whole Foods NYC; ICA Maxi Stockholm. SEK converted at 0.091 USD/SEK."
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", strip.text = element_text(face = "bold"))

Code
# --- Plot 2: Conclusion line graph — total basket cost as % of daily wage ---
# US food CPI index (2021=100): 2021=100, 2022=110.4, 2023=115.8, 2024=118.2, 2025=120.0
# Sweden food CPI index (2021=100): 2021=100, 2022=112.5, 2023=127.3, 2024=131.0, 2025=133.5

basket_avg_2025_usd <- premium_basket |>
  group_by(country) |>
  summarise(avg_kg_price = mean(price_usd_per_kg)) |>
  mutate(
    # Assume basket = 1.5 kg/day equivalent across all food groups
    daily_cost_2025 = avg_kg_price * 1.5
  )

# Back-calculate daily costs using food CPI
cpi_index <- tribble(
  ~country, ~Year, ~cpi,
  "USA",    2021,  100.0,
  "USA",    2022,  110.4,
  "USA",    2023,  115.8,
  "USA",    2024,  118.2,
  "USA",    2025,  120.0,
  "Sweden", 2021,  100.0,
  "Sweden", 2022,  112.5,
  "Sweden", 2023,  127.3,
  "Sweden", 2024,  131.0,
  "Sweden", 2025,  133.5,
)

# Daily wages (annual / 365)
daily_wages <- tribble(
  ~country, ~daily_wage_usd,
  "USA",    56000 / 365,       # ~$153/day
  "Sweden", (370000 * 0.091) / 365  # ~$92/day
)

conclusion_df <- cpi_index |>
  left_join(basket_avg_2025_usd, by = "country") |>
  mutate(daily_cost = daily_cost_2025 * (cpi / 133.5)) |>  # rescale from 2025 base
  left_join(daily_wages, by = "country") |>
  mutate(
    pct_daily_wage = daily_cost / daily_wage_usd * 100,
    city = if_else(country == "USA", "New York (Whole Foods)", "Stockholm (ICA Maxi)")
  )



::: {.cell}

```{.r .cell-code}
# Redefine dependencies
basket_avg_2025_usd <- premium_basket |>
  group_by(country) |>
  summarise(avg_kg_price = mean(price_usd_per_kg)) |>
  mutate(daily_cost_2025 = avg_kg_price * 1.5)

cpi_index <- tribble(
  ~country, ~Year, ~cpi,
  "USA",    2021,  100.0,
  "USA",    2022,  110.4,
  "USA",    2023,  115.8,
  "USA",    2024,  118.2,
  "USA",    2025,  120.0,
  "Sweden", 2021,  100.0,
  "Sweden", 2022,  112.5,
  "Sweden", 2023,  127.3,
  "Sweden", 2024,  131.0,
  "Sweden", 2025,  133.5,
)

daily_wages <- tribble(
  ~country, ~daily_wage_usd,
  "USA",    56000 / 365,
  "Sweden", (370000 * 0.091) / 365
)

conclusion_df <- cpi_index |>
  left_join(basket_avg_2025_usd, by = "country") |>
  mutate(daily_cost = daily_cost_2025 * (cpi / 133.5)) |>
  left_join(daily_wages, by = "country") |>
  mutate(
    pct_daily_wage = daily_cost / daily_wage_usd * 100,
    city = if_else(country == "USA", "New York (Whole Foods)", "Stockholm (ICA Maxi)")
  )
conclusion_df <- cpi_index |>
  left_join(basket_avg_2025_usd, by = "country") |>
  mutate(daily_cost = daily_cost_2025 * (cpi / 133.5)) |>
  left_join(daily_wages, by = "country") |>
  mutate(
    pct_daily_wage = daily_cost / daily_wage_usd * 100,
    city = if_else(country == "USA", "New York (Whole Foods)", "Stockholm (ICA Maxi)")
  )
ggplot(conclusion_df, aes(x = Year, y = pct_daily_wage, colour = country, group = country)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  scale_colour_manual(values = c("Sweden" = "#e6a817", "USA" = "#1f77b4"),
                      labels = c("Sweden" = "Stockholm (ICA Maxi)", "USA" = "New York (Whole Foods)")) +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  scale_x_continuous(breaks = 2021:2025) +
  labs(
    title = "Premium Healthy Basket Cost as % of Median Daily Wage",
    subtitle = "New York (Whole Foods) vs Stockholm (ICA Maxi), 2021–2025",
    x = NULL, y = "% of median daily wage", colour = NULL,
    caption = "Sources: Whole Foods NYC; ICA Maxi Stockholm; OECD wages.\nFood CPI used to back-calculate 2021–2024 prices from 2025 basket."
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")

:::

Instead of median income, what’s it like for low income folks in both cities?

Low income uses policy thresholds: the US federal poverty line ($15,650/yr for a single person) and Sweden’s Riksnorm (~102,000 SEK/yr) - the minimum subsistence standard used to calculate social assistance eligibility.

Code
# Low-income thresholds
daily_wages_lowincome <- tribble(
  ~country, ~daily_wage_usd, ~threshold_label,
  "USA",    15650 / 365,     "US Federal Poverty Line",
  "Sweden", (102000 * 0.091) / 365, "Sweden Riksnorm"
)

conclusion_df_lowincome <- cpi_index |>
  left_join(basket_avg_2025_usd, by = "country") |>
  mutate(daily_cost = daily_cost_2025 * (cpi / 133.5)) |>
  left_join(daily_wages_lowincome, by = "country") |>
  mutate(
    pct_daily_wage = daily_cost / daily_wage_usd * 100,
    city = if_else(country == "USA",
                   "New York (Whole Foods)\nvs. US poverty line",
                   "Stockholm (ICA Maxi)\nvs. Sweden Riksnorm")
  )

ggplot(conclusion_df_lowincome, aes(x = Year, y = pct_daily_wage,
                                     colour = country, group = country)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  scale_colour_manual(values = c("Sweden" = "#e6a817", "USA" = "#1f77b4"),
                      labels = c("Sweden" = "Stockholm (ICA Maxi) vs Riksnorm",
                                 "USA" = "New York (Whole Foods) vs poverty line")) +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  scale_x_continuous(breaks = 2021:2025) +
  labs(
    title = "Premium Healthy Basket Cost as % of Poverty-Line Income",
    subtitle = "New York (Whole Foods) vs Stockholm (ICA Maxi), 2021–2025",
    x = NULL, y = "% of daily poverty-line income", colour = NULL,
    caption = "Sources: Whole Foods NYC; ICA Maxi Stockholm; US HHS poverty guidelines; Sweden Riksnorm.\nFood CPI used to back-calculate 2021–2024 prices from 2025 basket."
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")

How about high income workers?

High income reflects the 80th percentile wage (US: ~$100,000/yr; Sweden: ~620,000 SEK/yr).

Code
daily_wages_highincome <- tribble(
  ~country, ~daily_wage_usd,
  "USA",    100000 / 365,
  "Sweden", (620000 * 0.091) / 365
)

conclusion_df_highincome <- cpi_index |>
  left_join(basket_avg_2025_usd, by = "country") |>
  mutate(daily_cost = daily_cost_2025 * (cpi / 133.5)) |>
  left_join(daily_wages_highincome, by = "country") |>
  mutate(
    pct_daily_wage = daily_cost / daily_wage_usd * 100,
    city = if_else(country == "USA",
                   "New York (Whole Foods)",
                   "Stockholm (ICA Maxi)")
  )

ggplot(conclusion_df_highincome, aes(x = Year, y = pct_daily_wage,
                                      colour = country, group = country)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  scale_colour_manual(values = c("Sweden" = "#e6a817", "USA" = "#1f77b4"),
                      labels = c("Sweden" = "Stockholm (ICA Maxi)",
                                 "USA" = "New York (Whole Foods)")) +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  scale_x_continuous(breaks = 2021:2025) +
  labs(
    title = "Premium Healthy Basket Cost as % of High-Income Daily Wage",
    subtitle = "New York (Whole Foods) vs Stockholm (ICA Maxi), 2021–2025\n80th percentile wage",
    x = NULL, y = "% of daily high income", colour = NULL,
    caption = "Sources: Whole Foods NYC; ICA Maxi Stockholm; OECD wage distribution.\nFood CPI used to back-calculate 2021–2024 prices from 2025 basket."
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")

Who can afford a healthy diet?

To understand how food costs vary by income, we compare the premium basket cost against three income benchmarks. High income reflects the 80th percentile wage (US: ~$100,000/yr; Sweden: ~620,000 SEK/yr).

Code
library(patchwork)

shared_y <- scale_y_continuous(limits = c(0, 35), 
                                labels = scales::percent_format(scale = 1))
shared_x <- scale_x_continuous(breaks = 2021:2025)
shared_colour <- scale_colour_manual(
  values = c("Sweden" = "#e6a817", "USA" = "#1f77b4"),
  labels = c("Sweden" = "Stockholm (ICA Maxi)", "USA" = "New York (Whole Foods)")
)
base_theme <- theme_minimal(base_size = 11) +
  theme(legend.position = "none",
        plot.title = element_text(face = "bold", size = 12),
        plot.subtitle = element_text(colour = "grey40", size = 10))

p_high <- ggplot(conclusion_df_highincome, 
                 aes(x = Year, y = pct_daily_wage, colour = country, group = country)) +
  geom_line(linewidth = 1.2) + geom_point(size = 3) +
  scale_y_continuous(limits = c(0, 35), labels = scales::percent_format(scale = 1)) +
  shared_x + shared_colour +
  labs(title = "High income (P80)",
       subtitle = "Healthy food is easily\naffordable — under 5%",
       x = NULL, y = "% of daily income", colour = NULL) +
  base_theme

p_median <- ggplot(conclusion_df, 
                   aes(x = Year, y = pct_daily_wage, colour = country, group = country)) +
  geom_line(linewidth = 1.2) + geom_point(size = 3) +
  scale_y_continuous(limits = c(0, 35), labels = scales::percent_format(scale = 1)) +
  shared_x + shared_colour +
  labs(title = "Median income",
       subtitle = "Manageable but rising —\naround 8% and climbing",
       x = NULL, y = NULL, colour = NULL) +
  base_theme

p_low <- ggplot(conclusion_df_lowincome, 
                aes(x = Year, y = pct_daily_wage, colour = country, group = country)) +
  geom_line(linewidth = 1.2) + geom_point(size = 3) +
  scale_y_continuous(limits = c(0, 35), labels = scales::percent_format(scale = 1)) +
  shared_x + shared_colour +
  labs(title = "Low income",
       subtitle = "A healthy diet consumes\nnearly one-third of income",
       x = NULL, y = NULL, colour = NULL) +
  base_theme

# Build combined plot
(p_high | p_median | p_low) +
  plot_annotation(
    title = "Who can afford a healthy diet? It depends entirely on your income",
    subtitle = "Premium basket cost (Whole Foods NYC vs ICA Maxi Stockholm) as % of daily income, 2021–2025",
    caption = "Sources: Whole Foods NYC; ICA Maxi Stockholm; OECD wages; US HHS poverty guidelines; Sweden Riksnorm.\nFood CPI used to back-calculate 2021–2024 prices from 2025 basket.",
    theme = theme(
      plot.title = element_text(face = "bold", size = 14),
      plot.subtitle = element_text(colour = "grey40", size = 11),
      plot.caption = element_text(colour = "grey50", size = 9)
    )
  ) +
  plot_layout(guides = "collect", widths = c(1, 1, 1.2)) &
  theme(legend.position = "top",
        legend.justification = "left")

Conclusion

When we first looked at FAO data, the US appeared to have a more affordable healthy diet than Sweden. That seemed off to anyone who has actually shopped in both countries. The reason turns out to be methodological: FAO prices the cheapest available items in each market. In the US, many of those cheap items contain additives, pesticides, and processing agents that are banned in the EU. Sweden’s cheapest food already meets stricter standards. So the baseline comparison was never really fair.

To get a more honest picture, we priced what a genuinely healthy meal actually costs — using Whole Foods in New York and ICA Maxi in Stockholm, and extending the comparison across the three largest cities in each country. On that basis, New York is more expensive across every food category, and more than twice as expensive for meat and vegetables.

When you factor in income, the story gets more interesting. For high earners, healthy food is affordable in both cities without much thought. For median earners it’s manageable, but the cost has been rising. For people at the poverty line, a healthy diet now takes up nearly a third of daily income — and that figure looks almost identical in New York and Stockholm by 2025, despite the very different social safety nets surrounding them.

Where you live matters. What you earn matters more. And what counts as “cheap food” depends entirely on what you’re willing to eat.


Limitations

  • Premium basket prices are estimates. Whole Foods and ICA Maxi prices were manually curated for 2025 and back-calculated using national food CPI indices. This keeps the basket consistent over time but doesn’t capture how individual item prices actually moved.
  • Fixed exchange rate. All SEK values use a fixed 0.091 USD/SEK rate. Currency movements would affect the cross-country comparisons.
  • Store tier mismatch. Whole Foods is a premium US retailer; ICA Maxi is a mainstream Swedish supermarket. A like-for-like comparison would match store tiers more carefully.
  • National wages, not city wages. NYC wages are well above the US median; Stockholm wages are close to the Swedish median. City-level wage data would make the affordability analysis sharper.
  • FAO methodology. The FAO basket prices the cheapest available items and may not reflect what people actually eat or what’s realistically accessible in a given area.
  • Numbeo is crowdsourced. City-level prices from Numbeo depend on user submissions and may not always reflect current retail reality.