raw <- read_csv("dataset/big-mac-full-index.csv", show_col_types = FALSE)

latest_date <- max(raw$date)

## `USD_raw` is a proportion: -0.35 means a Big Mac costs 35% less there than in
## the US once converted at the market exchange rate. Rescale to percentage
## points so the reader never has to do the multiplication.
latest <- raw %>%
  filter(date == latest_date) %>%
  mutate(
    valuation     = USD_raw * 100,
    valuation_adj = USD_adjusted * 100
  )

release_label <- format(latest_date, "%B %Y")
us_price      <- latest$dollar_price[latest$name == "United States"]

## The index publishes five base currencies; those are the reference lines.
## Everything else in the current release is offered in the picker.
ref_names <- c("United States", "Euro area", "Britain", "Japan", "China")
names(ref_cols) <- ref_names
pick_names   <- sort(setdiff(latest$name, ref_names))
default_pick <- "Mexico"

## Shape of the price ladder in section 2, needed by the prose above the chunk.
n_omitted      <- nrow(latest) - 6 - (nrow(latest) - (which(
                    arrange(latest, dollar_price)$name == "United States") - 2) + 1)
nth_expensive  <- nrow(latest) - which(
                    arrange(latest, dollar_price)$name == "United States") + 1

A Big Mac is close to the same sandwich everywhere it is sold — same beef, same bun, same two burgers. So if you convert its local price into dollars, you get a rough, honest read on how far your money actually goes in a country. This is The Economist’s Big Mac Index, and it is a surprisingly good travel-budget map. Everything below uses the July 2026 release, covering 54 countries.

cheapest  <- latest %>% filter(name != "United States") %>% slice_min(dollar_price, n = 1)
priciest  <- latest %>% slice_max(dollar_price, n = 1)

stat_tile <- function(label, value, sub) {
  sprintf('<div class="stat"><div class="label">%s</div><div class="value">%s</div><div class="sub">%s</div></div>',
          label, value, sub)
}

cat('<div class="stat-row">',
  stat_tile("Cheapest Big Mac",
            dollar_format(accuracy = 0.01)(cheapest$dollar_price),
            paste0(cheapest$name, " — ", round(abs(cheapest$valuation)), "% below US price")),
  stat_tile("Most expensive",
            dollar_format(accuracy = 0.01)(priciest$dollar_price),
            paste0(priciest$name, " — ", round(abs(priciest$valuation)), "% above US price")),
  stat_tile("United States",
            dollar_format(accuracy = 0.01)(us_price),
            "The benchmark every other price is measured against"),
'</div>', sep = "\n")
Cheapest Big Mac
$2.38
Indonesia — 62% below US price
Most expensive
$9.04
Switzerland — 45% above US price
United States
$6.22
The benchmark every other price is measured against

1. Where does a dollar stretch furthest?

The map shades each country by how its Big Mac price compares with the US price after converting at the market exchange rate. Blue means cheaper than home — your dollars buy more there. Red means pricier. Grey sits within 10% of the US price either way, which is close enough to call even.

euro_iso <- c("AUT","BEL","HRV","CYP","EST","FIN","FRA","DEU","GRC","IRL","ITA",
              "LVA","LTU","LUX","MLT","NLD","PRT","SVK","SVN","ESP")

map_vals <- latest %>%
  select(iso_a3, country = name, currency_code, local_price,
         dollar_price, valuation)

## The index reports the euro area as a single observation. Painting one grey
## hole across half of Europe would read as "no data", so the pooled figure is
## carried down to each member state — flagged in the tooltip so nobody mistakes
## it for a country-level measurement.
euro_row <- map_vals %>% filter(iso_a3 == "EUZ")

euro_expanded <- if (nrow(euro_row) == 1) {
  euro_row %>%
    select(-iso_a3) %>%
    crossing(iso_a3 = setdiff(euro_iso, map_vals$iso_a3))
} else {
  map_vals[0, ]
}

map_vals <- bind_rows(filter(map_vals, iso_a3 != "EUZ"), euro_expanded)

world <- ne_countries(scale = "medium", returnclass = "sf") %>%
  select(adm0_a3, geometry)

world_data <- world %>%
  left_join(map_vals, by = c("adm0_a3" = "iso_a3"))

pal <- colorBin(palette = div_cols, bins = div_bins,
                domain = world_data$valuation, na.color = "#eeedea")

tooltips <- ifelse(
  is.na(world_data$valuation), NA,
  sprintf(
    "<strong>%s</strong><br/>Big Mac: %s (%s %s)<br/><span style='color:#52514e'>%s%% %s than the US</span>%s",
    world_data$country,
    dollar_format(accuracy = 0.01)(world_data$dollar_price),
    comma(world_data$local_price, accuracy = 0.01), world_data$currency_code,
    round(abs(world_data$valuation)),
    ifelse(world_data$valuation < 0, "cheaper", "pricier"),
    ifelse(world_data$country == "Euro area",
           "<br/><em style='color:#898781'>Euro-area average</em>", "")
  )
)

leaflet(world_data, options = leafletOptions(minZoom = 1, worldCopyJump = TRUE)) %>%
  setView(lng = 10, lat = 25, zoom = 2) %>%
  addPolygons(
    fillColor   = ~pal(valuation),
    fillOpacity = 0.9,
    color       = surface,          # surface-coloured hairline, not a dark border
    weight      = 0.6,
    label       = lapply(tooltips, function(x) if (is.na(x)) NULL else HTML(x)),
    labelOptions = labelOptions(
      textsize = "13px", sticky = FALSE,
      style = list("background" = "#ffffff", "border" = paste0("1px solid ", baseline_col),
                   "border-radius" = "6px", "padding" = "8px 10px",
                   "font-family" = "system-ui, -apple-system, sans-serif",
                   "box-shadow" = "0 2px 6px rgba(11,11,11,0.12)")
    ),
    highlightOptions = highlightOptions(weight = 2, color = ink_primary,
                                        fillOpacity = 1, bringToFront = TRUE)
  ) %>%
  addLegend(
    position = "bottomleft", colors = div_cols, labels = div_labs,
    title = HTML(paste0("<span style='font-size:12px'>Big Mac price vs. the US<br/>",
                        "<span style='color:#898781;font-weight:400'>", release_label,
                        "</span></span>")),
    opacity = 0.9
  )

Grey countries are not in the index — McDonald’s either does not operate there or The Economist does not survey prices there. Euro-area members are all shaded with the single pooled euro-area figure, so treat those as one bloc rather than 20 independent readings.

2. The two ends of the price ladder

The map is good for scanning a region; it is bad for comparing two specific places, because judging a shade against a legend is a guess. Bar length is not.

This shows the cheap end, the expensive end, and where the United States sits — in orange. The middle of the ranking is cut out: it is 33 countries sitting between $3 and $6 with nothing to say to a traveller choosing a destination. Every one of them is in the table in section 5 if you want it. All bars share one axis and start at zero, so lengths stay directly comparable across the gap.

ladder <- latest %>%
  arrange(dollar_price) %>%
  mutate(rank = row_number())

us_rank   <- ladder$rank[ladder$name == "United States"]
n_cheap   <- 6
top_from  <- us_rank - 2          # start the upper block just below the US

keep_cheap <- filter(ladder, rank <= n_cheap)
keep_top   <- filter(ladder, rank >= top_from)

## One spacer row stands in for the countries left out. It carries no value, so
## no bar is drawn -- only the axis label naming what is missing.
gap_label <- sprintf("—  %d countries omitted  —", n_omitted)
gap_row   <- tibble(name = gap_label, dollar_price = NA_real_,
                    valuation = NA_real_)

bar_df <- bind_rows(
    select(keep_cheap, name, dollar_price, valuation),
    gap_row,
    select(keep_top,   name, dollar_price, valuation)
  ) %>%
  mutate(
    is_us = name == "United States",
    tip = ifelse(
      is.na(dollar_price), "",
      sprintf("%s\n$%.2f — %s%% %s than the US",
              name, dollar_price, round(abs(valuation)),
              ifelse(valuation < 0, "cheaper", "pricier"))
    ),
    ## Rows were assembled cheapest-first; reversing the levels puts the
    ## cheapest at the top once ggplot draws the y axis bottom-up.
    name = factor(name, levels = rev(name))
  )

p_bars <- ggplot(bar_df, aes(x = dollar_price, y = name, text = tip)) +
  geom_col(aes(fill = is_us), width = 0.68, na.rm = TRUE) +
  scale_fill_manual(values = c("FALSE" = cat_slots[1], "TRUE" = cat_slots[2]),
                    guide = "none") +
  scale_x_continuous(breaks = seq(0, 10, by = 2),
                     labels = dollar_format(accuracy = 1),
                     expand = expansion(mult = c(0, 0.06))) +
  labs(x = "Price of a Big Mac, converted to US dollars", y = NULL) +
  theme_bigmac() +
  theme(panel.grid.major.y = element_blank())

ggplotly(p_bars, tooltip = "text") %>% clean_plotly()

The United States is the 13th most expensive of the 54 countries here, which surprises most Americans — and Poland matches it to the cent at $6.22.

3. Compare any country with the five major currencies

The five coloured lines are the currencies the Big Mac Index is built around: the US dollar, the euro, the pound, the yen and the yuan. They are the reference frame most travellers already carry in their head.

Use the dropdown to add any other country as a heavy black line. That is the comparison that matters — not how the majors move against each other, but where the place you are actually going sits against them.

## One engine drives both charts below. Rather than shipping a separate trace
## for every country, it ships the price table once and lets the browser do the
## arithmetic on selection -- which is both smaller and what makes an arbitrary
## baseline possible in section 4.
releases  <- sort(unique(raw$date))
countries <- sort(unique(latest$name))

price_wide <- raw %>%
  filter(name %in% countries) %>%
  select(date, name, dollar_price) %>%
  complete(date = releases, name = countries) %>%
  arrange(name, date)

prices <- split(price_wide$dollar_price, price_wide$name)

base_default <- "United States"
pick_default <- "Mexico"

## The identity behind section 4: a country's valuation against any base is just
## the ratio of their dollar prices. Verified against the four base columns The
## Economist publishes (EUR/GBP/JPY/CNY) across all 2,056 rows -- it reproduces
## them to within their own 5-decimal rounding, so an arbitrary baseline is the
## same measure, not an approximation.
series_of <- function(country, base = NULL) {
  if (is.null(base)) prices[[country]]
  else 100 * (prices[[country]] / prices[[base]] - 1)
}

picker_js <- htmlwidgets::JS("
function(el, x, data) {
  var wrap = document.createElement('div');
  wrap.className = 'viz-controls';

  function mkSelect(label, initial) {
    var box = document.createElement('label');
    box.className = 'viz-control';
    var cap = document.createElement('span');
    cap.textContent = label;
    var sel = document.createElement('select');
    data.countries.forEach(function (c) {
      var o = document.createElement('option');
      o.value = c; o.textContent = c;
      if (c === initial) o.selected = true;
      sel.appendChild(o);
    });
    box.appendChild(cap); box.appendChild(sel);
    wrap.appendChild(box);
    return sel;
  }

  var selBase = (data.mode === 'valuation')
    ? mkSelect('Measure against', data.base) : null;
  var selPick = mkSelect('Compare a country', data.pick);
  el.parentNode.insertBefore(wrap, el);

  function series(country, base) {
    var p = data.prices[country];
    if (!p) return [];
    if (!base) return p;
    var b = data.prices[base], out = [];
    for (var i = 0; i < p.length; i++) {
      var pv = p[i], bv = b[i];
      out.push((pv === null || bv === null) ? null : 100 * (pv / bv - 1));
    }
    return out;
  }

  function redraw() {
    var base = selBase ? selBase.value : null;
    var pick = selPick.value;
    var ys = [], vis = [];
    data.majors.forEach(function (m) {
      ys.push(series(m, base));
      vis.push(base ? m !== base : true);
    });
    ys.push(series(pick, base));
    // The pick is hidden when it would duplicate a line already on the chart:
    // it is one of the majors, or it is the baseline (a flat zero).
    vis.push(pick !== base && data.majors.indexOf(pick) === -1);

    Plotly.restyle(el, { y: ys, visible: vis });
    Plotly.restyle(el, { name: pick }, [data.majors.length]);

    if (base) {
      Plotly.relayout(el, {
        'yaxis.title.text': 'Big Mac price vs. ' + base,
        'annotations[0].text': base + '<br>= parity'
      });
    }
  }

  if (selBase) selBase.addEventListener('change', redraw);
  selPick.addEventListener('change', redraw);
}")

picker_chart <- function(mode) {
  valuation <- identical(mode, "valuation")
  base0     <- if (valuation) base_default else NULL

  add_line <- function(p, nm, colour, width, visible) {
    add_trace(
      p, x = releases, y = series_of(nm, base0), name = nm,
      type = "scatter", mode = "lines", visible = visible,
      line = list(color = colour, width = width),
      hovertemplate = paste0(
        "%{fullData.name}   ",
        if (valuation) "%{y:.0f}%" else "$%{y:.2f}",
        "<extra></extra>"
      )
    )
  }

  p <- plot_ly()
  ## Colour is bound to the currency for the whole report, so changing the
  ## baseline never repaints the lines that remain.
  for (nm in ref_names) {
    p <- add_line(p, nm, ref_cols[[nm]], 2, !valuation || nm != base0)
  }
  p <- add_line(p, pick_default, pick_col, 3.2, TRUE)

  axis_common <- list(
    gridcolor = gridline, griddash = "solid", zeroline = FALSE,
    showspikes = FALSE, linecolor = baseline_col,
    tickfont = list(color = ink_muted, size = 11),
    titlefont = list(color = ink_muted, size = 12)
  )

  p %>%
    layout(
      xaxis = c(axis_common, list(
        title = "", dtick = "M48", tickformat = "%Y",
        showspikes = TRUE, spikemode = "across", spikedash = "solid",
        spikecolor = baseline_col, spikethickness = 1
      )),
      yaxis = c(axis_common, list(
        title = if (valuation) paste("Big Mac price vs.", base0)
                else "Big Mac price in US dollars",
        tickformat = if (valuation) ".0f" else "$,.0f",
        ## The valuation axis is percentage points -- say so on the ticks, not
        ## only in the tooltip.
        ticksuffix = if (valuation) "%" else ""
      )),
      hovermode = "x unified",
      shapes = if (valuation) list(list(
        type = "line", xref = "paper", x0 = 0, x1 = 1,
        yref = "y", y0 = 0, y1 = 0,
        line = list(color = baseline_col, width = 1)
      )) else list(),
      ## The parity label sits in the right margin, clear of the data.
      annotations = if (valuation) list(list(
        text = paste0(base0, "<br>= parity"),
        x = 1, xanchor = "left", xref = "paper", xshift = 8,
        y = 0, yanchor = "middle", yref = "y",
        showarrow = FALSE, font = list(size = 11, color = ink_muted)
      )) else list(),
      legend = list(orientation = "h", x = 0, y = -0.16, xanchor = "left",
                    font = list(color = ink_muted, size = 11),
                    bgcolor = "rgba(0,0,0,0)"),
      margin = list(t = 28, b = 72, l = 74,
                    r = if (valuation) 118 else 24)
    ) %>%
    clean_plotly() %>%
    htmlwidgets::onRender(picker_js, data = list(
      prices    = prices,
      countries = countries,
      majors    = ref_names,
      base      = base_default,
      pick      = pick_default,
      mode      = mode
    ))
}
picker_chart("price")

4. Is that currency over- or undervalued?

Now the valuation gap instead of the raw price — and you choose what “parity” means. Set Measure against to any of the 54 countries and every line is re-scaled to that country’s Big Mac. Whichever one you pick becomes the grey zero line, because measured against itself it is 0% at every date.

That is the whole question for a traveller who does not think in dollars. A student in Seoul sets the baseline to South Korea; a student in Madrid sets it to the Euro area. Below the line a currency is undervalued against your baseline and the country is cheap for you; above it, the reverse.

picker_chart("valuation")

The Economist publishes the index against five bases (dollar, euro, pound, yen, yuan). The other 49 baselines here are derived, not published — but they use the same arithmetic, and they reproduce all four of the published non-dollar columns to within rounding across the full 2000–2026 history, so they are the same measure rather than an approximation of it.

5. The numbers behind the charts

Every country in the July 2026 release. Sort any column, or search for the one country you actually care about.

tbl <- latest %>%
  arrange(dollar_price) %>%
  transmute(
    Country          = name,
    Currency         = currency_code,
    `Local price`    = round(local_price, 2),
    `Price in USD`   = round(dollar_price, 2),
    `vs. US (%)`     = round(valuation, 1),
    `GDP-adjusted (%)` = round(valuation_adj, 1)
  )

datatable(
  tbl, rownames = FALSE, class = "compact stripe hover",
  options = list(pageLength = 15, lengthChange = FALSE,
                 columnDefs = list(list(className = "dt-right", targets = 2:5)))
) %>%
  formatCurrency("Price in USD", currency = "$", digits = 2) %>%
  formatStyle("vs. US (%)",
              color = styleInterval(0, c(cat_slots[1], "#a32e2e")),
              fontWeight = "bold")

A caveat worth keeping. The Big Mac Index is a novelty measure, not a rigorous one. A burger’s price bakes in local rent, wages and beef costs that have nothing to do with currency valuation, which is why poor countries look “undervalued” almost by default. The GDP-adjusted column is The Economist’s correction for that — it compares each country against what its income level would predict rather than against the US flat. Use the raw column to plan a trip; use the adjusted column before saying anything confident about a currency.

Source: The Economist, Big Mac Index (full index, 2000–2026). Prices from McDonald’s and local reporting; exchange rates from Thomson Reuters; GDP per person from the IMF World Economic Outlook and Eurostat. Country outlines from Natural Earth.