What is this?

The OECD publishes yearly analytical house price indicators for its members and partner countries: an index of rent prices, an index of house prices, and two affordability ratios — price-to-income and price-to-rent. The OECD gives them as indices with 2015 = 100; here every value is turned into a plain percentage change since 2015 — “+13%” means rents are 13% higher than in 2015. They show how much each market moved, not the level of rents in euros.

This vignette pulls the series straight from the OECD data API and builds the same kind of visualisations as the EU Salary Race: a ranked “bar chart race” and a shareable GIF — with Greece highlighted in red against the rest of Europe in gray. It starts with a look at Greece on its own, since rents and house prices there have taken very different paths.


Packages

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

library(dplyr)
library(tidyr)
library(countrycode)  # ISO3 codes -> country names
library(plotly)       # interactive charts
library(gganimate)    # animation
library(gifski)       # GIF renderer
library(scales)       # axis formatting

The data

The OECD serves its datasets through an SDMX API that can answer with a plain CSV, so read.csv() is all we need — no API key, no extra package. The dataset is Analytical house prices indicators (DSD_AN_HOUSE_PRICES@DF_HOUSE_PRICES). We keep the annual observations of the 29 European countries the OECD covers (EU members, Norway, Switzerland, Iceland and the United Kingdom).

url <- paste0("https://sdmx.oecd.org/public/rest/data/",
              "OECD.ECO.MPD,DSD_AN_HOUSE_PRICES@DF_HOUSE_PRICES,1.0/all",
              "?startPeriod=2000&dimensionAtObservation=AllDimensions",
              "&format=csvfilewithlabels")
df_raw <- read.csv(url, fileEncoding = "UTF-8-BOM", check.names = FALSE)

europe <- c("AUT", "BEL", "BGR", "HRV", "CZE", "DNK", "EST", "FIN", "FRA", "DEU",
            "GRC", "HUN", "IRL", "ITA", "LVA", "LTU", "LUX", "NLD", "POL", "PRT",
            "ROU", "SVK", "SVN", "ESP", "SWE", "NOR", "CHE", "ISL", "GBR")

df_housing <- df_raw %>%
  filter(FREQ == "A", REF_AREA %in% europe,
         MEASURE %in% c("RPI", "HPI", "HPI_YDH", "HPI_RPI")) %>%
  transmute(iso     = REF_AREA,
            country = countrycode(REF_AREA, origin = "iso3c", destination = "country.name"),
            measure = MEASURE,
            year    = as.integer(TIME_PERIOD),
            values  = round(as.numeric(OBS_VALUE), 1),
            change  = round(values - 100, 1)) %>%  # % change since 2015
  filter(!is.na(values))

head(df_housing)
##   iso country measure year values change
## 1 IRL Ireland HPI_YDH 2000  123.6   23.6
## 2 IRL Ireland     HPI 2000   79.5  -20.5
## 3 IRL Ireland HPI_YDH 2001  122.8   22.8
## 4 IRL Ireland     HPI 2001   89.3  -10.7
## 5 IRL Ireland HPI_YDH 2002  124.5   24.5
## 6 IRL Ireland HPI_YDH 2003  134.8   34.8

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

Column Description
country Country name (derived from the OECD’s ISO3 REF_AREA code)
measure RPI rent prices, HPI nominal house prices, HPI_YDH price-to-income ratio, HPI_RPI price-to-rent ratio
year Year of observation
values Index, 2015 = 100 (as published)
change Percentage change since 2015 (values - 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. A small helper does that for any of the four measures:

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_rent   <- race_data(df_housing, "RPI")      # rent prices
df_income <- race_data(df_housing, "HPI_YDH")  # house price to income

title_rent   <- "How much more expensive rents got since 2015 (%)"
# "+13%" / "-3%"; rounding first (+ 0 turns -0 into 0) so -0.4 reads "+0%", never "-0%"
pct <- function(v) sprintf("%+.0f%%", round(v) + 0)
title_income <- "How much faster house prices grew than incomes since 2015 (%)"

# 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
oecd_url <- paste0("https://data-explorer.oecd.org/vis?df[ds]=dsDisseminateFinalDMZ",
                   "&df[id]=DSD_AN_HOUSE_PRICES%40DF_HOUSE_PRICES&df[ag]=OECD.ECO.MPD")
add_source <- function(p, what, b) {
  plotly::layout(p,
    margin = list(b = b),
    annotations = list(list(
      text = paste0("Source: <a href='", oecd_url, "'>OECD, Analytical house prices indicators</a>",
                    " (", what, "), annual; 2015 = 100 index shown as % change since 2015"),
      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

Before racing Greece against Europe, it helps to see its three indicators side by side, as a change against 2015 (the dotted zero line). Rents and house prices both fell hard after 2010, but they recovered at very different speeds — and because incomes recovered more slowly still, the price-to-income line shows how much less affordable buying a home has become.

labels_measure <- c(RPI = "Rent prices", HPI = "House prices", HPI_YDH = "Price to income")

df_greece <- df_housing %>%
  filter(country == "Greece", measure %in% names(labels_measure)) %>%
  mutate(indicator = labels_measure[measure])

df_greece_temp<-df_greece[order(df_greece$measure, df_greece$year), c("year", "indicator", "change")]

plot_ly(df_greece_temp,
        x = ~year,
        y = ~change,
        color = ~indicator,
        colors = c("#0082c8", "#E74C3C", "#3cb44b"),
        type = "scatter",
        mode = "lines+markers",
        text = ~paste0("Year: ", year, "<br>", indicator, ": ", sprintf("%+.1f%%", change)),
        hoverinfo = "text",
        marker = list(size = 8)) %>%
  plotly::layout(title = "Greece: change since 2015 (%)",
                 xaxis = list(title = "Year", tickangle = -90),
                 yaxis = list(title = "% vs 2015", ticksuffix = "%"),
                 legend = list(orientation = "h", y = -0.2),
                 font = list(size = 16, color = "gray25"),
                 shapes = list(list(type = "line", x0 = min(df_greece$year), x1 = max(df_greece$year),
                                    y0 = 0, y1 = 0, line = list(dash = "dot", color = "gray")))) %>%
  add_source("rent prices RPI, house prices HPI, price-to-income HPI_YDH", b = 190)

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 both races: rents, and house prices relative to income.

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  = title,
           margin = list(l = 50, r = 50, b = 200, t = 100),
           font   = list(size = 20, color = "gray25", weight = "bold")) %>%
    animation_opts(frame = 1000, transition = 0, easing = "linear", redraw = TRUE) %>%
    add_source(what, b = 330)
}

bar_race(df_rent, title_rent, "rent prices, RPI")
bar_race(df_income, title_income, "price-to-income ratio, HPI_YDH")

Step 2 — dark themed GIF with gganimate

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

n_countries <- max(df_rent$rank)
hi  <- max(df_rent$change)
lo  <- min(0, min(df_rent$change))   # a few markets were below 2015 for a while
pad <- (hi - lo) * 0.02

p <- ggplot(df_rent, 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 = df_rent %>% 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_rent,
       x       = NULL,
       y       = "% vs 2015",
       caption = "Source: OECD Analytical house prices indicators  |  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("greek_housing_race.gif"))

Note: rendering the GIF takes a couple of minutes and requires an internet connection to reach the OECD API. The GIF is written to the working directory (getwd()).


What the chart shows

  • In Greece, rents kept falling after 2015 and bottomed out in 2018 (-8% against 2015). By 2025 they were +13%, while house prices were +79%: since 2015, buying has become far more expensive than renting.
  • House prices also outran incomes: the price-to-income ratio was +19% against 2015, which puts Greece 8th of 29 European countries in how much less affordable homes have become.
  • In the rent race Greece was 25th of 29 in 2025: rents rose less there than in most of Europe. The race ranks how much each market moved since 2015, not which country has the most expensive rents.
  • A caveat for Greece: the OECD rent index comes from the rents in consumer price statistics, which follow all tenancies, most of them on older contracts. Rents asked for new leases, the ones a person moving house actually faces, have risen much faster than this index shows — listing-based indices (e.g. Spitogatos or the Bank of Greece’s analyses) are the place to look for those.

Customising it

Highlight a different country

df_rent <- race_data(df_housing, "RPI", highlight = "Portugal")

Race another indicator

bar_race(race_data(df_housing, "HPI"), "How much house prices rose since 2015 (%)", "house prices, HPI")
bar_race(race_data(df_housing, "HPI_RPI"), "House prices against rents since 2015 (%)", "price-to-rent ratio, HPI_RPI")

Change the animation speed

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 · OECD SDMX API