The WorldCups.ai Database
The WorldCups.ai Database is a comprehensive database about the FIFA World Cup created by Joshua C. Fjelstul, Ph.D. It covers all 22 men's tournaments (1930–2022) and all 9 women's tournaments (1991–2023). The database includes 27 tables (over 1.58 million data points) covering all aspects of the World Cup — tournaments, teams, players, managers, referees, matches, goals, bookings, substitutions, and more.
Getting Started
The database is available in two formats.
CSV files are the easiest way to get started. Each table is a separate
.csv file that you can open in Excel, Google Sheets, R, Python, or any other data tool. Many of the CSV files include pre-merged variables from related tables for convenience — for example, matches.csv already includes the team names alongside the team IDs, so you don't need to join to the teams table to see them. This makes the CSV files well-suited for casual analysis and teaching.The relational SQL database is a PostgreSQL schema designed for users who are comfortable with SQL. In the relational version, each table contains only its own columns — merged-in convenience columns are not included. You join tables using the primary and foreign keys documented in the codebook below. This version is more compact and is the right choice for complex queries or production applications.
The
relational column in each codebook table below indicates whether a variable is present in the SQL version (TRUE) or only in the CSV version (FALSE). The primary_key and foreign_key columns indicate which variables are keys.Database Overview
The 27 tables are organized into five groups: entity tables (e.g., the basic units of observation), tournament tables, registration tables, appearance tables, and event tables.
Entity tables (9 tables): These tables contain one record per entity, which are the basic units of observation — tournaments (
tournaments), confederations (confederations), teams (teams), matches (matches), players (players), managers (managers), referees (referees), stadiums (stadiums), and awards (awards). Each entity has a unique ID.Tournament tables (6 tables): These tables describe how each tournament was organized and how each tournament progressed — which country hosted (
host_countries), which teams qualified (qualified_teams), what the stages of the tournament were (tournament_stages), what the groups were (groups), what the final group standings were (group_standings), what the final tournament standings were (tournament_standings), and what awards were presented (award_winners).Registration tables (4 tables): These tables map teams, players, managers, and referees to tournaments — which teams qualified for each tournament (
qualified_teams), which players were registered in each squad (squads), which managers were appointed to which teams (manager_appointments), and which referees were appointed to each tournament (referee_appointments).Appearance tables (4 tables): These tables map teams, players, managers, and referees to individual matches — which teams participated in each match (
team_appearances), which players participated in each match (player_appearances), which managers were in charge for each match (manager_appearances), and which referees participated in each match (referee_appearances).Event tables (4 tables): These tables record in-match events — every goal (
goals), every booking (bookings), every substitution (substitutions), and every penalty kick (penalty_kicks) in a shootout. Each event has a unique ID.License
The copyright for the original structure, organization, documentation, and replication code of the WorldCups.ai Database is owned by Joshua C. Fjelstul, Ph.D.
The database and all associated materials are published under a CC-BY-SA 4.0 license. This means you can distribute, modify, and use all or part of the database for commercial or non-commercial purposes, provided that: (1) you provide proper attribution (see below) and (2) any new works you produce based on this database also carry the CC-BY-SA 4.0 license. Contact us to discuss commercial licensing options.
Proper attribution requires: the name of the author (Joshua C. Fjelstul, Ph.D.), a copyright notice (© 2026 Joshua C. Fjelstul, Ph.D.), a link to the CC-BY-SA 4.0 license, a link to this website, and an indication of any modifications you have made.
Consistent with the CC-BY-SA 4.0 license, this database is provided as-is and as-available, with no warranties of any kind — express, implied, statutory, or otherwise — including without limitation warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors.
Common use cases
The database supports a wide range of analyses. Here are some of the most common.
Scoring and goal statistics. The
goals table records every goal, including the scorer, the team, the minute, and whether the goal was an own goal or a penalty. Combined with players and teams, you can calculate all-time top scorers, goals per tournament, scoring rates by confederation or region, and how scoring patterns have changed over time.Match results and team performance. The
matches and team_appearances tables are the foundation for analyzing match outcomes. You can calculate win rates, goal differentials, home vs. away performance (the "home" team is the first-named team in the official match designation, not necessarily a team playing in their own country), and head-to-head records between any two teams.Tournament progression. The
qualified_teams, group_standings, and tournament_standings tables let you track how far teams go in each tournament. Combined with host_countries, you can analyze whether host nations perform better than expected.Player careers. The
squads and player_appearances tables track individual players across multiple tournaments. The players table includes count_tournaments and list_tournaments to make multi-tournament career analysis straightforward. Players who appeared for different countries in different tournaments (a small number of historical cases) are handled correctly — their player_id links all appearances.Managerial records. The
manager_appointments and manager_appearances tables allow you to analyze managerial win rates, which managers have appeared in the most tournaments, and patterns in which countries managers come from relative to the teams they coach.Referee patterns. The
referee_appointments and referee_appearances tables record which referees officiated which matches. Combined with bookings, you can analyze booking rates by referee, confederation, or era.Disciplinary patterns. The
bookings table records every yellow card, red card, and second yellow card since 1970. You can analyze which teams, players, or stages of tournaments see the most disciplinary action.Substitution strategy. The
substitutions table records every substitution since 1970 — who came on, who went off, and at what minute. This is useful for analyzing tactical substitution patterns and how substitution rules have changed over time.Penalty shootouts. The
penalty_kicks table records every penalty kick in every shootout, including whether it was converted. Combined with matches, you can analyze shootout outcomes, conversion rates by team or player, and which stages of tournaments most frequently go to shootouts.Award winners. The
award_winners table lets you analyze patterns in individual tournament awards — Golden Ball, Golden Boot, Golden Glove, and Best Young Player. You can look at whether award winners correlate with team success, which confederations produce the most award winners, and how often players from losing finalists win individual honors.Host countries and stadiums. The
stadiums table records the venue and city for every match. Combined with matches, you can analyze how capacity, location, and climate zone relate to match outcomes, scoring rates, or attendance patterns — and which stadiums have hosted the most matches or finals.Tournament formats. The tournament format has changed significantly over time — from 13 teams in 1930 to 32 today, with the introduction of group stages, a round of 16, and penalty shootouts at different points. The
tournaments and tournament_stages tables let you analyze how format changes have affected outcomes, match counts, and competitive balance.Network analysis. The
player_appearances and squads tables support graph-based analysis — which players shared the pitch in the same matches or squads, teammate networks across tournaments, or how national squad composition has changed over time.Competition analysis. The database covers the men's tournaments and the women's tournaments using a consistent schema, so you can make direct comparisons across competitions — scoring rates, substitution patterns, disciplinary rates, tournament format changes over time, and how the women's tournament has grown in team count and global representation.
Teaching data science. The database is intentionally designed to support teaching. It has multiple units of analysis (tournaments, teams, players, matches, events), many opportunities for merging and reshaping data, a mix of wide and long formats, and clear primary and foreign keys. It is suitable for teaching data wrangling, data visualization, relational databases, and introductory statistics.
Key Join Patterns
The tables below document the most common multi-table join paths. In the relational database, convenience columns (team names, player names, etc.) are not included in every table — you need to join to the relevant reference table to attach them. These patterns cover the most frequent cases.
Attaching a player name to an event. Goals, bookings, and substitutions record
player_id but not the player's name. Join to players on player_id to attach family_name and given_name.SELECT g.*, p.family_name, p.given_name
FROM worldcupsai.goals g
JOIN worldcupsai.players p ON g.player_id = p.player_id
Attaching a team name to an event. Goals, bookings, and substitutions record
team_id but not the team name. Join to teams on team_id to attach team_name.SELECT g.*, t.team_name
FROM worldcupsai.goals g
JOIN worldcupsai.teams t ON g.team_id = t.team_id
Attaching a confederation to a team.
teams records confederation_id but not the confederation name or code. Join to confederations on confederation_id.SELECT t.*, c.confederation_name, c.confederation_code
FROM worldcupsai.teams t
JOIN worldcupsai.confederations c ON t.confederation_id = c.confederation_id
Attaching match context to an event. Goals, bookings, and substitutions record
match_id but not the stage, date, or stadium. Join to matches on match_id to attach match-level context.SELECT g.*, m.match_date, m.stage_name, m.group_name
FROM worldcupsai.goals g
JOIN worldcupsai.matches m ON g.match_id = m.match_id
Attaching tournament context to a match.
matches records tournament_id but not the tournament name or year. Join to tournaments on tournament_id.SELECT m.*, t.tournament_name, t.year, t.host_country
FROM worldcupsai.matches m
JOIN worldcupsai.tournaments t ON m.tournament_id = t.tournament_id
Going from a goal to a tournament. To connect an event all the way up to the tournament level, chain through
matches.SELECT g.*, t.tournament_name, t.year
FROM worldcupsai.goals g
JOIN worldcupsai.matches m ON g.match_id = m.match_id
JOIN worldcupsai.tournaments t ON m.tournament_id = t.tournament_id
Going from a player to a confederation. There is no direct link from
players to confederations. Go through squads and teams.SELECT p.family_name, p.given_name, c.confederation_code
FROM worldcupsai.players p
JOIN worldcupsai.squads s ON p.player_id = s.player_id
JOIN worldcupsai.teams t ON s.team_id = t.team_id
JOIN worldcupsai.confederations c ON t.confederation_id = c.confederation_id
Note that this join can produce multiple rows per player if a player represented teams from different confederations in different tournaments — a rare but real historical case.
Going from a match to a stadium and city.
matches records stadium_id. Join to stadiums to attach the stadium name, city, and country.SELECT m.match_id, m.match_date, s.stadium_name, s.city_name, s.country_name
FROM worldcupsai.matches m
JOIN worldcupsai.stadiums s ON m.stadium_id = s.stadium_id
Getting group standings with team names.
group_standings records team_id but not the team name. Join to teams and tournaments to attach names and year.SELECT tr.year, gs.stage_name, gs.group_name, gs.position, t.team_name, gs.points, gs.goal_difference
FROM worldcupsai.group_standings gs
JOIN worldcupsai.teams t ON gs.team_id = t.team_id
JOIN worldcupsai.tournaments tr ON gs.tournament_id = tr.tournament_id
ORDER BY tr.year, gs.group_name, gs.position
Common Queries
Here are some common queries in
SQL, R, and Python that illustrate how to work with the database. Each example below is shown in SQL (using the relational database), R (using the CSV files), and Python (using the CSV files).Top scorers
This query identifies the players with the most career World Cup goals across all tournaments. It joins the
goals table with players and teams to attach names and nationalities, filters out own goals, and aggregates by player. The final unit of observation is one row per player, with a total goal count.SQL
SELECT
p.family_name,
p.given_name,
t.team_name,
COUNT(*) AS goals
FROM worldcupsai.goals g
JOIN worldcupsai.players p ON g.player_id = p.player_id
JOIN worldcupsai.teams t ON g.player_team_id = t.team_id
WHERE g.own_goal = FALSE
GROUP BY p.player_id, p.family_name, p.given_name, t.team_name
ORDER BY goals DESC
LIMIT 10;
R
library(tidyverse)
goals <- read_csv("goals.csv")
goals |>
filter(!own_goal) |>
count(player_id, family_name, given_name, player_team_name, sort = TRUE) |>
rename(goals = n) |>
slice_head(n = 10)
Python
import pandas as pd
goals = pd.read_csv("goals.csv")
(goals
.query("own_goal == False")
.groupby(["player_id", "family_name", "given_name", "player_team_name"], dropna=False)
.size()
.reset_index(name="goals")
.sort_values("goals", ascending=False)
.head(10))
Win rates
This query calculates each team's overall win rate across all World Cup appearances. It uses
team_appearances to get one row per team per match, joins with teams for the team name, and aggregates wins and total matches by team. The final unit of observation is one row per team, with match counts and a win percentage.SQL
SELECT
t.team_name,
COUNT(*) AS matches_played,
SUM(CASE WHEN ta.result = 'win' THEN 1 ELSE 0 END) AS wins,
ROUND(AVG(CASE WHEN ta.result = 'win' THEN 1.0 ELSE 0 END) * 100, 1) AS win_pct
FROM worldcupsai.team_appearances ta
JOIN worldcupsai.teams t ON ta.team_id = t.team_id
GROUP BY t.team_id, t.team_name
HAVING COUNT(*) >= 10
ORDER BY win_pct DESC;
R
library(tidyverse)
team_appearances <- read_csv("team_appearances.csv")
team_appearances |>
group_by(team_name) |>
summarise(
matches_played = n(),
wins = sum(result == "win"),
win_pct = round(wins / matches_played * 100, 1)
) |>
filter(matches_played >= 10) |>
arrange(desc(win_pct))
Python
import pandas as pd
team_appearances = pd.read_csv("team_appearances.csv")
(team_appearances
.groupby("team_name")
.agg(matches_played=("result", "count"), wins=("win", "sum"))
.query("matches_played >= 10")
.assign(win_pct=lambda x: (x["wins"] / x["matches_played"] * 100).round(1))
.sort_values("win_pct", ascending=False))
Goals per match
This query shows how the average number of goals per match has changed across tournaments over time. It joins
tournaments and matches to get the match count per tournament, then left joins goals to count goals, using a left join to correctly handle matches with no goals. The final unit of observation is one row per tournament, with total goals, total matches, and a goals-per-match ratio.SQL
SELECT
t.tournament_name,
t.year,
COUNT(g.goal_id) AS total_goals,
COUNT(DISTINCT m.match_id) AS total_matches,
ROUND(COUNT(g.goal_id)::NUMERIC / COUNT(DISTINCT m.match_id), 2) AS goals_per_match
FROM worldcupsai.tournaments t
JOIN worldcupsai.matches m ON t.tournament_id = m.tournament_id
LEFT JOIN worldcupsai.goals g ON m.match_id = g.match_id
GROUP BY t.tournament_id, t.tournament_name, t.year
ORDER BY t.year;
R
library(tidyverse)
tournaments <- read_csv("tournaments.csv")
matches <- read_csv("matches.csv")
goals <- read_csv("goals.csv")
matches |>
left_join(goals |> count(match_id, name = "goals"), by = "match_id") |>
mutate(goals = replace_na(goals, 0)) |>
group_by(tournament_id) |>
summarise(
total_goals = sum(goals),
total_matches = n(),
goals_per_match = round(total_goals / total_matches, 2)
) |>
left_join(tournaments |> select(tournament_id, tournament_name, year), by = "tournament_id") |>
arrange(year)
Python
import pandas as pd
tournaments = pd.read_csv("tournaments.csv")
matches = pd.read_csv("matches.csv")
goals = pd.read_csv("goals.csv")
goals_per_match = goals.groupby("match_id").size().reset_index(name="goals")
matches = matches.merge(goals_per_match, on="match_id", how="left").fillna({"goals": 0})
(matches
.groupby("tournament_id")
.agg(total_goals=("goals", "sum"), total_matches=("match_id", "count"))
.assign(goals_per_match=lambda x: (x["total_goals"] / x["total_matches"]).round(2))
.reset_index()
.merge(tournaments[["tournament_id", "tournament_name", "year"]], on="tournament_id")
.sort_values("year"))
Penalty conversion rates
This query calculates each team's penalty shootout conversion rate using the
penalty_kicks table, which records every individual kick in every shootout. It joins with teams for team names and filters to teams with at least 5 kicks to exclude small samples. The final unit of observation is one row per team, with kick counts and a conversion percentage.SQL
SELECT
t.team_name,
COUNT(*) AS kicks_taken,
SUM(CASE WHEN pk.converted THEN 1 ELSE 0 END) AS converted,
ROUND(AVG(CASE WHEN pk.converted THEN 1.0 ELSE 0 END) * 100, 1) AS conversion_pct
FROM worldcupsai.penalty_kicks pk
JOIN worldcupsai.teams t ON pk.team_id = t.team_id
GROUP BY t.team_id, t.team_name
HAVING COUNT(*) >= 5
ORDER BY conversion_pct DESC;
R
library(tidyverse)
penalty_kicks <- read_csv("penalty_kicks.csv")
penalty_kicks |>
group_by(team_name) |>
summarise(
kicks_taken = n(),
converted = sum(converted),
conversion_pct = round(converted / kicks_taken * 100, 1)
) |>
filter(kicks_taken >= 5) |>
arrange(desc(conversion_pct))
Python
import pandas as pd
penalty_kicks = pd.read_csv("penalty_kicks.csv")
(penalty_kicks
.groupby("team_name")
.agg(kicks_taken=("converted", "count"), converted=("converted", "sum"))
.query("kicks_taken >= 5")
.assign(conversion_pct=lambda x: (x["converted"] / x["kicks_taken"] * 100).round(1))
.sort_values("conversion_pct", ascending=False))
Disciplinary records
This query ranks the top 5 most-carded teams in each tournament using total bookings — the sum of yellow cards, red cards, and second yellow cards. It joins
bookings with teams and tournaments to attach names, aggregates by team and tournament, and uses a window function to rank teams within each tournament. The final unit of observation is one row per team per tournament, limited to the top 5 teams by booking count within each edition.SQL
WITH bookings_per_team AS (
SELECT
b.tournament_id,
t.tournament_name,
tr.year,
tm.team_name,
COUNT(*) AS total_bookings,
SUM(CASE WHEN b.yellow_card THEN 1 ELSE 0 END) AS yellow_cards,
SUM(CASE WHEN b.red_card OR b.second_yellow_card THEN 1 ELSE 0 END) AS send_offs,
RANK() OVER (
PARTITION BY b.tournament_id
ORDER BY COUNT(*) DESC
) AS rank
FROM worldcupsai.bookings b
JOIN worldcupsai.teams tm ON b.team_id = tm.team_id
JOIN worldcupsai.matches m ON b.match_id = m.match_id
JOIN worldcupsai.tournaments t ON b.tournament_id = t.tournament_id
JOIN worldcupsai.tournaments tr ON b.tournament_id = tr.tournament_id
GROUP BY b.tournament_id, t.tournament_name, tr.year, tm.team_id, tm.team_name
)
SELECT tournament_name, year, team_name, total_bookings, yellow_cards, send_offs
FROM bookings_per_team
WHERE rank <= 5
ORDER BY year, rank;
R
library(tidyverse)
bookings <- read_csv("bookings.csv")
tournaments <- read_csv("tournaments.csv")
bookings |>
group_by(tournament_id, tournament_name, team_name) |>
summarise(
total_bookings = n(),
yellow_cards = sum(yellow_card),
send_offs = sum(red_card | second_yellow_card),
.groups = "drop"
) |>
left_join(tournaments |> select(tournament_id, year), by = "tournament_id") |>
group_by(tournament_id) |>
slice_max(total_bookings, n = 5, with_ties = FALSE) |>
arrange(year, desc(total_bookings))
Python
import pandas as pd
bookings = pd.read_csv("bookings.csv")
tournaments = pd.read_csv("tournaments.csv")
carded = (bookings
.groupby(["tournament_id", "tournament_name", "team_name"])
.agg(
total_bookings=("booking_id", "count"),
yellow_cards=("yellow_card", "sum"),
send_offs=("sending_off", "sum")
)
.reset_index()
.merge(tournaments[["tournament_id", "year"]], on="tournament_id"))
(carded
.sort_values("total_bookings", ascending=False)
.groupby("tournament_id")
.head(5)
.sort_values(["year", "total_bookings"], ascending=[True, False]))
Table Reference
For each table, the codebook lists every variable with its type, a description, and flags indicating whether the column is included in the relational version of the database, and whether it is a primary or foreign key.
Variable types:
uuid— a universally unique identifier (the primary key format used throughout the database)text— a free-form text stringinteger— a whole numberboolean— a boolean (codedTRUE/FALSE)date— a calendar date inYYYY-MM-DDformatenum— a text value constrained to a fixed set of allowed values (listed in the description)
Tournaments
One record per tournament. Covers all 22 men's World Cups (1930–2022) and all 8 women's World Cups (1991–2019).
SQL schema
CREATE TABLE worldcupsai.tournaments(
tournament_id UUID NOT NULL,
tournament_name TEXT NOT NULL,
men BOOLEAN NOT NULL,
women BOOLEAN NOT NULL,
year INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
host_country TEXT NOT NULL,
host_won BOOLEAN NOT NULL,
count_teams INTEGER NOT NULL,
group_stage BOOLEAN NOT NULL,
second_group_stage BOOLEAN NOT NULL,
final_round BOOLEAN NOT NULL,
round_of_16 BOOLEAN NOT NULL,
quarter_finals BOOLEAN NOT NULL,
semi_finals BOOLEAN NOT NULL,
third_place_match BOOLEAN NOT NULL,
final BOOLEAN NOT NULL,
PRIMARY KEY (tournament_id),
CONSTRAINT dates_ordered CHECK (end_date >= start_date)
);
Confederations
One record per FIFA confederation. There are 6 confederations: AFC (Asia), CAF (Africa), CONCACAF (North/Central America and Caribbean), CONMEBOL (South America), OFC (Oceania), and UEFA (Europe).
SQL schema
CREATE TABLE worldcupsai.confederations(
confederation_id UUID NOT NULL,
confederation_name TEXT NOT NULL,
confederation_code TEXT NOT NULL,
wikipedia_link_confederation TEXT,
PRIMARY KEY (confederation_id)
);
Teams
One record per national team. A small number of teams appear in both the men's and women's tournaments; these are recorded as a single row with both
men and women set to TRUE.SQL schema
CREATE TABLE worldcupsai.teams(
team_id UUID NOT NULL,
team_name TEXT NOT NULL,
team_code TEXT NOT NULL,
men BOOLEAN NOT NULL,
women BOOLEAN NOT NULL,
federation_name TEXT NOT NULL,
region_name worldcupsai.region_name NOT NULL,
confederation_id UUID NOT NULL,
wikipedia_link_men TEXT,
wikipedia_link_women TEXT,
wikipedia_link_federation TEXT,
PRIMARY KEY (team_id),
FOREIGN KEY (confederation_id) REFERENCES worldcupsai.confederations (confederation_id)
);
Matches
One record per match. The central table of the database — most analyses start here or join through this table. The
home_team is the first-named team in the official match designation; in most cases this is not meaningful (it does not indicate which country the match was played in), but it is consistent with the official FIFA match records.A small number of matches were replayed due to exceptional circumstances. The original match has
replayed = TRUE and the replacement match has replay = TRUE. Both records are included.SQL schema
CREATE TABLE worldcupsai.matches(
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
match_name TEXT NOT NULL,
stage_name worldcupsai.stage_name NOT NULL,
group_name worldcupsai.group_name,
group_stage BOOLEAN NOT NULL,
knockout_stage BOOLEAN NOT NULL,
replayed BOOLEAN NOT NULL,
replay BOOLEAN NOT NULL,
match_date DATE NOT NULL,
match_time TEXT NOT NULL,
stadium_id UUID NOT NULL,
home_team_id UUID NOT NULL,
away_team_id UUID NOT NULL,
score TEXT NOT NULL,
home_team_score INTEGER NOT NULL,
away_team_score INTEGER NOT NULL,
home_team_score_margin INTEGER NOT NULL,
away_team_score_margin INTEGER NOT NULL,
extra_time BOOLEAN NOT NULL,
penalty_shootout BOOLEAN NOT NULL,
score_penalties TEXT,
home_team_score_penalties INTEGER,
away_team_score_penalties INTEGER,
result worldcupsai.match_result NOT NULL,
home_team_win BOOLEAN NOT NULL,
away_team_win BOOLEAN NOT NULL,
draw BOOLEAN NOT NULL,
PRIMARY KEY (match_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (stadium_id) REFERENCES worldcupsai.stadiums (stadium_id),
FOREIGN KEY (home_team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (away_team_id) REFERENCES worldcupsai.teams (team_id),
CONSTRAINT different_teams CHECK (home_team_id != away_team_id),
CONSTRAINT penalties_iff_shootout CHECK (
(penalty_shootout = TRUE AND home_team_score_penalties IS NOT NULL
AND away_team_score_penalties IS NOT NULL)
OR
(penalty_shootout = AND home_team_score_penalties IS NULL
AND away_team_score_penalties IS NULL)
),
CONSTRAINT margins_consistent CHECK (
home_team_score_margin = home_team_score - away_team_score AND
away_team_score_margin = away_team_score - home_team_score
),
CONSTRAINT result_flags_consistent CHECK (
(home_team_win = TRUE AND away_team_win = AND draw = FALSE
AND result = 'home team win') OR
(home_team_win = AND away_team_win = TRUE AND draw = FALSE
AND result = 'away team win') OR
(home_team_win = AND away_team_win = AND draw = TRUE
AND result = 'draw')
),
CONSTRAINT shootout_requires_extra_time CHECK (
penalty_shootout = OR extra_time = TRUE
),
CONSTRAINT scores_non_negative CHECK (
home_team_score >= 0 AND away_team_score >= 0
),
CONSTRAINT group_name_iff_group_stage CHECK (
(group_stage = TRUE AND group_name IS NOT NULL) OR
(knockout_stage = TRUE AND group_name IS NULL)
)
);
Players
One record per player. Covers all players who appeared in a squad, including players on the bench who did not enter the match. A small number of players represented different countries in different tournaments; these players appear only once in this table, with their
player_id linking all of their appearances.SQL schema
CREATE TABLE worldcupsai.players(
player_id UUID NOT NULL,
family_name TEXT NOT NULL,
given_name TEXT,
birth_date DATE,
male BOOLEAN NOT NULL,
female BOOLEAN NOT NULL,
goal_keeper BOOLEAN NOT NULL,
defender BOOLEAN NOT NULL,
midfielder BOOLEAN NOT NULL,
forward BOOLEAN NOT NULL,
count_tournaments INTEGER NOT NULL,
list_tournaments TEXT NOT NULL,
wikipedia_link_player TEXT,
PRIMARY KEY (player_id)
);
Managers
One record per manager.
SQL schema
CREATE TABLE worldcupsai.managers(
manager_id UUID NOT NULL,
family_name TEXT NOT NULL,
given_name TEXT,
male BOOLEAN NOT NULL,
female BOOLEAN NOT NULL,
country_name TEXT NOT NULL,
wikipedia_link_manager TEXT,
PRIMARY KEY (manager_id)
);
Referees
One record per referee. Only the main match referee is recorded; assistant referees, fourth officials, and video assistant referees are not included.
SQL schema
CREATE TABLE worldcupsai.referees(
referee_id UUID NOT NULL,
family_name TEXT NOT NULL,
given_name TEXT,
male BOOLEAN NOT NULL,
female BOOLEAN NOT NULL,
country_name TEXT NOT NULL,
confederation_id UUID NOT NULL,
wikipedia_link_referee TEXT,
PRIMARY KEY (referee_id),
FOREIGN KEY (confederation_id) REFERENCES worldcupsai.confederations (confederation_id)
);
Stadiums
One record per stadium. Stadium capacity is the approximate capacity at the time the stadium hosted World Cup matches; it may differ from the current capacity.
SQL schema
CREATE TABLE worldcupsai.stadiums(
stadium_id UUID NOT NULL,
stadium_name TEXT NOT NULL,
city_name TEXT NOT NULL,
country_name TEXT NOT NULL,
stadium_capacity INTEGER NOT NULL,
wikipedia_link_stadium TEXT,
wikipedia_link_city TEXT,
PRIMARY KEY (stadium_id)
);
Awards
One record per individual award. Awards are handed out to players at each tournament.
SQL schema
CREATE TABLE worldcupsai.awards(
award_id UUID NOT NULL,
award_name TEXT NOT NULL,
award_description TEXT NOT NULL,
year_introduced INTEGER NOT NULL,
PRIMARY KEY (award_id)
);
Host countries
One record per host country per tournament. Some tournaments had multiple host countries (e.g., the 2002 tournament was co-hosted by South Korea and Japan).
SQL schema
CREATE TABLE worldcupsai.host_countries(
tournament_id UUID NOT NULL,
team_id UUID NOT NULL,
progress worldcupsai.stage_name NOT NULL,
PRIMARY KEY (tournament_id, team_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id)
);
Tournament stages
One record per stage per tournament. A stage is a distinct phase of the tournament, such as the group stage or the quarter-finals. The 1974, 1978, and 1982 tournaments had two group stages; these each appear as separate rows. The
stage_number field gives the chronological order of stages within a tournament.SQL schema
CREATE TABLE worldcupsai.tournament_stages(
tournament_id UUID NOT NULL,
stage_number INTEGER NOT NULL,
stage_name worldcupsai.stage_name NOT NULL,
group_stage BOOLEAN NOT NULL,
knockout_stage BOOLEAN NOT NULL,
unbalanced_groups BOOLEAN,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
count_matches INTEGER NOT NULL,
count_teams INTEGER NOT NULL,
count_scheduled INTEGER NOT NULL,
count_replays INTEGER NOT NULL,
count_playoffs INTEGER NOT NULL,
count_walkovers INTEGER NOT NULL,
PRIMARY KEY (tournament_id, stage_number),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
CONSTRAINT dates_ordered CHECK (end_date >= start_date),
CONSTRAINT unbalanced_groups_only_in_group_stage CHECK (
group_stage = TRUE OR unbalanced_groups IS NOT DISTINCT FROM FALSE
)
);
Groups
One record per group per stage per tournament. The 1934 and 1938 tournaments did not have a group stage, so they have no rows in this table. Through 1970, group names used numbers (Group 1, Group 2, etc.). Since 1982, group names have used letters (Group A, Group B, etc.). The 1974, 1978, and 1982 tournaments had two group stages; groups from both stages are included.
SQL schema
CREATE TABLE worldcupsai.groups(
tournament_id UUID NOT NULL,
stage_number INTEGER NOT NULL,
stage_name worldcupsai.stage_name NOT NULL,
group_name worldcupsai.group_name NOT NULL,
count_teams INTEGER NOT NULL,
PRIMARY KEY (tournament_id, stage_number, group_name),
FOREIGN KEY (tournament_id, stage_number)
REFERENCES worldcupsai.tournament_stages (tournament_id, stage_number),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id)
);
Group standings
One record per team per group per stage per tournament. Final positions account for tie-breakers as applied in the original tournament. Points follow the 3-1-0 system (3 points for a win, 1 for a draw, 0 for a loss) for tournaments from 1994 onward, and the 2-1-0 system for earlier tournaments.
SQL schema
CREATE TABLE worldcupsai.group_standings(
tournament_id UUID NOT NULL,
stage_number INTEGER NOT NULL,
stage_name worldcupsai.stage_name NOT NULL,
group_name worldcupsai.group_name NOT NULL,
position INTEGER NOT NULL,
team_id UUID NOT NULL,
played INTEGER NOT NULL,
wins INTEGER NOT NULL,
draws INTEGER NOT NULL,
losses INTEGER NOT NULL,
goals_for INTEGER NOT NULL,
goals_against INTEGER NOT NULL,
goal_difference INTEGER NOT NULL,
points INTEGER NOT NULL,
advanced BOOLEAN NOT NULL,
PRIMARY KEY (tournament_id, stage_number, group_name, position),
FOREIGN KEY (tournament_id, stage_number)
REFERENCES worldcupsai.tournament_stages (tournament_id, stage_number),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
CONSTRAINT unique_team_in_group UNIQUE (tournament_id, stage_number, group_name, team_id),
CONSTRAINT standings_non_negative CHECK (
played >= 0 AND wins >= 0 AND draws >= 0 AND losses >= 0 AND
goals_for >= 0 AND goals_against >= 0 AND points >= 0
),
CONSTRAINT played_consistent CHECK (played = wins + draws + losses),
CONSTRAINT goal_diff_consistent CHECK (goal_difference = goals_for - goals_against),
CONSTRAINT points_consistent CHECK (
points = (wins * 3) + draws OR
points = (wins * 2) + draws
)
);
Tournament standings
One record per ranked position per tournament. Only the top four teams are ranked. In most tournaments these are: 1st (winner of the final), 2nd (loser of the final), 3rd (winner of the third-place match), and 4th (loser of the third-place match). In tournaments without a third-place match, only positions 1 and 2 are recorded.
SQL schema
CREATE TABLE worldcupsai.tournament_standings(
tournament_id UUID NOT NULL,
position INTEGER NOT NULL,
team_id UUID NOT NULL,
PRIMARY KEY (tournament_id, position),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
CONSTRAINT unique_team_in_standings UNIQUE (tournament_id, team_id)
);
Award winners
One record per player per award per tournament. Some awards are shared; these appear as multiple rows with the same
tournament_id and award_id and shared = TRUE.SQL schema
CREATE TABLE worldcupsai.award_winners(
tournament_id UUID NOT NULL,
award_id UUID NOT NULL,
shared BOOLEAN NOT NULL,
player_id UUID NOT NULL,
team_id UUID NOT NULL,
PRIMARY KEY (tournament_id, award_id, player_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (award_id) REFERENCES worldcupsai.awards (award_id),
FOREIGN KEY (player_id) REFERENCES worldcupsai.players (player_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id)
);
Qualified teams
One record per team per tournament. The
progress variable records the furthest stage of the tournament that each team reached. Note that progress is derived from the last match each team played in — it reflects the stage of the tournament in which the team played their final match, which is the furthest stage they reached.SQL schema
CREATE TABLE worldcupsai.qualified_teams(
tournament_id UUID NOT NULL,
team_id UUID NOT NULL,
count_matches INTEGER NOT NULL,
progress worldcupsai.stage_name NOT NULL,
PRIMARY KEY (tournament_id, team_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id)
);
Squads
One record per player per team per tournament. Covers all registered squad members, including players who did not enter any match. Shirt numbers are only available from 1954; they are coded
0 for tournaments prior to 1954.Note that
squads uses coarse squad-registration positions (goal keeper, defender, midfielder, forward), while player_appearances uses granular match-day positions (21 possible values). These are different position vocabularies serving different purposes.SQL schema
CREATE TABLE worldcupsai.squads(
tournament_id UUID NOT NULL,
team_id UUID NOT NULL,
player_id UUID NOT NULL,
shirt_number INTEGER NOT NULL,
squad_position_name worldcupsai.squad_position_name NOT NULL,
squad_position_code worldcupsai.squad_position_code NOT NULL,
PRIMARY KEY (tournament_id, team_id, player_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (player_id) REFERENCES worldcupsai.players (player_id),
CONSTRAINT shirt_number_valid CHECK (shirt_number BETWEEN 0 AND 99)
);
Manager appointments
One record per manager per team per tournament. Some teams have co-managers; these appear as separate rows for the same team and tournament.
SQL schema
CREATE TABLE worldcupsai.manager_appointments(
tournament_id UUID NOT NULL,
team_id UUID NOT NULL,
manager_id UUID NOT NULL,
PRIMARY KEY (tournament_id, team_id, manager_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (manager_id) REFERENCES worldcupsai.managers (manager_id)
);
Referee appointments
One record per referee per tournament. Only the main match referee is recorded; assistant referees, fourth officials, and video assistant referees are not included.
SQL schema
CREATE TABLE worldcupsai.referee_appointments(
tournament_id UUID NOT NULL,
referee_id UUID NOT NULL,
PRIMARY KEY (tournament_id, referee_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (referee_id) REFERENCES worldcupsai.referees (referee_id)
);
Team appearances
One record per team per match. There are always exactly two rows per match — one for the home team and one for the away team. This table is particularly useful for per-team analysis across many matches, because it puts each team's perspective in its own row rather than a single row with home and away columns.
SQL schema
CREATE TABLE worldcupsai.team_appearances(
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
team_id UUID NOT NULL,
opponent_id UUID NOT NULL,
home_team BOOLEAN NOT NULL,
away_team BOOLEAN NOT NULL,
goals_for INTEGER NOT NULL,
goals_against INTEGER NOT NULL,
goal_differential INTEGER NOT NULL,
extra_time BOOLEAN NOT NULL,
penalty_shootout BOOLEAN NOT NULL,
penalties_for INTEGER,
penalties_against INTEGER,
result worldcupsai.team_result NOT NULL,
win BOOLEAN NOT NULL,
lose BOOLEAN NOT NULL,
draw BOOLEAN NOT NULL,
PRIMARY KEY (tournament_id, match_id, team_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (opponent_id) REFERENCES worldcupsai.teams (team_id),
CONSTRAINT result_flags_consistent CHECK (
(win = TRUE AND lose = AND draw = AND result = 'win') OR
(win = AND lose = TRUE AND draw = AND result = 'lose') OR
(win = AND lose = AND draw = TRUE AND result = 'draw')
),
CONSTRAINT goal_diff_consistent CHECK (
goal_differential = goals_for - goals_against
),
CONSTRAINT shootout_requires_extra_time CHECK (
penalty_shootout = OR extra_time = TRUE
),
CONSTRAINT scores_non_negative CHECK (goals_for >= 0 AND goals_against >= 0),
CONSTRAINT penalties_non_negative CHECK (
penalties_for >= 0 AND penalties_against >= 0
),
CONSTRAINT penalties_iff_shootout CHECK (
(penalty_shootout = TRUE AND penalties_for IS NOT NULL
AND penalties_against IS NOT NULL) OR
(penalty_shootout = AND penalties_for IS NULL
AND penalties_against IS NULL)
),
CONSTRAINT home_or_away CHECK (home_team != away_team)
);
Player appearances
One record per player per team per match per tournament. Coverage starts in 1970; FIFA match reports before 1970 do not record substitution information, so pre-1970 lineups are not available. Only players who actually played — either as starters or as substitutes who entered the match — are included. Players who were in the squad but remained on the bench throughout are in
squads but not player_appearances.Note that
position_name and position_code here use a granular 21-value system for match-day positions, which is different from the 4-value system used in squads for squad-registration positions.SQL schema
CREATE TABLE worldcupsai.player_appearances(
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
team_id UUID NOT NULL,
home_team BOOLEAN NOT NULL,
away_team BOOLEAN NOT NULL,
player_id UUID NOT NULL,
shirt_number INTEGER NOT NULL,
position_name worldcupsai.player_position_name NOT NULL,
position_code worldcupsai.player_position_code NOT NULL,
starter BOOLEAN NOT NULL,
substitute BOOLEAN NOT NULL,
PRIMARY KEY (tournament_id, match_id, team_id, player_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (player_id) REFERENCES worldcupsai.players (player_id),
CONSTRAINT starter_or_sub CHECK (
(starter AND NOT substitute) OR (substitute AND NOT starter)
),
CONSTRAINT shirt_number_valid CHECK (shirt_number BETWEEN 1 AND 99),
CONSTRAINT home_or_away CHECK (home_team != away_team)
);
Manager appearances
One record per manager per team per match per tournament. Teams with co-managers will have two rows per match.
SQL schema
CREATE TABLE worldcupsai.manager_appearances(
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
team_id UUID NOT NULL,
home_team BOOLEAN NOT NULL,
away_team BOOLEAN NOT NULL,
manager_id UUID NOT NULL,
PRIMARY KEY (tournament_id, match_id, team_id, manager_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (manager_id) REFERENCES worldcupsai.managers (manager_id),
CONSTRAINT home_or_away CHECK (home_team != away_team)
);
Referee appearances
One record per referee per match per tournament.
SQL schema
CREATE TABLE worldcupsai.referee_appearances(
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
referee_id UUID NOT NULL,
PRIMARY KEY (tournament_id, match_id, referee_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (referee_id) REFERENCES worldcupsai.referees (referee_id)
);
Goals
One record per goal. Does not include converted penalties in penalty shootouts — those are in
penalty_kicks. Does include penalties scored during the run of play (marked with penalty = TRUE).For own goals,
team_id records the team that is credited with the goal (i.e., the team that benefits), while player_team_id records the team that the player who scored the own goal belongs to. These are the same for regular goals, and different for own goals.SQL schema
CREATE TABLE worldcupsai.goals(
goal_id UUID NOT NULL,
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
team_id UUID NOT NULL,
home_team BOOLEAN NOT NULL,
away_team BOOLEAN NOT NULL,
player_id UUID NOT NULL,
shirt_number INTEGER NOT NULL,
player_team_id UUID NOT NULL,
minute_label TEXT NOT NULL,
minute_regulation INTEGER NOT NULL,
minute_stoppage INTEGER,
match_period worldcupsai.match_period NOT NULL,
own_goal BOOLEAN NOT NULL,
penalty BOOLEAN NOT NULL,
PRIMARY KEY (goal_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (player_id) REFERENCES worldcupsai.players (player_id),
FOREIGN KEY (player_team_id) REFERENCES worldcupsai.teams (team_id),
CONSTRAINT minutes_non_negative CHECK (minute_regulation >= 0),
CONSTRAINT home_or_away CHECK (home_team != away_team),
CONSTRAINT stoppage_only_in_stoppage_time CHECK (
(minute_stoppage IS NULL) OR
(match_period IN (
'first half, stoppage time', 'second half, stoppage time',
'extra time, first half, stoppage time',
'extra time, second half, stoppage time'
))
),
CONSTRAINT stoppage_required_in_stoppage_periods CHECK (
(match_period NOT IN (
'first half, stoppage time', 'second half, stoppage time',
'extra time, first half, stoppage time',
'extra time, second half, stoppage time'
)) OR (minute_stoppage IS NOT NULL AND minute_stoppage > 0)
)
);
Bookings
One record per booking. Coverage starts in 1970; the modern yellow/red card system was introduced at the 1970 World Cup. Exactly one of
yellow_card, red_card, or second_yellow_card is TRUE for each booking. The sending_off variable is derived: it is always TRUE when red_card or second_yellow_card is TRUE.SQL schema
CREATE TABLE worldcupsai.bookings(
booking_id UUID NOT NULL,
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
team_id UUID NOT NULL,
home_team BOOLEAN NOT NULL,
away_team BOOLEAN NOT NULL,
player_id UUID NOT NULL,
shirt_number INTEGER NOT NULL,
minute_label TEXT NOT NULL,
minute_regulation INTEGER NOT NULL,
minute_stoppage INTEGER,
match_period worldcupsai.match_period NOT NULL,
yellow_card BOOLEAN NOT NULL,
red_card BOOLEAN NOT NULL,
second_yellow_card BOOLEAN NOT NULL,
sending_off BOOLEAN NOT NULL,
PRIMARY KEY (booking_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (player_id) REFERENCES worldcupsai.players (player_id),
CONSTRAINT one_card_type CHECK (
(yellow_card::int + red_card::int + second_yellow_card::int) = 1
),
CONSTRAINT home_or_away CHECK (home_team != away_team),
CONSTRAINT sending_off_consistent CHECK (
sending_off = (red_card OR second_yellow_card)
),
CONSTRAINT minutes_non_negative CHECK (minute_regulation >= 0),
CONSTRAINT stoppage_only_in_stoppage_time CHECK (
(minute_stoppage IS NULL) OR
(match_period IN (
'first half, stoppage time', 'second half, stoppage time',
'extra time, first half, stoppage time',
'extra time, second half, stoppage time'
))
),
CONSTRAINT stoppage_required_in_stoppage_periods CHECK (
(match_period NOT IN (
'first half, stoppage time', 'second half, stoppage time',
'extra time, first half, stoppage time',
'extra time, second half, stoppage time'
)) OR (minute_stoppage IS NOT NULL AND minute_stoppage > 0)
)
);
Substitutions
One record per player per substitution. Coverage starts in 1970. There are two rows per substitution event: one with
going_off = TRUE for the player leaving the pitch and one with coming_on = TRUE for the player entering.SQL schema
CREATE TABLE worldcupsai.substitutions(
substitution_id UUID NOT NULL,
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
team_id UUID NOT NULL,
home_team BOOLEAN NOT NULL,
away_team BOOLEAN NOT NULL,
player_id UUID NOT NULL,
shirt_number INTEGER NOT NULL,
minute_label TEXT NOT NULL,
minute_regulation INTEGER NOT NULL,
minute_stoppage INTEGER,
match_period worldcupsai.match_period NOT NULL,
going_off BOOLEAN NOT NULL,
coming_on BOOLEAN NOT NULL,
PRIMARY KEY (substitution_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (player_id) REFERENCES worldcupsai.players (player_id),
CONSTRAINT one_direction CHECK (going_off != coming_on),
CONSTRAINT minutes_non_negative CHECK (minute_regulation >= 0),
CONSTRAINT home_or_away CHECK (home_team != away_team),
CONSTRAINT stoppage_only_in_stoppage_time CHECK (
(minute_stoppage IS NULL) OR
(match_period IN (
'first half, stoppage time', 'second half, stoppage time',
'extra time, first half, stoppage time',
'extra time, second half, stoppage time'
))
),
CONSTRAINT stoppage_required_in_stoppage_periods CHECK (
(match_period NOT IN (
'first half, stoppage time', 'second half, stoppage time',
'extra time, first half, stoppage time',
'extra time, second half, stoppage time'
)) OR (minute_stoppage IS NOT NULL AND minute_stoppage > 0)
)
);
Penalty kicks
One record per penalty kick in a penalty shootout. Does not include penalties taken during the run of play (those are in
goals with penalty = TRUE). The order of kicks within a shootout is preserved by the order of rows.SQL schema
CREATE TABLE worldcupsai.penalty_kicks(
penalty_kick_id UUID NOT NULL,
tournament_id UUID NOT NULL,
match_id UUID NOT NULL,
team_id UUID NOT NULL,
home_team BOOLEAN NOT NULL,
away_team BOOLEAN NOT NULL,
player_id UUID NOT NULL,
shirt_number INTEGER NOT NULL,
converted BOOLEAN NOT NULL,
PRIMARY KEY (penalty_kick_id),
FOREIGN KEY (tournament_id) REFERENCES worldcupsai.tournaments (tournament_id),
FOREIGN KEY (match_id) REFERENCES worldcupsai.matches (match_id),
FOREIGN KEY (team_id) REFERENCES worldcupsai.teams (team_id),
FOREIGN KEY (player_id) REFERENCES worldcupsai.players (player_id),
CONSTRAINT home_or_away CHECK (home_team != away_team)
);
Coverage Table
Not all tables cover all time periods. The table below summarizes the coverage of each table.
Data Notes
Data sources
All data in the database is coded from Wikipedia. Some information is cross-referenced with the official FIFA match reports. The Wikipedia pages used to code the database are archived in the
data-raw/ folder of the repository. Tournament and award data is hand-coded; squad and match data is machine-coded using R scripts available in data-raw/code/. All replication code is open source and available in the repository.Player names
The official FIFA match reports contain numerous errors in player names, including incorrect given names, misspellings, and inconsistent or outdated transliterations. Wikipedia provides cleaner names. Player names are not always uniquely identifying — the database uses Wikipedia links as the primary identifier for players, standardized so that each player has exactly one link across all tournaments. Players commonly known by a single name (e.g., Pelé, Ronaldinho) have that name recorded in
family_name and a null given_name. The replication code performs extensive name standardization, including resolving inconsistent names for players who appeared across multiple tournaments under different transliterations or married names.Shirt numbers
Prior to 1950, players did not wear numbers on their shirts. In the 1950 World Cup, players had numbers but they were not consistent from match to match. The
shirt_number variable in squads is coded 0 for all tournaments prior to 1954.Substitution patterns
Data on substitutions and player appearances starts in 1970. FIFA match reports before 1970 do not record substitution information, so pre-1970 lineups cannot be fully reconstructed. The
substitutions and player_appearances tables therefore have no records before 1970. When comparing the data to online versions of the official FIFA match reports, note that the match reports sometimes incorrectly swap the player going off and the player coming on, or record the wrong player as coming on. These errors have been corrected where identified.Event times
For goals, substitutions, and bookings, times are taken from Wikipedia. These can differ slightly from the official FIFA match reports, which themselves sometimes contain inconsistencies. It is common for the exact minute of in-match events to vary slightly between different sources.
Match minutes
In football, minute
1' refers to the time from 00:00 to 00:59; minute 45' refers to the time from 44:00 to 44:59 and is the last regulation minute of the first half; minute 46' is the first regulation minute of the second half. Stoppage time minutes are written as 45'+1', 45'+2', etc. for first-half stoppage time, and 90'+1', 90'+2', etc. for second-half stoppage time. The same pattern continues into extra time (91'–105' for the first half of extra time, 106'–120' for the second half).In this database,
minute_regulation records the number before the + and minute_stoppage records the number after the +. For events in regulation time, minute_stoppage is null. For events in stoppage time, minute_regulation will be 45, 90, 105, or 120, and minute_stoppage will be 1 or greater.Tournament formats
The format of the World Cup has changed significantly over time. Key variations:
- The 1934 and 1938 tournaments were purely knockout from the first round with no group stage. The
groupstable has no records for these tournaments. - The 1950 tournament used a round-robin final group instead of a final match; the winner was determined by points, not a single final.
- Through 1970, group names used numbers (Group 1, Group 2, etc.).
- The 1974, 1978, and 1982 tournaments had two group stages. The
groupsandgroup_standingstables include records for both stages. In 1974 and 1978, the first stage used numbered groups and the second stage used lettered groups. In 1982, both stages used numbered groups. - From 1986 onward, group names have used letters (Group A, Group B, etc.).
Country codes
The database uses ISO Alpha-3 country codes. Notable cases:
- The home nations use their own codes:
ENG(England),WAL(Wales),SCO(Scotland),NIR(Northern Ireland). - Former countries:
SCG(Serbia and Montenegro),YUG(Yugoslavia),DDR(East Germany),CSK(Czechoslovakia),SUN(Soviet Union). - The Dutch East Indies uses
IDN(Indonesia) as there is no ISO Alpha-3 code for the Dutch East Indies. - West Germany uses the code for Germany (
DEU) but is labeled as "West Germany" in theteam_namefield.
Points systems
The
group_standings table records points as awarded in the original tournament. Tournaments from 1994 onward use the modern 3-1-0 system (3 points for a win, 1 for a draw, 0 for a loss). Earlier tournaments used the 2-1-0 system (2 points for a win, 1 for a draw, 0 for a loss). Both systems are valid values and are preserved as-is.Replayed matches
A small number of matches were replayed due to exceptional circumstances (e.g., the 1930 and 1934 tournaments). The original match has
replayed = TRUE and the replacement match has replay = TRUE. Both records are included in the matches table. Goal and event data are recorded only for the matches that count toward the official record.Known limitations
The database does not include: assistant referees, fourth officials, or video assistant referees; player club affiliations; qualifying matches; friendly matches; or any matches outside of the official FIFA World Cup tournaments. Data quality is high, and data is extensively audited and cross-checked where possible, but reflects the quality of the underlying Wikipedia sources. Known errors in Wikipedia have been corrected where identified, but others may remain.
Frequently Asked Questions
Why does a goal have two team ID columns —
team_id and player_team_id?
For regular goals, these are the same. For own goals, they differ. team_id records the team credited with the goal — the team that benefits. player_team_id records the team of the player who actually scored it. When analyzing a player's goal tally, always filter on player_team_id and exclude own_goal = TRUE. When analyzing a team's goals scored, use team_id.Why do some players have a
null value for given_name?
Players commonly known by a single name — Pelé, Ronaldinho, Eusébio, and others — have that name recorded in family_name and a null in given_name. This allows you to use family_name as a display label.Are West Germany and Germany the same team?
No. West Germany and Germany are different countries. They are recorded as separate entries in the
teams table. West Germany (DEU, labeled "West Germany") and the reunified Germany (DEU, labeled "Germany") share the same ISO country code but are distinct rows with different team_id values. Be careful when aggregating across both — you will need to decide whether to treat them as the same entity for historical analyses.What does "home team" mean?
The
home_team designation follows the official FIFA match designation — it is the first-named team in the official record, not necessarily a team playing in their home country. In most cases this distinction is not meaningful. Do not use home_team as a proxy for whether a team was playing in their own country; use host_countries for that.Why do
player_appearances, bookings, and substitutions start in 1970?
FIFA match reports before 1970 do not record lineup or substitution information in a form that can be reliably coded. The yellow and red card system was also introduced at the 1970 World Cup. These tables therefore have no records for tournaments before 1970. squads is available for all tournaments, so you can identify which players were registered for pre-1970 tournaments even if their match-level appearances are not available.Why does the 1950 tournament not have a final?
The 1950 World Cup used a round-robin final group rather than a knockout final. The winner was determined by points accumulated across the final group matches, not by a single final match. The
final column in tournaments is FALSE for 1950, and tournament_standings records only the top positions based on the final group standings.How do I find which team won a tournament?
Use
tournament_standings and filter for position = 1. Do not rely on host_won in tournaments for this — that variable only indicates whether the host country won, not which team did.Why do some tournaments have two group stages?
The 1974, 1978, and 1982 World Cups used a second group stage instead of a knockout round of 16. The
groups and group_standings tables include records for both stages, identified by stage_number and stage_name. The tournament_stages table documents the full structure of each tournament.How should I handle players who represented different countries in different tournaments?
A small number of players represented different national teams across tournaments — typically due to geopolitical changes or eligibility switches. These players appear only once in the
players table, with a single player_id that links all their appearances. Their squads and player_appearances records will show different team_id values across tournaments. This is intentional and correct.Why are there no records for the 1934 and 1938 tournaments in
groups and group_standings?
Those tournaments were purely knockout from the first round with no group stage. There are no groups to record. The tournaments table has group_stage = FALSE for both.What is the difference between
squad_position in squads and position in player_appearances?
squads uses a coarse four-value position system for squad registration: goal keeper, defender, midfielder, forward. player_appearances uses a granular 21-value system reflecting the player's actual position in each match. These are different typologies that serve different purposes.