Greek Wages Race: Salaries, Consumer Prices and Real Wages in Europe

What is this?

A pay rise only matters if it beats the rise in prices. This vignette puts three Eurostat series side by side for every EU country:

  • wages — the average full-time adjusted salary per employee (nama_10_fte),
  • consumer prices — the Harmonised Index of Consumer Prices, all items (prc_hicp_aind),
  • real wages — wages divided by prices: what a salary can actually buy.

Every value is shown as a plain percentage change since 2015 — “+6%” means 6% higher than in 2015. Greece is highlighted in red throughout, in the style of the EU Salary Race and the Greek Housing Race: a look at Greece on its own, the same chart for every other country, three ranked “bar chart races” and a shareable GIF of each.


Packages

Code
# install.packages(c("dplyr", "tidyr", "countrycode", "plotly", "eurostat",
#                     "gganimate", "gifski", "scales", "ragg"))

library(dplyr)
library(tidyr)
library(countrycode)  # Eurostat geo codes -> country names
library(plotly)       # interactive charts
library(eurostat)     # Eurostat data API
library(gganimate)    # animation
library(gifski)       # GIF renderer
library(scales)       # axis formatting

The data

get_eurostat() downloads each table by its Eurostat code. Two choices matter:

  • Wages in national currency (unit = "NAC"), not in euros. For a country outside the euro area a salary in euros also moves with the exchange rate; deflating it by that country’s own prices only makes sense in the currency those prices are measured in.
  • Everything rebased to 2015 = 100 by us, so wages and prices share the same starting line whatever base year Eurostat publishes them in.

The real wage is then simply wages / prices × 100. Wages are published with a delay, so the wage and real-wage series end a year earlier than prices.

Code
# the time column is called `time` or `TIME_PERIOD` depending on the
# eurostat package version and table — give it one name
get_series <- function(code, filters) {
  d <- as.data.frame(get_eurostat(code, filters = filters, time_format = "num"))
  names(d)[names(d) %in% c("time", "TIME_PERIOD")] <- "year"
  d[, c("geo", "year", "values")]
}

rebase_2015 <- function(d) {
  d %>%
    filter(nchar(geo) == 2) %>%                 # countries only, no EU27/EA aggregates
    group_by(geo) %>%
    filter(any(year == 2015)) %>%
    mutate(index = values / values[year == 2015] * 100) %>%
    ungroup()
}

df_wages  <- rebase_2015(get_series("nama_10_fte", list(unit = "NAC")))
df_prices <- rebase_2015(get_series("prc_hicp_aind", list(unit = "INX_A_AVG", coicop = "CP00"))) %>%
  filter(geo %in% df_wages$geo)                 # the countries we have wages for

df_real <- inner_join(df_wages[, c("geo", "year", "index")],
                      df_prices[, c("geo", "year", "index")],
                      by = c("geo", "year"), suffix = c("_wage", "_price")) %>%
  transmute(geo, year, index = index_wage / index_price * 100)

df_pay <- bind_rows(
    df_wages  %>% transmute(geo, year, index, measure = "wages"),
    df_prices %>% transmute(geo, year, index, measure = "prices"),
    df_real   %>% transmute(geo, year, index, measure = "real")) %>%
  mutate(country = countrycode(geo, origin = "eurostat", destination = "country.name"),
         index   = round(index, 1),
         change  = round(index - 100, 1)) %>%   # % change since 2015
  filter(!is.na(country), !is.na(index))

head(df_pay, 12)
# A tibble: 12 × 6
   geo    year index measure country change
   <chr> <dbl> <dbl> <chr>   <chr>    <dbl>
 1 BE     1995  60.3 wages   Belgium  -39.7
 2 BE     1996  61.5 wages   Belgium  -38.5
 3 BE     1997  63.2 wages   Belgium  -36.8
 4 BE     1998  64.3 wages   Belgium  -35.7
 5 BE     1999  67.3 wages   Belgium  -32.7
 6 BE     2000  69.1 wages   Belgium  -30.9
 7 BE     2001  72   wages   Belgium  -28  
 8 BE     2002  74.7 wages   Belgium  -25.3
 9 BE     2003  76.3 wages   Belgium  -23.7
10 BE     2004  78.1 wages   Belgium  -21.9
11 BE     2005  79.3 wages   Belgium  -20.7
12 BE     2006  82.3 wages   Belgium  -17.7

Each row is one country, one indicator, one year:

Column Description
country Country name (derived from Eurostat’s geo code)
measure wages average full-time adjusted salary, prices consumer prices (HICP, all items), real wages divided by prices
year Year of observation
index Index, 2015 = 100
change Percentage change since 2015 (index - 100)

For the races we need one indicator at a time, from 2015 (where every country starts at 0%) onwards, ranked within each year, with Greece in red:

Code
race_data <- function(df, which, highlight = "Greece", from = 2015) {
  d <- df[df$measure == which & df$year >= from, ]
  d$rank <- ave(-d$change, d$year, FUN = function(v) rank(v, ties.method = "first"))
  d$color <- ifelse(d$country %in% highlight, "#E74C3C", "gray70")
  d
}

df_race_wages  <- race_data(df_pay, "wages")
df_race_prices <- race_data(df_pay, "prices")
df_race_real   <- race_data(df_pay, "real")

title_wages  <- "How much salaries rose since 2015 (%, national currency)"
title_prices <- "How much consumer prices rose since 2015 (%)"
title_real   <- "How much more (or less) a salary buys than in 2015 (%)"

# "+6%" / "-3%"; rounding first (+ 0 turns -0 into 0) so -0.4 reads "+0%", never "-0%"
pct <- function(v) sprintf("%+.0f%%", round(v) + 0)

# data source as a footnote inside each plotly chart (it stays on the chart
# when it is downloaded as a PNG from the plotly toolbar): `b` is the bottom
# margin that makes room for it; the note sits at the bottom of that margin,
# in pixels, so it lands in the same place whatever the chart's height
# chart height in pixels, set on the widget itself (width stays 100% of the
# page), so the chart always fills the box it is drawn in — no scroll bars.
# Returned inside a tagList: printed straight from a chunk, a widget's height
# is overridden by the chunk's figure size; inside a tagList it is kept
# The plotly toolbar goes down the right edge, so it never covers a long title
sized <- function(p, h) {
  p <- plotly::layout(p, modebar = list(orientation = "v"))
  p$height <- h
  htmltools::tagList(p)
}

add_source <- function(p, what, b) {
  plotly::layout(p,
    margin = list(b = b),
    annotations = list(list(
      text = paste0("Source: Eurostat, <a href='https://ec.europa.eu/eurostat/databrowser/view/nama_10_fte/default/table'>nama_10_fte</a>",
                    " and <a href='https://ec.europa.eu/eurostat/databrowser/view/prc_hicp_aind/default/table'>prc_hicp_aind</a>",
                    " (", what, ")"),
      xref = "paper", yref = "paper", x = 1, y = 0, yshift = -(b - 22),
      xanchor = "right", yanchor = "top", showarrow = FALSE,
      font = list(size = 11, color = "gray50"))))
}

Greece at a glance

The three lines tell the whole story: when the wage line runs below the price line, salaries are losing ground, and the real-wage line (wages divided by prices) shows by how much.

Code
labels_measure <- c(wages = "Wages", prices = "Consumer prices", real = "Real wages")
pal <- c("Consumer prices" = "#E74C3C", "Real wages" = "#3cb44b", "Wages" = "#0082c8")

country_plot <- function(cname, font_size = 13) {
  d <- df_pay %>%
    filter(country == cname, year >= 2005) %>%
    mutate(indicator = labels_measure[measure]) %>%
    arrange(measure, year)

  plot_ly(d,
          x = ~year,
          y = ~change,
          color = ~indicator,
          colors = pal,
          type = "scatter",
          mode = "lines+markers",
          text = ~paste0(cname, " · ", year, "<br>", indicator, ": ", sprintf("%+.1f%%", change)),
          hoverinfo = "text",
          marker = list(size = 9)) %>%
    plotly::layout(title = list(text = paste0(cname, ": wages, prices and real wages, change since 2015 (%)"),
                                x = 0.02, xanchor = "left",
                                y = 0.97, yanchor = "top"),
                   margin = list(t = 80),
                   xaxis = list(title = "", tickangle = -90),
                   yaxis = list(title = "% vs 2015", ticksuffix = "%"),
                   legend = list(orientation = "h", y = -0.15),
                   font = list(size = font_size, color = "gray25"),
                   shapes = list(list(type = "line",
                                      x0 = min(d$year), x1 = max(d$year),
                                      y0 = 0, y1 = 0,
                                      line = list(dash = "dot", color = "gray")))) %>%
    add_source("wages, consumer prices, real wages", b = 150) %>%
    sized(520)
}

country_plot("Greece")

The same chart for every other country, alphabetically:

Code
countries <- sort(setdiff(unique(df_pay$country), "Greece"))
htmltools::tagList(lapply(countries, country_plot))

Building the visualisations

Step 1 — bar chart race, ranked

The frame argument of plot_ly turns a bar chart into a play/pause animation stepping through each year, and plotting by rank instead of country name makes the bars physically reorder every year. Greece is layered on top in red so it is always visible against the gray field. The same function draws all three races: wages, consumer prices and real wages.

Code
bar_race <- function(d, title, what) {
  plot_ly() %>%
    add_bars(data = d[d$color == "gray70", ],
             x = ~rank, y = ~change, frame = ~year, ids = ~rank,
             marker = list(color = "gray70"),
             text = ~paste0(country, " ", sprintf("%+.0f%%", change)), hoverinfo = "text",
             textposition = "outside", textangle = 90, showlegend = FALSE) %>%
    add_bars(data = d[d$color %in% "#E74C3C", ],
             x = ~rank, y = ~change, frame = ~year, ids = ~rank,
             marker = list(color = "#E74C3C"),
             text = ~paste0(country, " ", sprintf("%+.0f%%", change)), hoverinfo = "text",
             textposition = "outside", textangle = 90, showlegend = FALSE) %>%
    layout(xaxis  = list(title = "Rank", tickmode = "array"),
           yaxis  = list(title = "% vs 2015", ticksuffix = "%"),
           title  = list(text = title, x = 0.02, xanchor = "left", y = 0.97, yanchor = "top"),
           margin = list(l = 50, r = 30, t = 70),
           font   = list(size = 13, color = "gray25")) %>%
    animation_opts(frame = 1000, transition = 0, easing = "linear", redraw = TRUE) %>%
    add_source(what, b = 200) %>%
    sized(620)
}

bar_race(df_race_wages,  title_wages,  "wages")
Code
bar_race(df_race_prices, title_prices, "consumer prices")
Code
bar_race(df_race_real,   title_real,   "real wages")

Step 2 — dark themed GIF with gganimate

A rendered GIF is easier to share than an interactive chart. ggplot2 + gganimate recreates each of the three ranked races as a horizontal bar chart with a dark background, country labels and a large year watermark.

Code
race_gif <- function(d, title, file) {
  n_countries <- max(d$rank)
  hi  <- max(d$change)
  lo  <- min(0, min(d$change))   # real wages fell below 2015 in several countries
  pad <- (hi - lo) * 0.02

  p <- ggplot(d, aes(x = rank, y = change, fill = color, group = country)) +
    geom_col(width = 0.85, show.legend = FALSE) +
    # country names left of the panel, value at the end of each bar
    geom_text(aes(y = lo - pad, label = country, color = color),
              hjust = 1, size = 3.8, fontface = "bold", show.legend = FALSE) +
    geom_text(aes(y = pmax(change, 0) + pad, label = pct(change)),
              hjust = 0, color = "gray80", size = 3.2) +
    geom_hline(yintercept = 0, color = "#546E7A", linewidth = 0.4) +
    scale_fill_identity() +
    scale_color_identity() +
    scale_x_reverse(breaks = NULL) +
    scale_y_continuous(labels = function(v) paste0(v, "%"),
                       expand = expansion(mult = c(0.02, 0.1))) +
    geom_text(
      data = d %>% dplyr::distinct(year),
      aes(x = n_countries, y = hi, label = year),
      inherit.aes = FALSE,
      hjust = 1, vjust = 0,
      size = 20, color = "white", alpha = 0.15,
      fontface = "bold"
    ) +
    coord_flip(clip = "off") +
    labs(title   = title,
         x       = NULL,
         y       = "% vs 2015",
         caption = "Source: Eurostat (nama_10_fte, prc_hicp_aind)  |  Greece highlighted in red") +
    theme_minimal(base_size = 10) +
    theme(
      plot.background    = element_rect(fill = "#0F1923", color = NA),
      panel.background   = element_rect(fill = "#0F1923", color = NA),
      panel.grid.major.x = element_line(color = "#1E2D3D", linewidth = 0.4),
      panel.grid.major.y = element_blank(),
      panel.grid.minor   = element_blank(),
      axis.text.x   = element_text(color = "#90A4AE", size = 10),
      axis.text.y   = element_blank(),
      axis.title.x  = element_text(color = "#90A4AE", size = 10),
      plot.title    = element_text(color = "white", face = "bold", size = 12,
                                    margin = margin(12, 0, 8, 0)),
      plot.caption  = element_text(color = "#546E7A", size = 8, hjust = 1,
                                    margin = margin(8, 0, 8, 0)),
      plot.margin   = margin(10, 40, 10, 130)) +
    transition_states(year, transition_length = 0, state_length = 5) +
    ease_aes("cubic-in-out")

  animate(p,
          nframes  = 100,
          fps      = 10,
          duration = 20,
          width    = 900,
          height   = 900,
          units    = "px",
          res      = 100,   # set explicitly: when knitting, gganimate otherwise uses the chunk dpi
          renderer = gifski_renderer(file))
}

# each GIF is drawn in its own chunk below: knitr shows only one animate()
# result per chunk
gifs <- c(wages  = "greek_wages_race_wages.gif",
          prices = "greek_wages_race_prices.gif",
          real   = "greek_wages_race_real.gif")

Wages

Code
race_gif(df_race_wages, title_wages, gifs[["wages"]])

Consumer prices

Code
race_gif(df_race_prices, title_prices, gifs[["prices"]])

Real wages

Code
race_gif(df_race_real, title_real, gifs[["real"]])

Note: rendering the three GIFs takes several minutes and requires an internet connection to reach the Eurostat API. The GIFs are written to the working directory (getwd()).


What the charts show

  • Between 2015 and 2024, salaries in Greece rose by +6% while consumer prices rose by +19%. The result is a real wage of -12% against 2015: an average Greek salary buys less than it did in 2015.
  • In the wage race Greece is 26th of 26 countries; in the real-wage race 26th of 26.
  • Across Europe the gap is wide: real wages rose most in Romania (+124%) and fell most in Greece (-12%). In 2024, 3 of 26 countries still had real wages below 2015.
  • Prices kept rising after 2024: by 2025 they were +23% in Greece against 2015.
  • Caveats: nama_10_fte is the average gross salary of a full-time equivalent employee — not take-home pay, and not the median, so a few very high salaries pull it up. The HICP measures the prices of a typical basket of goods and services; it leaves out the cost of owner-occupied housing, and households whose spending differs from the average basket (more rent, more energy) can feel inflation quite differently. Gross salaries can also jump for reasons that leave take-home pay unchanged: in 2018 Romania moved most social contributions from employers to employees and raised gross wages to compensate, which inflates its wage and real-wage growth here.

Customising it

Highlight a different country

Code
df_race_real <- race_data(df_pay, "real", highlight = "Portugal")

Prices of a single category

prc_hicp_aind breaks prices down by COICOP category — e.g. CP01 food, CP045 energy, CP041 rents:

Code
df_food <- rebase_2015(get_series("prc_hicp_aind", list(unit = "INX_A_AVG", coicop = "CP01")))

Change the animation speed

The GIFs’ timing is set by the animate() call at the end of race_gif():

Code
animate(p, nframes = 50, fps = 5, duration = 10, ...)   # faster / shorter
animate(p, nframes = 200, fps = 20, duration = 30, ...) # smoother / longer

Further reading


Rendered with R 4.6.1 · plotly · ggplot2 · gganimate · eurostat