Food Prices in India: Retail vs. Wholesale Dynamics

Food Economy
Prices
Interactive analysis of retail and wholesale food prices across commodity groups β€” cereals, pulses, edible oils, and vegetables β€” tracking price trends, spread analysis, and retail-wholesale comparisons.
Author

Pawan

Published

November 1, 2025

Modified

March 21, 2026

Overview

Food prices affect the welfare of every Indian household. This analysis examines retail and wholesale price trajectories for four commodity groups β€” cereals, pulses, edible oils, and vegetables β€” drawing on government price monitoring data. By comparing retail with wholesale prices and tracking commodity-level trends, we can identify where prices are diverging, where the supply chain spreads are widest, and which commodities have seen the sharpest inflationary pressure.


TipKey Findings at a Glance
  • 🌾 Cereal prices are the most stable: supported by MSP procurement, PDS distribution, and strategic stock releases
  • 🫘 Pulses show the highest price volatility: arhar (tur) dal has repeatedly hit β‚Ή150–200/kg in recent years
  • πŸ«™ Edible oil prices are import-driven: surged 80–100% in 2021–22 with global commodity price spikes
  • πŸ… Vegetable prices are the most erratic: tomatoes can rise 5–10Γ— within a single season before crashing
  • πŸ“Š Retail-wholesale spreads are widest for pulses and vegetables: indicating supply chain inefficiencies
  • πŸ”„ Price transmission is asymmetric: wholesale increases pass through rapidly; decreases are slow and incomplete

1 Setup

Code
library(data.table)
library(ggplot2)
library(ggiraph)
library(scales)
library(arrow)
library(dplyr)
library(viridis)

## ── Unified theme ──────────────────────────────────────────────────────────
my_theme <- theme_minimal(base_size = 13) +
  theme(
    text             = element_text(color = "#2c3e50"),
    plot.title       = element_text(size = 16, face = "bold", hjust = 0.5),
    plot.subtitle    = element_text(size = 11, hjust = 0.5, color = "#7f8c8d"),
    plot.caption     = element_text(size = 9, color = "#95a5a6", hjust = 0),
    axis.title       = element_text(size = 12, face = "bold"),
    axis.text        = element_text(size = 10),
    axis.text.x      = element_text(angle = 45, vjust = 1, hjust = 1),
    legend.position  = "bottom",
    legend.title     = element_blank(),
    panel.grid.major = element_line(color = "#ecf0f1", linewidth = 0.5),
    panel.grid.minor = element_blank(),
    strip.background = element_rect(fill = "#f8f9fa", color = NA),
    strip.text       = element_text(face = "bold", size = 11)
  )

std_opts <- list(
  opts_tooltip(
    css = paste0("background-color:#2c3e50;color:white;padding:8px 12px;",
                 "border-radius:6px;font-size:12px;line-height:1.5;")
  ),
  opts_hover(css = "stroke-width:2.5;opacity:1;"),
  opts_hover_inv(css = "opacity:0.2;"),
  opts_zoom(min = 1, max = 5),
  opts_toolbar(position = "topright"),
  opts_sizing(rescale = TRUE, width = 1)
)

2 Data Loading and Cleaning

Code
## Load parquet datasets
retail    <- open_dataset("~/foodprices/retailprices/parquetfilesnew/")
wholesale <- open_dataset("~/foodprices/wholesaleprices/parquetfilesnew/")

retail    <- as.data.table(select(retail,    c(states, date, commodity, price, commodity_group)))
wholesale <- as.data.table(select(wholesale, c(states, date, commodity, price, commodity_group)))

retail$Type    <- "Retail"
wholesale$Type <- "Wholesale"
wholesale$price <- wholesale$price / 100   ## Convert paise to rupees if needed

combined <- rbind(retail, wholesale)

## Clean price
combined[, price := ifelse(is.na(price), 0, price)]

## Clean commodity names
combined[, commodity := gsub(" @|\\(\\)|\\*|Packed", "", commodity)]
combined[, commodity := trimws(gsub("[-/]+", "-", commodity))]

## Remove aggregate rows
combined <- combined[!states %in% c("Maximum Price","Minimum Price",
                                    "Modal Price","Average Price")]
combined <- combined[price > 0]

## ── Helper: national average price trend ─────────────────────────────────
nat_avg <- function(dt, grp, type_filter) {
  dt[commodity_group == grp & Type == type_filter,
     .(avg_price = mean(price, na.rm = TRUE),
       med_price = median(price, na.rm = TRUE)),
     by = .(date, commodity)]
}

3 Cereals

NoteWhy Cereal Prices Are Stable

The government’s dual mandate of protecting farmer income (through MSP) and consumer welfare (through PDS at subsidised prices) creates a price floor and a price ceiling around cereals. This is the most heavily managed segment of India’s food economy.

3.1 Retail Cereal Prices

Code
r1 <- combined[commodity_group == "Cereals" & Type == "Retail"]
r1_avg <- r1[, .(avg = mean(price, na.rm = TRUE)), by = .(date, commodity)]
r1_avg[, tip := paste0("<b>", commodity, "</b><br>",
                       "Date: ", format(date, "%b %Y"), "<br>",
                       "Avg retail price: β‚Ή<b>", round(avg, 2), "</b>/kg")]

p_cr <- ggplot(r1_avg, aes(x = date, y = avg, color = commodity,
                            tooltip = tip, data_id = paste(commodity, date),
                            group = commodity)) +
  geom_smooth_interactive(method = "gam", se = FALSE, linewidth = 1.2) +
  facet_wrap(~ commodity, scales = "free_y") +
  scale_color_viridis(discrete = TRUE, option = "D") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Retail Cereal Prices (National Average)",
       subtitle = "GAM-smoothed trend β€” hover to see exact date and price",
       x = "Date", y = "Price (β‚Ή/kg)",
       caption = "Source: Price Monitoring Division, Dept. of Consumer Affairs | Analysis: Pawan") +
  my_theme + theme(legend.position = "none")

girafe(ggobj = p_cr, width_svg = 12, height_svg = 7, options = std_opts)

3.2 Wholesale Cereal Prices

Code
w1 <- combined[commodity_group == "Cereals" & Type == "Wholesale"]
w1_avg <- w1[, .(avg = mean(price, na.rm = TRUE)), by = .(date, commodity)]
w1_avg[, tip := paste0("<b>", commodity, "</b><br>",
                       "Date: ", format(date, "%b %Y"), "<br>",
                       "Avg wholesale: β‚Ή<b>", round(avg, 2), "</b>/kg")]

p_cw <- ggplot(w1_avg, aes(x = date, y = avg, color = commodity,
                            tooltip = tip, data_id = paste(commodity, date),
                            group = commodity)) +
  geom_smooth_interactive(method = "gam", se = FALSE, linewidth = 1.2) +
  facet_wrap(~ commodity, scales = "free_y") +
  scale_color_viridis(discrete = TRUE, option = "C") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Wholesale Cereal Prices (National Average)",
       x = "Date", y = "Price (β‚Ή/kg)",
       caption = "Source: Price Monitoring Division | Analysis: Pawan") +
  my_theme + theme(legend.position = "none")

girafe(ggobj = p_cw, width_svg = 12, height_svg = 7, options = std_opts)

4 Pulses

WarningPulse Price Volatility

Pulses experience the highest price volatility of any major food group in India. The supply-demand balance for arhar (tur) and moong is precarious β€” small production shortfalls can cause retail prices to double within a season, before crashing as imports arrive or next season’s harvest comes in.

4.1 Retail Pulse Prices

Code
r2 <- combined[commodity_group == "Pulses" & Type == "Retail"]
r2_avg <- r2[, .(avg = mean(price, na.rm = TRUE)), by = .(date, commodity)]
r2_avg[, tip := paste0("<b>", commodity, "</b><br>",
                       "Date: ", format(date, "%b %Y"), "<br>",
                       "Avg retail: β‚Ή<b>", round(avg, 2), "</b>/kg")]

p_pr <- ggplot(r2_avg, aes(x = date, y = avg, color = commodity,
                            tooltip = tip, data_id = paste(commodity, date),
                            group = commodity)) +
  geom_smooth_interactive(method = "gam", se = FALSE, linewidth = 1.2) +
  facet_wrap(~ commodity, scales = "free_y") +
  scale_color_viridis(discrete = TRUE, option = "D") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Retail Pulse Prices (National Average)",
       subtitle = "Tur dal & moong show highest volatility in the basket",
       x = "Date", y = "Price (β‚Ή/kg)",
       caption = "Source: Price Monitoring Division | Analysis: Pawan") +
  my_theme + theme(legend.position = "none")

girafe(ggobj = p_pr, width_svg = 12, height_svg = 7, options = std_opts)

4.2 Wholesale Pulse Prices

Code
w2 <- combined[commodity_group == "Pulses" & Type == "Wholesale"]
w2_avg <- w2[, .(avg = mean(price, na.rm = TRUE)), by = .(date, commodity)]
w2_avg[, tip := paste0("<b>", commodity, " (Wholesale)</b><br>",
                       "Date: ", format(date, "%b %Y"), "<br>",
                       "Avg wholesale: β‚Ή<b>", round(avg, 2), "</b>/kg")]

p_pw <- ggplot(w2_avg, aes(x = date, y = avg, color = commodity,
                            tooltip = tip, data_id = paste(commodity, date),
                            group = commodity)) +
  geom_smooth_interactive(method = "gam", se = FALSE, linewidth = 1.2) +
  facet_wrap(~ commodity, scales = "free_y") +
  scale_color_viridis(discrete = TRUE, option = "E") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Wholesale Pulse Prices (National Average)",
       x = "Date", y = "Price (β‚Ή/kg)",
       caption = "Source: Price Monitoring Division | Analysis: Pawan") +
  my_theme + theme(legend.position = "none")

girafe(ggobj = p_pw, width_svg = 12, height_svg = 7, options = std_opts)

5 Edible Oils

5.1 Retail Edible Oil Prices

Code
r3 <- combined[commodity_group == "Edible oils" & Type == "Retail"]
r3_avg <- r3[, .(avg = mean(price, na.rm = TRUE)), by = .(date, commodity)]
r3_avg[, tip := paste0("<b>", commodity, "</b><br>",
                       "Date: ", format(date, "%b %Y"), "<br>",
                       "Avg retail: β‚Ή<b>", round(avg, 2), "</b>/litre")]

p_or <- ggplot(r3_avg, aes(x = date, y = avg, color = commodity,
                            tooltip = tip, data_id = paste(commodity, date),
                            group = commodity)) +
  geom_smooth_interactive(method = "gam", se = FALSE, linewidth = 1.2) +
  facet_wrap(~ commodity, scales = "free_y") +
  scale_color_viridis(discrete = TRUE, option = "B") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Retail Edible Oil Prices (National Average)",
       subtitle = "The 2021–22 global price surge is clearly visible across all oil types",
       x = "Date", y = "Price (β‚Ή/litre)",
       caption = "Source: Price Monitoring Division | Analysis: Pawan") +
  my_theme + theme(legend.position = "none")

girafe(ggobj = p_or, width_svg = 12, height_svg = 7, options = std_opts)

5.2 Wholesale Edible Oil Prices

Code
w3 <- combined[commodity_group == "Edible oils" & Type == "Wholesale"]
w3_avg <- w3[, .(avg = mean(price, na.rm = TRUE)), by = .(date, commodity)]
w3_avg[, tip := paste0("<b>", commodity, " (Wholesale)</b><br>",
                       "Date: ", format(date, "%b %Y"), "<br>",
                       "Avg wholesale: β‚Ή<b>", round(avg, 2), "</b>/litre")]

p_ow <- ggplot(w3_avg, aes(x = date, y = avg, color = commodity,
                            tooltip = tip, data_id = paste(commodity, date),
                            group = commodity)) +
  geom_smooth_interactive(method = "gam", se = FALSE, linewidth = 1.2) +
  facet_wrap(~ commodity, scales = "free_y") +
  scale_color_viridis(discrete = TRUE, option = "C") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Wholesale Edible Oil Prices",
       x = "Date", y = "Price (β‚Ή/litre)",
       caption = "Source: Price Monitoring Division | Analysis: Pawan") +
  my_theme + theme(legend.position = "none")

girafe(ggobj = p_ow, width_svg = 12, height_svg = 7, options = std_opts)

6 Vegetables

WarningVegetable Price Volatility

Vegetables are India’s most price-volatile food category. Perishability, fragmented retail, poor cold chain infrastructure, and concentrated production regions make vegetable prices extremely sensitive to weather shocks. Tomatoes and onions, in particular, have triggered political crises and emergency government interventions on multiple occasions.

6.1 Retail Vegetable Prices

Code
r4 <- combined[commodity_group == "Vegetables" & Type == "Retail"]
r4_avg <- r4[, .(avg = mean(price, na.rm = TRUE)), by = .(date, commodity)]
r4_avg[, tip := paste0("<b>", commodity, "</b><br>",
                       "Date: ", format(date, "%b %Y"), "<br>",
                       "Avg retail: β‚Ή<b>", round(avg, 2), "</b>/kg")]

p_vr <- ggplot(r4_avg, aes(x = date, y = avg, color = commodity,
                            tooltip = tip, data_id = paste(commodity, date),
                            group = commodity)) +
  geom_smooth_interactive(method = "gam", se = FALSE, linewidth = 1.2) +
  facet_wrap(~ commodity, scales = "free_y") +
  scale_color_viridis(discrete = TRUE, option = "D") +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Retail Vegetable Prices (National Average)",
       subtitle = "Dramatic seasonal and year-on-year volatility; tomato spikes are particularly sharp",
       x = "Date", y = "Price (β‚Ή/kg)",
       caption = "Source: Price Monitoring Division | Analysis: Pawan") +
  my_theme + theme(legend.position = "none")

girafe(ggobj = p_vr, width_svg = 12, height_svg = 7, options = std_opts)

7 Retail vs. Wholesale Comparison

The following plots compare average retail and wholesale prices within each commodity group, revealing the supply chain spread β€” a proxy for distribution, storage, and intermediary costs.

Code
## Build retail-wholesale comparison for each group
grp_avg <- combined[
  commodity_group %in% c("Cereals", "Pulses", "Edible oils", "Vegetables"),
  .(avg_price = mean(price, na.rm = TRUE)),
  by = .(date, Type, commodity_group)
]
grp_avg[, tip := paste0("<b>", Type, " β€” ", commodity_group, "</b><br>",
                        "Date: ", format(date, "%b %Y"), "<br>",
                        "Avg price: β‚Ή<b>", round(avg_price, 2), "</b>")]

p_comp <- ggplot(grp_avg, aes(x = date, y = avg_price, color = Type,
                               tooltip = tip,
                               data_id = paste(Type, commodity_group, date),
                               group = Type)) +
  geom_line_interactive(linewidth = 1.2, alpha = 0.85) +
  facet_wrap(~ commodity_group, scales = "free_y", ncol = 2) +
  scale_color_manual(values = c("Retail" = "#e74c3c", "Wholesale" = "#2980b9"),
                     labels = c("Retail" = "Retail Price", "Wholesale" = "Wholesale Price")) +
  scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
  scale_y_continuous(labels = label_number(prefix = "β‚Ή")) +
  labs(title   = "Retail vs. Wholesale Prices by Commodity Group",
       subtitle = "The gap between lines represents the distribution/intermediary margin",
       x = "Date", y = "Average Price (β‚Ή/unit)",
       caption = "Source: Price Monitoring Division | Analysis: Pawan") +
  my_theme

girafe(ggobj = p_comp, width_svg = 12, height_svg = 8, options = std_opts)
NoteInterpreting the Retail-Wholesale Spread

A wide and widening gap between retail and wholesale prices signals supply chain inefficiencies β€” excess intermediaries, poor market connectivity, or rent-seeking at some point in the distribution chain. A narrowing gap typically indicates improved market integration through better roads, digital price discovery, or direct retail procurement from farmers. Pulses and vegetables consistently show the largest spreads.


8 Key Takeaways

ImportantSummary of Findings
  1. Cereals are the most price-stable food group, reflecting the government’s comprehensive procurement, buffer stock, and subsidised distribution architecture under the NFSA.

  2. Pulses are the most volatile major food group: their volatility stems from weather-sensitive rainfed cultivation, limited buffer stock management historically, and episodic import duty adjustments.

  3. Edible oil prices in India are essentially determined by global markets: the 2021–22 commodity price spike is unmistakably visible in the retail price series, with prices roughly doubling before retreating.

  4. Vegetable price volatility is a chronic structural problem: tomato, onion, and potato (β€œTOP” commodities) collectively drive a disproportionate share of food CPI volatility, yet the policy response remains largely reactive.

  5. Retail-wholesale spreads are widest for pulses and vegetables, suggesting these are the segments where investment in supply chain infrastructure, cold storage, and market linkages would yield the highest consumer welfare benefits.

  6. Price transmission is consistently asymmetric: increases in wholesale prices pass through to retail prices quickly; decreases are absorbed slowly, systematically benefiting intermediaries at consumers’ expense.