What Minute Decides a World Cup Final? A Century of Final Goals, Visualized in R

Spain won the 2026 World Cup beating Argentina 1–0 with a Ferran Torres goal in the 106th minute. Ninety scoreless minutes, then a champion crowned in extra time — which raises a question: is that how World Cup finals usually go, or did we just watch an unusual one? A single match can’t say — but the full record can: in what minute is the decisive goal typically scored, and has that minute drifted later over the tournament’s history? To answer, we need every goal ever scored in a final with the minute it was scored — exactly what the open international football results dataset  maintained by Mart Jürisoo provides: every international match since 1872, every goal, every minute, with last night’s final already in it. Two charts will give the answer — first the full picture, all 23 finals on one timeline, then the winning goal on its own.

R packages needed for this project:

library(ggplot2)
library(dplyr)
library(rvest)
library(ggrepel)
library(ggtext)

The data

Two CSVs: one row per match, and one row per goal (scorer, minute, penalty and own-goal flags).

base    <- "https://raw.githubusercontent.com/martj42/international_results/master/"
results <- read.csv(paste0(base, "results.csv"))
goals   <- read.csv(paste0(base, "goalscorers.csv"))

The dataset doesn’t label which match is a final, so we need a list of the 23 finals from somewhere. Rather than typing it by hand, we scrape it from Wikipedia’s list of World Cup finals : rvest reads the page, html_table() converts its tables, and we keep the one with a Winners column. Everything we need is in it — including whether the final went to extra time or penalties, which we parse out of the Score column. One cleaning step is genuinely necessary: Wikipedia correctly says West Germany for 1954–1990, but the results dataset uses Germany throughout, and the names must agree for the join ahead.

page <- read_html("https://en.wikipedia.org/wiki/List_of_FIFA_World_Cup_finals")
tabs <- html_table(page, fill = TRUE)
wiki <- tabs[[which(sapply(tabs, function(t) "Winners" %in% names(t)))[1]]]

finals <- wiki %>%
  transmute(year   = as.integer(Year),
            winner = sub("West Germany", "Germany", Winners),
            loser  = sub("West Germany", "Germany", `Runners-up`),
            et     = grepl("a.e.t.", Score, fixed = TRUE),
            pens   = grepl("pen.",   Score, fixed = TRUE),
            pens_score = ifelse(pens, sub(".*\((\d+–\d+) pen.*", "\1", Score), NA)) %>%
  filter(!is.na(year))
nrow(finals)
## [1] 23

Now we find each final in the results data. Wikipedia’s table has no match date, only the year — and there are two traps waiting. First, finals are played at neutral venues, so the dataset’s home_team/away_team is just an arbitrary listing order: we must match the two finalists in either order. Second, matching a year and a team pair is not always unique, because sometimes the finalists had already met earlier in the same tournament — in 1954 Hungary beat West Germany 8–3 in the group stage before losing the final to them, and 1962 has a group-stage Brazil–Czechoslovakia too. The pair’s last meeting of the tournament is always the final, so slice_max(date) resolves it. (This also sidesteps a third trap we never trigger: in 1938 the final and the third-place match were played on the same day, so date alone wouldn’t identify the final either.) A final join pulls in every goal from those 23 matches, tagged by which side scored it and how.

wc <- results %>%
  filter(tournament == "FIFA World Cup") %>%
  mutate(year = as.integer(substr(date, 1, 4)))

finals <- finals %>%
  left_join(wc, by = "year", relationship = "many-to-many") %>%
  filter((home_team == winner & away_team == loser) |
         (home_team == loser  & away_team == winner)) %>%
  group_by(year) %>% slice_max(date, n = 1) %>% ungroup()

fgoals <- goals %>%
  inner_join(finals, by = c("date", "home_team", "away_team")) %>%
  mutate(side = ifelse(team == winner, "Winning team", "Losing team"),
         kind = case_when(own_goal == "TRUE" ~ "Own goal",
                          penalty  == "TRUE" ~ "Penalty",
                          TRUE               ~ "Open play"))
nrow(fgoals)
## [1] 84

84 goals in 96 years of finals. A shared minimal theme keeps the two figures consistent:

th <- theme_minimal(base_size = 12) +
  theme(panel.grid.minor = element_blank(),
        plot.title = element_text(face = "bold", size = 15),
        plot.subtitle = element_text(colour = "#52514e", size = 10.5,
                                     margin = margin(b = 10)),
        plot.caption = element_text(colour = "#8a8983", size = 8.5, hjust = 0),
        plot.tag = element_text(colour = "#8a8983", size = 8.5,
                                hjust = 1, vjust = 0),
        plot.tag.position = c(1, 0), plot.tag.location = "plot",
        legend.position = "top", legend.justification = "left")
WIN <- "#2a78d6"; LOSE <- "#b9b8b2"; ACC <- "#e34948"

Figure 1: all 23 finals on one chart

Each final is a horizontal strip from kick-off to full time (extended to 120′ where extra time was played), and each goal sits on it at the minute it was scored — winners in blue, losers in gray, penalties as diamonds, own goals as squares. The result labels keep the dataset’s original team order (its nominal home/away listing — finals are at neutral venues), with the winner in blue; aet means the final was decided after extra time, and where it says pens the match was still level after 120 minutes, so the winner came from a penalty shootout — the shootout score is in parentheses.

strips <- finals %>% mutate(xmax = ifelse(et, 120, 90))
labels <- finals %>%
  mutate(suffix = case_when(
           pens ~ paste0(" (", pens_score, " pens)"),
           et   ~ " aet",
           TRUE ~ ""),
         lab = paste0(ifelse(home_team == winner,
                             paste0("<b style='color:#2a78d6'>", home_team, "</b>"),
                             home_team),
                      " ", home_score, "–", away_score, " ",
                      ifelse(away_team == winner,
                             paste0("<b style='color:#2a78d6'>", away_team, "</b>"),
                             away_team),
                      suffix))

ggplot() +
  annotate("rect", xmin = 90, xmax = 120, ymin = -Inf, ymax = Inf, fill = "#f3f2ef") +
  geom_segment(data = strips,
               aes(x = 0, xend = xmax, y = factor(year), yend = factor(year)),
               colour = "#e3e2de", linewidth = 2.6, lineend = "round") +
  geom_point(data = fgoals, aes(minute, factor(year), fill = side, shape = kind),
             size = 3, colour = "white", stroke = 0.5) +
  geom_richtext(data = labels, aes(x = 126, y = factor(year), label = lab),
                hjust = 0, size = 2.9, colour = "#52514e",
                fill = NA, label.colour = NA, label.padding = unit(0, "pt")) +
  scale_fill_manual(NULL, values = c("Winning team" = WIN, "Losing team" = LOSE)) +
  scale_shape_manual(NULL, values = c("Open play" = 21, "Penalty" = 23, "Own goal" = 22)) +
  scale_x_continuous(breaks = seq(0, 120, 15), limits = c(0, 205), expand = c(0.01, 0)) +
  scale_y_discrete(limits = rev(as.character(finals$year))) +
  guides(fill = guide_legend(override.aes = list(shape = 21, size = 3.4)),
         shape = guide_legend(override.aes = list(fill = "#8a8983", size = 3.4))) +
  labs(title = "Every goal ever scored in a World Cup final",
       subtitle = "23 finals, 1930–2026 · each dot is a goal at the minute it was scored · shaded band = extra time",
       x = "Match minute", y = NULL,
       caption = "Teams in the dataset's original order, winner in blue · aet = decided after extra time · pens = level after 120', decided by penalty shootout (shootout score in parentheses).n1994 is blank: the only 0–0 final. Data: github.com/martj42/international_results · finals list: Wikipedia",
       tag = "datascienceplus.com") +
  th + theme(panel.grid.major.y = element_blank(),
             axis.text.y = element_text(colour = "#0b0b0b", face = "bold", size = 9.5))
plot of chunk fig1

Whole eras are readable at a glance. The top third is the goal-fest period — Brazil’s 5–2 in 1958 made seven goals in a final, still the record. Then the chart empties out: from 1990 to 2014, four of seven finals produced one goal or none, with 1994 the only strip with no dot at all. And the recent finals push right: 2010, 2014, and 2026 were all settled inside the shaded extra-time band.

Figure 2: the winning goal keeps coming later

That rightward drift is worth isolating. Define the winning goal as the goal that put the champion ahead for good — the champion’s (opponent’s score + 1)th goal. In a 4–2 that’s the winner’s third goal; in a 1–0 it’s the only one. Sorting each champion’s goals by minute and picking that one is three lines of dplyr:

decisive <- fgoals %>%
  filter(team == winner) %>%
  group_by(year) %>% arrange(minute, .by_group = TRUE) %>%
  mutate(n_goal = row_number()) %>%
  left_join(finals %>% transmute(year, need = pmin(home_score, away_score) + 1),
            by = "year") %>%
  filter(n_goal == need) %>%
  ungroup() %>% select(year, minute, scorer)

shootout_finals <- finals %>% filter(pens) %>%
  transmute(year, minute = 126, scorer = "penalties")
dec  <- bind_rows(decisive, shootout_finals) %>% arrange(year)

The three shootout finals have no winning goal, so they get parked above the 120-minute line as a separate mark. Every matchup is written vertically inside the plot (in the dataset’s original team order, winner in blue), rising from the baseline under its dot, and a loess curve traces the century-long trend.

callouts <- dec %>%
  filter(year %in% c(1930, 1966, 2010, 2014, 2026)) %>%
  mutate(lab = paste0(scorer, " ", minute, "'"))

matchups <- dec %>%
  left_join(finals %>% select(year, winner, home_team, away_team), by = "year") %>%
  mutate(lab = paste0(ifelse(home_team == winner,
                             paste0("<b style='color:#2a78d6'>", home_team, "</b>"),
                             home_team),
                      " v ",
                      ifelse(away_team == winner,
                             paste0("<b style='color:#2a78d6'>", away_team, "</b>"),
                             away_team)))

ggplot(dec, aes(year, minute)) +
  annotate("rect", ymin = 90, ymax = 120, xmin = -Inf, xmax = Inf, fill = "#f3f2ef") +
  annotate("text", x = 1931, y = 116, label = "extra time", hjust = 0,
           size = 3.1, colour = "#8a8983", fontface = "italic") +
  geom_smooth(data = dec %>% filter(minute <= 120), method = "loess", span = 1.1,
              se = FALSE, colour = "#a9c7ec", linewidth = 1.1) +
  geom_richtext(data = matchups, aes(x = year, y = 2, label = lab),
                angle = 90, hjust = 0, size = 2.5, colour = "#a5a49e",
                fill = NA, label.colour = NA, label.padding = unit(0, "pt")) +
  geom_point(aes(fill = minute > 120), shape = 21, size = 3.4,
             colour = "white", stroke = 0.5, show.legend = FALSE) +
  geom_text_repel(data = callouts, aes(label = lab), size = 3,
                  colour = "#52514e", seed = 3, box.padding = 0.5,
                  min.segment.length = 0.3, segment.colour = "#c9c8c2") +
  annotate("text", x = 1994, y = 129, label = "decided on penalties",
           size = 3, colour = ACC, fontface = "italic") +
  scale_fill_manual(values = c(`FALSE` = WIN, `TRUE` = ACC)) +
  scale_y_continuous(breaks = seq(0, 120, 30), limits = c(0, 132)) +
  scale_x_continuous(breaks = seq(1930, 2026, 16)) +
  labs(title = "The goal that wins the World Cup keeps coming later",
       subtitle = "Minute of the match-winning goal in each final (the goal that put the champion ahead for good)",
       x = NULL, y = "Minute of the winning goal",
       caption = "Winning goal = the champion's (opponent's score + 1)th goal. Three finals had none — settled from the spot.nVertical matchup labels keep the dataset's team order, winner in blue. Data: github.com/martj42/international_results · finals list: Wikipedia",
       tag = "datascienceplus.com") +
  th
plot of chunk fig2

For the first sixty years the trend line barely moves: the average final was settled around the 70th minute, and Geoff Hurst’s 101st-minute goal in 1966 was a famous outlier. Since 1990 the line climbs steeply into the extra-time band. Iniesta in the 116th (2010), Götze in the 113th (2014), three finals with no winning goal at all (1994, 2006, 2022) — and now Ferran Torres in the 106th. Of the last ten World Cups, six were still level after 90 minutes of the final; of the first thirteen, only three were. The answer to the title’s question has shifted: the game that decides a World Cup has quietly grown half an hour longer.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.