Code
library(tidyverse)
library(scales)FAO CoAHD Data Analysis
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.
library(tidyverse)
library(scales)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.…
# 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
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
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()
)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
# 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>
# 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
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()
)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()
)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.
# 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,
)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)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.
# 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
Now so I’m going to compare the third largest Swedish cities to the third largest American cities.
# 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")
)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")
)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.
# 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)))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.
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"))# 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)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).
# 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,
)# --- 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")))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"))# --- 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")
:::
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.
# 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")High income reflects the 80th percentile wage (US: ~$100,000/yr; Sweden: ~620,000 SEK/yr).
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")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).
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")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.