When Russiaโs invasion of Ukraine sent European natural gas prices soaring in 2022, wholesale electricity prices followed. But the shock did not hit every country equally. Countries that had invested heavily in low-carbon generation โ nuclear, hydro, wind, solar, geothermal, and bioenergy โ saw smaller and shorter-lived price increases than those still reliant on fossil fuels.
This analysis uses publicly available data to quantify the relationship between a countryโs pre-crisis low-carbon electricity share and the size of its price increase.
The chart below shows monthly wholesale electricity prices for every European country in the Ember dataset. Use the dropdown to select specific countries, or view them all at once. The 2022 spike โ and the uneven recovery โ is immediately visible.
sd_prices <- SharedData$new(prices, key = ~country, group = "ts_group")
filter_select(
id = "country_filter",
label = "Select countries to highlight",
sharedData = sd_prices,
group = ~country,
multiple = TRUE
)
plot_ly(sd_prices, x = ~date, y = ~price, color = ~country,
type = "scatter", mode = "lines", line = list(width = 1.3),
hovertemplate = "%{x|%b %Y}<br>%{y:.2f} c/kWh<extra>%{fullData.name}</extra>") |>
layout(
xaxis = list(title = ""),
yaxis = list(title = "Wholesale price (cents / kWh)"),
height = 420,
legend = list(orientation = "v", x = 1.02, y = 1, font = list(size = 8)),
hovermode = "x unified",
margin = list(t = 10)
) |>
highlight(on = "plotly_click", off = "plotly_doubleclick",
persistent = TRUE, selectize = FALSE)
Source: Ember European Wholesale Electricity Price Data (monthly).
To measure the lasting impact โ rather than just the 2022 spike โ we compare each countryโs 2019 average price with its average over 2023โ2025. This smooths out the volatile recovery and captures the new normal.
d_bar <- change |>
mutate(
country = factor(country, levels = country),
col = ifelse(price_change > 0, "#e74c3c", "#2980b9")
)
plot_ly(d_bar, y = ~country, x = ~price_change,
type = "bar", orientation = "h",
marker = list(color = ~col),
text = ~paste0(ifelse(price_change > 0, "+", ""),
round(price_change, 2), " c/kWh"),
textposition = "outside", textfont = list(size = 11),
hovertemplate = paste0(
"<b>%{y}</b><br>",
"Change: %{x:.2f} c/kWh<br>",
"2019: %{customdata[0]:.2f} c/kWh<br>",
"Avg 2023-25: %{customdata[1]:.2f} c/kWh<extra></extra>"
),
customdata = ~cbind(price_2019, price_post)) |>
layout(
xaxis = list(title = "Price change (cents / kWh)"),
yaxis = list(title = "", categoryorder = "trace"),
height = max(380, nrow(d_bar) * 18),
margin = list(l = 120, r = 60),
showlegend = FALSE
)
Difference between the average wholesale price in 2023โ2025 and the 2019 baseline, in euro-cents per kWh. Red bars = price increase; blue bars = price decrease.
Is there a systematic relationship between a countryโs pre-crisis
electricity mix and the size of its price shock? We measure each
countryโs low-carbon share (renewx) in
2019 โ the combined share of nuclear, hydro, wind, solar, geothermal,
and bioenergy โ and plot it against the price change computed above.
fit <- lm(price_change ~ renewx, data = reg_data)
s <- summary(fit)
r2 <- round(s$r.squared, 3)
slope <- round(coef(fit)[2], 4)
se_slope <- round(s$coefficients[2, 2], 4)
slope_sign <- ifelse(slope < 0, "", "+")
slope_label <- glue("Slope: {slope_sign}{slope} c/kWh per pp (SE = {se_slope})")
# Use ggplot + ggrepel for non-overlapping labels
p_scatter <- ggplot(reg_data, aes(renewx, price_change)) +
geom_smooth(method = "lm", se = TRUE, colour = "#e67e22", fill = "#e67e22",
alpha = 0.15, linewidth = 1.2) +
geom_point(colour = "#2980b9", size = 3, alpha = 0.85,
shape = 21, fill = "#2980b9", stroke = 0.5) +
ggrepel::geom_text_repel(
aes(label = country),
size = 3.2, colour = "#2c3e50",
max.overlaps = Inf,
box.padding = 0.4,
point.padding = 0.3,
segment.color = "grey70",
segment.size = 0.3,
min.segment.length = 0.2,
seed = 42
) +
annotate("text",
x = max(reg_data$renewx) * 0.97,
y = max(reg_data$price_change) * 0.95,
label = slope_label,
hjust = 1, size = 4, colour = "#e67e22",
fontface = "bold"
) +
annotate("text",
x = max(reg_data$renewx) * 0.97,
y = max(reg_data$price_change) * 0.85,
label = glue("R\u00b2 = {r2}"),
hjust = 1, size = 3.8, colour = "#e67e22"
) +
labs(
x = "Low-carbon electricity share in 2019 (%)",
y = "Price change: 2019 vs avg 2023\u20132025 (cents / kWh)"
)
p_scatter
Each dot is a European country. The orange line is the OLS best fit; the
shaded band shows the 95% confidence interval. Countries with a higher
low-carbon share in 2019 experienced smaller price increases.
ยฉ Ralf
Martin
For every additional percentage point of low-carbon electricity in 2019, the post-crisis price increase was -0.033ย cents/kWh smaller on average.
cat(glue("**N = {nrow(reg_data)} countries  |  ",
"R\u00b2 = {r2}  |  ",
"Adj. R\u00b2 = {round(s$adj.r.squared, 3)}  |  ",
"F-statistic p = {format.pval(pf(s$fstatistic[1], s$fstatistic[2], s$fstatistic[3], lower.tail=FALSE), digits=3)}**\n\n"))
## **N = 29 countries  |  Rยฒ = 0.207  |  Adj. Rยฒ = 0.177  |  F-statistic p = 0.0132**
broom::tidy(fit, conf.int = TRUE) |>
mutate(across(where(is.numeric), ~round(.x, 4))) |>
knitr::kable(
col.names = c("Term", "Estimate", "Std. Error", "t", "p-value",
"CI low (2.5%)", "CI high (97.5%)"),
caption = "OLS regression: price change (cents/kWh) ~ low-carbon share (%)"
)
| Term | Estimate | Std. Error | t | p-value | CI low (2.5%) | CI high (97.5%) |
|---|---|---|---|---|---|---|
| (Intercept) | 6.4001 | 0.7976 | 8.0243 | 0.0000 | 4.7636 | 8.0367 |
| renewx | -0.0329 | 0.0124 | -2.6533 | 0.0132 | -0.0583 | -0.0075 |
Between 2010 and 2019 many European countries significantly increased their low-carbon electricity share. Using the regression slope estimated above, we can ask: how much higher would the post-crisis price increase have been if each country had stayed at its 2010 low-carbon share?
For each country we compute the change in renewx from
2010 to 2019, multiply by the regression slope, and interpret the result
as the price-change savings attributable to that expansion.
slope_est <- coef(fit)[2] # cents/kWh per percentage point of renewx
cf <- reg_data |>
inner_join(gen_2010, by = "country") |>
filter(!is.na(renewx_2010)) |>
mutate(
renewx_gain = renewx - renewx_2010, # pp increase 2010->2019
price_saving = renewx_gain * slope_est, # implied price saving (negative = saved)
counterfactual = price_change - price_saving # price change if stuck at 2010 mix
) |>
arrange(desc(renewx_gain))
ggplot(cf, aes(renewx_gain, price_saving)) +
geom_point(colour = "#27ae60", size = 3, alpha = 0.85,
shape = 21, fill = "#27ae60", stroke = 0.5) +
ggrepel::geom_text_repel(
aes(label = country),
size = 3.2, colour = "#2c3e50",
max.overlaps = Inf,
box.padding = 0.4,
point.padding = 0.3,
segment.color = "grey70",
segment.size = 0.3,
min.segment.length = 0.2,
seed = 42
) +
labs(
x = "Increase in low-carbon share, 2010 to 2019 (pp)",
y = "Implied price saving (cents / kWh)"
)
Each dot is a country. The x-axis shows how much the low-carbon share grew between 2010 and 2019; the y-axis shows the implied reduction in the post-crisis price increase, based on the regression slope (-0.0329 c/kWh per pp). Countries in the bottom-right expanded low-carbon the most and benefited the most.
The chart below compares the actual price increase (2019 โ avg 2023โ25) with the counterfactual โ what the increase would have been if the country had kept its 2010 low-carbon share.
cf_long <- cf |>
select(country, Actual = price_change, Counterfactual = counterfactual) |>
arrange(Actual) |>
mutate(country = factor(country, levels = country)) |>
pivot_longer(cols = c(Actual, Counterfactual),
names_to = "scenario", values_to = "change")
plot_ly(cf_long, y = ~country, x = ~change, color = ~scenario,
type = "bar", orientation = "h",
colors = c("Actual" = "#2980b9", "Counterfactual" = "#e74c3c"),
hovertemplate = paste0("<b>%{y}</b><br>",
"%{fullData.name}: %{x:.2f} c/kWh<extra></extra>")) |>
layout(
barmode = "group",
xaxis = list(title = "Price change (cents / kWh)"),
yaxis = list(title = "", categoryorder = "trace"),
height = max(400, n_distinct(cf$country) * 22),
margin = list(l = 120),
legend = list(x = 0.7, y = 0.05, bgcolor = "rgba(255,255,255,0.8)")
)
Blue = actual price change. Red = counterfactual price change if the country had stayed at its 2010 low-carbon share. The gap between the bars is the estimated benefit of expanding low-carbon generation between 2010 and 2019.
cf |>
select(Country = country,
`Renewx 2010 (%)` = renewx_2010,
`Renewx 2019 (%)` = renewx,
`Gain (pp)` = renewx_gain,
`Actual change (c/kWh)` = price_change,
`Counterfactual (c/kWh)` = counterfactual,
`Saving (c/kWh)` = price_saving) |>
mutate(across(where(is.numeric), ~round(.x, 2))) |>
knitr::kable(caption = "Counterfactual analysis: implied savings from low-carbon expansion 2010-2019")
| Country | Renewx 2010 (%) | Renewx 2019 (%) | Gain (pp) | Actual change (c/kWh) | Counterfactual (c/kWh) | Saving (c/kWh) |
|---|---|---|---|---|---|---|
| Luxembourg | 8.33 | 74.77 | 66.43 | 4.98 | 7.17 | -2.19 |
| Lithuania | 18.20 | 73.95 | 55.75 | 4.32 | 6.15 | -1.83 |
| Denmark | 32.08 | 78.55 | 46.47 | 4.03 | 5.56 | -1.53 |
| United Kingdom | 23.12 | 53.77 | 30.65 | 4.71 | 5.72 | -1.01 |
| Ireland | 13.23 | 38.39 | 25.16 | 6.63 | 7.45 | -0.83 |
| Finland | 58.26 | 81.26 | 23.00 | 0.34 | 1.10 | -0.76 |
| Estonia | 8.10 | 28.12 | 20.03 | 4.06 | 4.72 | -0.66 |
| Greece | 18.60 | 33.58 | 14.98 | 4.42 | 4.91 | -0.49 |
| Italy | 25.95 | 39.94 | 13.99 | 6.68 | 7.15 | -0.46 |
| Germany | 39.41 | 52.70 | 13.29 | 4.98 | 5.42 | -0.44 |
| Hungary | 50.27 | 61.23 | 10.96 | 5.52 | 5.88 | -0.36 |
| Austria | 66.34 | 77.30 | 10.96 | 5.42 | 5.78 | -0.36 |
| Netherlands | 12.84 | 22.25 | 9.41 | 4.58 | 4.89 | -0.31 |
| Belgium | 58.58 | 67.94 | 9.35 | 4.45 | 4.76 | -0.31 |
| Bulgaria | 45.74 | 55.01 | 9.27 | 5.72 | 6.03 | -0.30 |
| Poland | 6.94 | 15.60 | 8.66 | 5.05 | 5.34 | -0.28 |
| Romania | 52.83 | 61.11 | 8.28 | 5.49 | 5.77 | -0.27 |
| Czechia | 39.79 | 47.03 | 7.23 | 5.41 | 5.65 | -0.24 |
| Spain | 53.65 | 58.90 | 5.25 | 2.48 | 2.65 | -0.17 |
| Slovenia | 64.02 | 68.34 | 4.32 | 5.16 | 5.30 | -0.14 |
| Sweden | 94.25 | 98.01 | 3.76 | 0.50 | 0.62 | -0.12 |
| Croatia | 62.88 | 66.01 | 3.13 | 5.22 | 5.32 | -0.10 |
| Slovakia | 74.73 | 77.74 | 3.01 | 5.86 | 5.96 | -0.10 |
| Norway | 95.79 | 97.94 | 2.14 | 0.86 | 0.93 | -0.07 |
| Switzerland | 96.61 | 97.31 | 0.70 | 5.31 | 5.33 | -0.02 |
| France | 89.84 | 90.52 | 0.68 | 3.33 | 3.36 | -0.02 |
| Portugal | 53.01 | 53.12 | 0.11 | 2.55 | 2.55 | 0.00 |
| Serbia | 31.77 | 28.51 | -3.26 | 5.74 | 5.63 | 0.11 |
| Latvia | 54.90 | 49.53 | -5.37 | 4.30 | 4.12 | 0.18 |
| Item | Detail |
|---|---|
| Prices | Ember European Wholesale Electricity Price Data (monthly, day-ahead) |
| Generation mix | Our World in Data / Ember + Energy Institute |
| Price unit | Euro-cents per kWh (= EUR/MWh รท 10) |
| Base year | 2019 (pre-pandemic, pre-crisis) |
| Comparison period | Average of 2023, 2024, and 2025 (where available) |
Low-carbon share (renewx) |
Nuclear + hydro + wind + solar + geothermal + bioenergy (% of electricity, 2019) |
| Method | OLS regression of absolute price change on 2019 low-carbon share |
All data is freely available without API keys. Code is shown behind the Code buttons above each chart.