EN | DE

German Federal Elections: Data Analysis and Visualization


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:

  • Direct difference between seats obtained and votes received
  • Gallagher-Index (Least Squares Index) as a quadratic deviation measure
  • Direct conversion rate from vote share to seat share

▶ How to interact with these charts:

All charts on this page were built with Plotly and are fully interactive. Here is a quick overview of the available controls:

  • Hover — move your cursor over any data point or bar segment to reveal a tooltip with detailed values.
  • Zoom — click and drag a rectangle over any area of a chart to zoom into that region.
  • Pan — after zooming in, hold and drag to pan across the chart.
  • Reset view — double-click anywhere on the chart to reset the zoom back to the default view.
  • Toggle series — click any item in the legend to hide or show that data series. Double-click a legend item to isolate it and hide all others.
  • Download — hover over the chart and click the camera icon (📷) in the top-right toolbar to download the current view as a PNG.
  • Dropdown menus & buttons — some charts include custom filter buttons (e.g. Vote Sharee / Seat Sharee) or dropdown selectors (e.g. by chancellor). These update the displayed data without reloading the page.

1. Overview: Seat Share and Vote Share

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.


2. Direkte Differenz zwischen Stimm- und Seat Share

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.


3. Gallagher Index

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.


4. Conversion Rate from Vote to Seat Shares

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.


Summary

The combined analysis of all four graphics produces a consistent picture:

  1. The narrow non-entry of several smaller parties increases overall disproportionality and redistributes votes with a structural bias toward larger parties.
  2. The Gallagher Index quantifies this distortion at the system level and identifies 2025 as an election with elevated disproportionality. Overall, however, Germany remains within the international average of disproportionality.
  3. The conversion rate highlights the mandate bonus of larger parties and the heightened volatility of smaller parties in the context of the electoral threshold.

In light of the narrowly missed five-percent threshold by FDP and BSW, it becomes evident that several tens of thousands of votes can substantially alter parliamentary majority structures. The 2025 federal election thus serves as an empirical example of the strong leverage effect of institutional threshold rules in competitive multi-party elections.




Sources

  1. Bundeswahlleiterin. (2025). Wahl zum 21. Deutschen Bundestag – Ergebnisse (Bund – Zweitstimmen). https://www.bundeswahlleiterin.de/bundestagswahlen/2025/ergebnisse/bund-99.html
  2. Gallagher, M. (o. J.). Election indices: Third edition [PDF]. Trinity College Dublin, Department of Political Science. https://www.tcd.ie/Political_Science/about/people/michael_gallagher/ElSystems/Docts/ElectionIndices.pdf
  3. Gesetz über die Wahl des Deutschen Bundestages [Bundeswahlgesetz], § 4. (Stand 2025). https://www.gesetze-im-internet.de/bwahlg/__4.html
▶ Python Documentation — click to expand

Python Documentation

All operations were performed in a Google Colab environment


Import Libraries

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

Reference Lists and Dictionaries

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"]

Import Data

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)

Clean Up Dataframe

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)

Create Dataframes for Analysis

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')

Vote- and Seat Percentage Comparison

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')

Chart 1: General Overview — Plot and Menu Creation

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")

Chart 2: Vote and Seat Share Discrepancy

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")

Chart 3: Gallagher Index

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")

Chart 4: Seat Conversion Rate

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")