The 2025 German federal election marked a highly competitive political contest, the final result of which was determined by marginal vote differences.
Two parties narrowly failed to clear the five-percent electoral threshold:
The Free Democratic Party (FDP), a member of the previous governing coalition, remained below the threshold with approximately 333,000 votes (≈0.67% of 49.6 million valid second votes). The Sahra Wagenknecht Alliance (BSW), which has been
part of the state governments in Thuringia and Brandenburg since 2024, lacked fewer than 10,000 votes (≈0.02%) to enter the German Bundestag. Had one or both parties entered parliament, the current governing coalition would have lost its
absolute majority.1
Against this background, the present analytical project was developed. Its objective was to systematically examine the discrepancy between vote shares and
seat shares based on the official election data of the Federal Returning Officer. In addition to direct comparisons, the following metrics were analyzed:
All charts on this page were built with Plotly and are fully interactive. Here is a quick overview of the available controls:
The overview graphic compares vote shares (lower bar) and seat shares (upper bar) for selected parties in every federal election since 1949. A vertical reference line marks the 50-percent threshold required to form a governing coalition
with an absolute majority.
The chart illustrates the evolution from the dominant position of the major parties (CDU/CSU and SPD) in the early decades toward an increasingly fragmented multi-party system since the 1990s. The FDP,
which regularly helped the major parties achieve an absolute majority since 1949, failed to enter parliament in two of the last four federal elections. At the same time, new actors (AfD, BSW, and other minor parties) have emerged more
prominently since 2013.
The graphic directly juxtaposes the asymmetry between vote shares and seat shares and highlights the effect of the five-percent threshold. In elections with narrow majority margins, such as 2025, this effect
intensifies significantly. Votes for parties below the threshold (e.g., FDP and BSW) are redistributed among successful parties in the allocation of mandates, enabling governing coalitions to secure narrow parliamentary majorities.
The second chart visualizes the development of the difference between seat share and vote share for selected parties (CDU/CSU, SPD, FDP, Greens, AfD) over time. Positive values indicate overrepresentation in parliament, negative values
indicate underrepresentation.
Historically, CDU/CSU and SPD in particular regularly exhibit positive deviations, while smaller parties show smaller or more volatile differences. Notable are the strong negative deviation of the Greens
in 1990 and pronounced positive spikes for CDU/CSU in certain electoral periods (e.g., 2013).
The difference analysis reveals two central mechanisms: First, the electoral threshold produces systematic overrepresentation of established
parties when multiple parties remain just below five percent. Second, the variance of differences increases with growing fragmentation of the party system. The 2013 election demonstrates how strongly the exclusion of the FDP shifted seat
distribution in favor of larger parties. The extremely narrow failure of FDP and BSW in 2025 generated a disproportional allocation of mandates in favor of the five parties represented in parliament (with the exception of the SSW). The
displayed differences are a direct result of institutional constraints within the electoral system.
The Gallagher Index measures disproportionality between vote and seat shares on a scale from 0 (minimal disproportionality, e.g., 0.64 in Sweden 2022) to 20+ (very high disproportionality, e.g., 23.64 in the United
Kingdom 2024).2
For German federal elections, the time series shows comparatively low values in the 1970s and early 1980s. All other election years reach moderate to slightly
elevated levels. The shaded threshold areas illustrate different intensities of disproportionality.
Quadratic deviation measures disproportionately weight larger differences. Accordingly, high Gallagher values reflect election years in
which structural distortions were particularly pronounced. Already in 1949, prior to the introduction of the five-percent threshold at the federal level, the second-highest index value was recorded. The year 2013 marked a significant increase
due to the FDP’s failure to enter parliament. In 2025, the index again reached an elevated range, though remaining two percentage points below the historical maximum. The index thus corroborates the results of the difference analysis, showing
that the more votes remain below the threshold, the greater the deviation between seat allocation and vote distribution. For 2025, the non-entry of FDP and BSW, together with other minor parties accounting for 13% of total voter support,
resulted in renewed elevated disproportionality in the Gallagher Index.
The above boxplot shows the conversion rate from votes to Bundestag seats per party across multiple electoral periods. Values above 100% indicate disproportionate mandate conversion, while values below indicate under-conversion. Election years
in which parties failed to meet the five-percent threshold are excluded from the graphic.
The visualization demonstrates that the major parties maintain a consistent conversion rate above 100%, though not exceeding 120%. Smaller parties display
greater dispersion and slightly lower average rates. Extreme outliers and lower average conversion rates are particularly visible among parties that intermittently failed to meet the threshold.
The South Schleswig Voters’ Association (SSW),
representing the Danish minority, is exempt from the five-percent threshold.3 This has allowed it to obtain one seat in the Bundestag in 1949 (before the introduction of the threshold), 2021,
and 2025. Due to the very small vote share relative to the overall Bundestag composition, this single mandate produces statistical outliers.
The combined analysis of all four graphics produces a consistent picture:
All operations were performed in a Google Colab environment
An up-to-date version of Plotly is required. The following libraries are used for data processing and visualization:
!pip install --upgrade plotly
#--upgrade is required for specific hover menu functions
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
List of all election years, used for columns, y-axis labels and sorting:
#used for columns, y-axis labels and sorting
electionYears = [1949, 1953, 1957, 1961, 1965, 1969, 1972, 1976, 1980, 1983, 1987, 1990, 1994, 1998, 2002, 2005, 2009, 2013, 2017, 2021, 2025]
Color codes for graphing, keyed by party name:
partyColor = { "CDU/CSU" : "#2d3c4b", "SPD" : "#E3000F", "FDP" : "#ffed00", "GRÜNE" : "#46962b",
"AfD" : "#0099ff", "DIE LINKE" : "#FF0000", "SSW" : "#003c8f",
"NPD" : "#8b4726", "PIRATEN" : "#FF8800", "BSW" : "#792351", "REP" : "#0075be",
"KPD/DKP":"#FF355E", "DRP" : "#8b4726", "GB/BHE" : "#A04B90", "DP" : "#EFBF04",
"BP" : "#3158b0", "Zentrum" : "#3389CC", "Sonstige" : "#878787"
}
Dictionaries mapping chancellors and coalition parties to election years:
#name of chancellor
chancellorNames = {"Adenauer" : [1949, 1953, 1957, 1961],"Erhard" : [1965], "Brandt" : [1969, 1972],
"Schmidt" : [1976, 1980], "Kohl" : [1983, 1987, 1990, 1994], "Schröder" : [1998, 2002],
"Merkel" : [2005, 2009, 2013, 2017], "Scholz" : [2021], "Merz" : [2025]}
coalitionYears = {1949 : ["CDU/CSU", "FDP","DP"], 1953 : ["CDU/CSU", "FDP","GB/BHE","DP"],
1957 : ["CDU/CSU", "DP"], 1961 : ["CDU/CSU", "FDP"], 1965 : ["CDU/CSU", "FDP"],
1969 : ["SPD", "FDP"], 1972 : ["SPD", "FDP"], 1976 : ["SPD", "FDP"], 1980 : ["SPD", "FDP"],
1983 : ["CDU/CSU", "FDP"], 1987 : ["CDU/CSU", "FDP"], 1990 : ["CDU/CSU", "FDP"],
1994 : ["CDU/CSU", "FDP"], 1998 : ["SPD", "GRÜNE"], 2002 : ["SPD", "GRÜNE"],
2005 : ["CDU/CSU", "SPD"], 2009 : ["CDU/CSU", "FDP"], 2013 : ["CDU/CSU", "SPD"],
2017 : ["CDU/CSU", "SPD"], 2021 : ["SPD", "GRÜNE", "FDP"], 2025 : ["CDU/CSU", "SPD"]}
#parties relevant for discrepancy chart
discParties = ["CDU/CSU", "SPD", "FDP", "GRÜNE", "AfD"]
Data is scraped directly from the wahlrecht.de results table:
url = 'https://www.wahlrecht.de/ergebnisse/bundestag.htm'
tables = pd.read_html(url)
print (f'Total tables: {len(tables)}')
Verify import and transfer to DataFrame:
tables[1].head(3)
electionResults = tables[1]
electionResults.head(3)
The raw HTML table contains annotated text values and ambiguous separators that need to be resolved before analysis:
#replace annotated text values
electionResults.replace('5,1¹',51,inplace=True, regex=True)
electionResults.replace('DIE LINKE²','DIE LINKE',inplace=True, regex=True)
electionResults.replace('KPD/DKP³','KPD/DKP',inplace=True, regex=True)
#set first col as index
electionResults = electionResults.set_index(('Unnamed: 0_level_0', 'Unnamed: 0_level_1')).copy()
electionResults.index.name = 'Partei'
#drop irrelevant row
electionResults = electionResults.drop('Wahlbeteiligung')
#drop last, redundant, column
electionResults = electionResults.drop(electionResults.columns[-1], axis=1)
#proper NaNs
electionResults = electionResults.replace(['–', '-'], np.nan)
electionResults.head(3)
Separate DataFrames are created for seat counts and vote percentages, then normalized and transposed for plotting:
#select seat columns
seats = electionResults.loc[:, (slice(None), 'Sitze')]
#drop multi-index level
seats.columns = seats.columns.droplevel(1)
#assign years as column names
seats.columns = electionYears
seats.head(3) #verify dataframe creation
#select percentage columns
percentage = electionResults.loc[:, (slice(None), '%')]
percentage.columns = percentage.columns.droplevel(1)
percentage.columns = electionYears
#decimal ',' was not recognized during import. replace values
percentage = percentage.astype(float)
percentage = percentage / 10
percentage.loc['BSW',2025] = percentage.loc['BSW',2025]/100 #BSW value was imported incorrectly
percentage.head(3)
percentagePlot = percentage.transpose()
#convert all values in the DataFrame to numeric, coercing errors to NaN
seatShare = seats.apply(pd.to_numeric, errors='coerce')
#calculate the sum of seats for each year, then calculate percentage of total
totalSeats = seatShare.sum(axis=0)
seatShare = seatShare.div(totalSeats, axis=1) * 100
#transpose, set index and sort by year
seatShare = seatShare.transpose()
seatShare.index = seatShare.index.astype(int)
seatShare = seatShare.sort_index(ascending=True)
#drop columns containing only NaN values to limit legend entries
seatShare = seatShare.dropna(axis=1, how='all')
Combined and derived DataFrames are prepared for use across all four charts:
#combine percentage and seat dataframes for comparison
percSeatComp = pd.concat([percentagePlot, seatShare])
percSeatComp = percSeatComp.sort_index(level=0) #sort years in index
#create df to analyze discrepancy between votes and seats
discrepancy = percentagePlot - seatShare
discrepancyRate = (seatShare / percentagePlot) *100
#needed for boxplot
seatConv = discrepancyRate.dropna(axis=1, how='all')
Each election year contributes three rows to the plot (a spacer, a votes row, and a seats row) with interactive dropdown menus for filtering by chancellor and coalition:
rows = []
meta = [] #keep metadata for y-axis labels and hover menus
#loop through every election year, adding from vote and seat dfs
for y in electionYears:
#1st spacer row
spacer = pd.Series(np.nan, index=percentagePlot.columns)
rows.append(spacer)
meta.append({"year": y, "row_type": "spacer"})
#2nd votes row
votePerc = percentagePlot.loc[y].copy()
rows.append(votePerc)
meta.append({"year": y, "row_type": "Vote Share"})
#3rd seats row
seatPerc = seatShare.loc[y].copy()
rows.append(seatPerc)
meta.append({"year": y, "row_type": "Seat Share"})
compPlot = pd.DataFrame(rows).reset_index(drop=True)
meta = pd.DataFrame(meta)
#build y categories
y = np.arange(len(compPlot))
#create tick labels only on every 3rd row (your "seats" rows)
#2 instead of 0 or 1 so year label is on top of row entry
tick_vals = np.arange(2, len(compPlot), 3)
tick_text = [str(y) for y in electionYears]
fig = go.Figure() #create plotly figure element
Create primary filtering arrays for votes/seats rows:
#meta["rowType"] should be one of: "spacer", "votes", "seats"
row_type = meta["row_type"].to_numpy()
votesMask = (row_type == "Vote Share")
seatsMask = (row_type == "Seat Share")
bothMask = votesMask | seatsMask # spacer rows remain hidden anyway
#precompute masked x arrays per party
xAll = [compPlot[c].where(bothMask, None).to_numpy() for c in compPlot.columns]
xVotes = [compPlot[c].where(votesMask, None).to_numpy() for c in compPlot.columns]
xSeats = [compPlot[c].where(seatsMask, None).to_numpy() for c in compPlot.columns]
Create coalition-based filtering system:
xCoalition = [] #create dropdown menu options for coalitions
for party in compPlot.columns:
partyCoalitionMask = np.zeros(len(compPlot), dtype=bool)
#for each row, check if the party was in coalition for that year
for idx, row_meta in meta.iterrows():
year = row_meta["year"]
if year in coalitionYears and party in coalitionYears[year]:
partyCoalitionMask[idx] = bothMask[idx]
xCoalition.append(compPlot[party].where(partyCoalitionMask, None).to_numpy())
Refine chancellor dropdown — this fixes an issue arising from selecting "coalition only" after having selected a chancellor:
n_traces = len(compPlot.columns)
#helper: indices (rows) belonging to certain election years
def RowsForYears(years):
idxs = []
for i, yr in enumerate(electionYears):
if yr in years:
base = i * 3
idxs.extend([base, base+1, base+2]) # spacer, votes, seats
return idxs
#precompute chancellor-specific arrays for all menus
chancellorArrays = {}
for chancellor, years in chancellorNames.items():
yearMask = meta["year"].isin(years).to_numpy()
#create masks for proper button functions
bothChancMask = bothMask & yearMask
votesChancMask = votesMask & yearMask
seatsChancMask = seatsMask & yearMask
#coalition mask (full length)
coalition_ch = np.zeros(len(compPlot), dtype=bool)
for idx, row_meta in meta.iterrows():
yr = row_meta["year"]
if yr in coalitionYears:
#only mark rows that belong to this chancellor and are votes/seats
if (yr in years) and (row_meta["row_type"] in ("Vote Share", "Seat Share")):
coalition_ch[idx] = True
#build x arrays per trace
xAll_ch = [compPlot[c].where(bothChancMask, None).to_numpy() for c in compPlot.columns]
xvotesChancMask = [compPlot[c].where(votesChancMask, None).to_numpy() for c in compPlot.columns]
xseatsChancMask = [compPlot[c].where(seatsChancMask, None).to_numpy() for c in compPlot.columns]
#coalition per trace must be party-aware per year
xCoalition_ch = []
for party in compPlot.columns:
partyMask = np.zeros(len(compPlot), dtype=bool)
for idx, row_meta in meta.iterrows():
yr = row_meta["year"]
if yr in years and yr in coalitionYears and party in coalitionYears[yr]:
partyMask[idx] = bothMask[idx] # votes/seats rows only
xCoalition_ch.append(compPlot[party].where(partyMask, None).to_numpy())
#y-axis zoom + ticks for those years (use the seats row of each year: base+2)
idxs = RowsForYears(years)
lo, hi = min(idxs), max(idxs)
tick_vals_ch = [i*3 + 2 for i, yr in enumerate(electionYears) if yr in years]
tick_text_ch = [str(yr) for yr in electionYears if yr in years]
chancellorArrays[chancellor] = dict(
xAll=xAll_ch, xVotes=xvotesChancMask, xSeats=xseatsChancMask, xCoalition=xCoalition_ch,
tickvals=tick_vals_ch, ticktext=tick_text_ch,
yrange=[lo - 0.5, hi + 0.5],
height= 1000
)
Add dropdown for each chancellor:
chancellorDropdown = []
#default menu including all chancellors
chancellorDropdown.append(
dict(
label="Alle Kanzler",
method="update",
args=[
{"x": xAll, "y": [y] * n_traces},
{
"yaxis.tickvals": tick_vals,
"yaxis.ticktext": tick_text,
"yaxis.range": [-0.5, len(compPlot) - 0.5],
"title.text": "<b>German Federal Elections: Seat Share vs. Vote Share - All Years</b>",
"height": 1000,
#set menu button args back to global arrays
"updatemenus[0].buttons[0].args": [{"x": xAll}],
"updatemenus[0].buttons[1].args": [{"x": xVotes}],
"updatemenus[0].buttons[2].args": [{"x": xSeats}],
"updatemenus[2].buttons[0].args": [{"x": xAll}],
"updatemenus[2].buttons[1].args": [{"x": xCoalition}],
}
]
)
)
#add one option per chancellor
for chancellor in chancellorNames.keys():
A = chancellorArrays[chancellor]
chancellorDropdown.append(
dict(
label=chancellor,
method="update",
args=[
{"x": A["xAll"], "y": [y] * n_traces},
{
"yaxis.tickvals": A["tickvals"],
"yaxis.ticktext": A["ticktext"],
"yaxis.range": A["yrange"],
"title.text": f"<b>German Federal Elections: Seat Share vs. Vote Share - {chancellor}</b>",
"height": 1000,
#rewrite other menus so subsequent clicks use the same chancellor subset
"updatemenus[0].buttons[0].args": [{"x": A["xAll"]}],
"updatemenus[0].buttons[1].args": [{"x": A["xVotes"]}],
"updatemenus[0].buttons[2].args": [{"x": A["xSeats"]}],
"updatemenus[2].buttons[0].args": [{"x": A["xAll"]}],
"updatemenus[2].buttons[1].args": [{"x": A["xCoalition"]}],
}
]
)
)
Create all dropdown and button menus:
fig.update_layout(
updatemenus=[
#data type selector (Alle/Vote Sharee/Seat Sharee)
dict(
type="buttons",
direction="right",
x=0.0, xanchor="left",
y=1.07, yanchor="bottom",
pad = {"t":10,"b":0},
buttons=[
dict(
label="All",
method="restyle",
args=[{"x": xAll}],
),
dict(
label="Vote Share Only",
method="restyle",
args=[{"x": xVotes}],
),
dict(
label="Seat Share Only",
method="restyle",
args=[{"x": xSeats}],
),
],
),
#chancellor selector dropdown
dict(
type="dropdown",
direction="right",
x=0, xanchor="left",
y=1, yanchor="bottom",
pad = {"t":0,"b":10},
buttons=chancellorDropdown,
active=0,
),
#coalition filter button
dict(
type="buttons",
direction="right",
x=0.41, xanchor="left",
y=1.07, yanchor="bottom",
pad = {"t":10,"b":0},
active=0,
buttons=[
dict(
label="All Parties",
method="restyle",
args=[{"x": xAll}],
),
dict(
label="Coalition Parties Only",
method="restyle",
args=[{"x": xCoalition}],
),
],
)
],
)
Add one stacked horizontal bar trace per party:
#add one stacked trace per party (column)
for i, party in enumerate(compPlot.columns):
fig.add_trace(
go.Bar(
y=y,
x=xAll[i],
orientation="h",
name=party,
visible=True,
marker=dict(color=partyColor.get(party, "#CCCCCC")),
#richer hover: year + whether it's votes or seats
customdata=np.stack([meta["year"], meta["row_type"]], axis=-1),
hovertemplate=(
"%{customdata[1]}<br>"
f"{party}<br>"
"Share: %{x:.2f}%<extra></extra>"
),
)
)
Add final styling elements, axis labels and display the chart:
#absolute majority line at 50%
fig.add_vline(x=50, line_width=1, line_color="black")
fig.update_layout(
barmode="stack",
title="<b>German Federal Elections: Seat Share vs. Vote Share</b>",
xaxis_title="<b>Share (%)</b>",
yaxis_title="",
xaxis=dict(range=[0, 100]),
yaxis=dict(
tickmode="array",
tickvals=tick_vals,
ticktext=tick_text,
range=[-0.5, len(compPlot) - 0.5],
),
legend_title="Party",
bargap=0,
plot_bgcolor="#FfFffF",
hovermode="closest",
height=1000,
margin=dict(t=200),
)
fig.update_layout(legend_traceorder="normal")
fig.show()
Export chart:
# fig.write_html("bundestagswahlen_chart.html")
The discrepancy DataFrame is plotted as a line chart for the major parties. The values are inverted to show the seats gained relative to vote share:
discrepancy.sample(3)
discFig = px.line(-discrepancy[discParties]) #inverse to show seats gained
#discParties is limited to (former) governing or high share parties
for trace in discFig.data: #loop through plot columns
if trace.name in partyColor:
#match column/party name and get color coding from dict
trace.line.color = partyColor[trace.name]
discFig.update_traces(line=dict(width=3),
hovertemplate =
"<b>%{y:.2f}%</b>")
discFig.update_yaxes(
zeroline=True,
zerolinewidth=2,
zerolinecolor='rgba(0, 0, 0, 0.5)')
discFig.update_layout(
hovermode="x unified",
title="<b>Diskrepanz between Vote- und Seat Share</b>",
yaxis_title="<b>Discrepancy (%)</b>",
xaxis_title="<b>Election Year</b>",
xaxis=dict(range=[1948, 2026], unifiedhovertitle=dict(text="<b>Discrepancy in Election Year %{x}</b>")),
yaxis=dict(range=[-4, 8]),
legend_title="<b>Party</b>",
plot_bgcolor="#ffffff",
height=700,
margin=dict(t=100),
)
discFig.show()
Export chart:
discFig.write_html("Discrepancyen_chart.html")
The Gallagher Index is computed from the squared discrepancy values and plotted with color-coded background bands indicating proportionality levels:
discrepancyGallagher = discrepancy**2
discrepancyGallagher['Gallagher Score'] = np.sqrt((discrepancyGallagher.sum(axis=1))*.5)
discrepancyGallagher.head(3)
gallFig = px.line(discrepancyGallagher["Gallagher Score"])
#prevent stacking of hrect shapes from rerunning graph
gallFig.layout.shapes = ()
gallFig.update_traces(line=dict(width=3),
hovertemplate = '<b>%{x} | %{y:.2f}%</b><extra></extra>',
line_color = 'white')
gallFig.update_yaxes(tickmode = 'linear',dtick = 2)
#hex values taken from Wikipedia shading for Gallgher Index
gallFig.add_hrect(y0=0, y1=1, fillcolor="#0c3091",
opacity=1, layer="below", line_width=0)
gallFig.add_hrect(y0=1, y1=3, fillcolor="#2f5cd5",
opacity=1, layer="below", line_width=0)
gallFig.add_hrect(y0=3, y1=5, fillcolor="#6bd2df",
opacity=1, layer="below", line_width=0)
gallFig.add_hrect(y0=5, y1=7, fillcolor="#c3eded",
opacity=1, layer="below", line_width=0)
gallFig.update_layout(
hovermode="closest",
title="<b>Gallagher Index nach Election Year</b>",
yaxis_title="<b>Index (%)</b>",
xaxis_title="<b>Election Year</b>",
xaxis=dict(range=[1948, 2026], showgrid=False),
yaxis=dict(range=[0, 7]),
height=600,
showlegend=False,
)
gallFig.show()
Export chart:
discFig.write_html("gallgher_chart.html")
The seat conversion rate (seat share / vote share × 100) is displayed as a boxplot per party, showing the distribution across all elections in which a party was represented:
seatConv.sample(3)
orderedCols = [col for col in partyColor.keys() if col in seatConv.columns and col not in ["Sonstige"]]
seatConv = seatConv[orderedCols]
boxFig = go.Figure()
for col in orderedCols:
color = partyColor[col]
boxFig.add_trace(go.Box(
y=seatConv[col].dropna(),
name=col,
marker_color=color,
line_color=color,
boxpoints="all",
pointpos=0,
text=seatConv[col].dropna().index.astype(str),
hovertemplate='<b>%{text} | %{y:.2f}</b><extra></extra>'
))
boxFig.update_traces(
yhoverformat=".2f"
)
boxFig.update_layout(
hovermode="closest",
title="<b>Conversion of Vote Share to Seat Share</b>",
yaxis_title="<b>Conversion RaTe (%)</b>",
xaxis_title="<b>Party</b>",
yaxis=dict(range=[20, 140]),
plot_bgcolor="#ffffff",
height=600,
showlegend=False,
)
boxFig.show()
Export chart:
boxFig.write_html("boxplot_chart.html")