EN | DE

Bundestagswahlen: Data Analysis and Visualization


Die Bundestagswahl 2025 markierte einen politisch hochgradig kompetitiven Urnengang, dessen Endergebnis durch marginale Stimmenunterschiede geprägt wurde.
Zwei Parteien verfehlten die Fünf-Prozent-Hürde nur knapp: Die Freie Demokratische Partei (FDP), Mitglied der vorangegangenen Regierungskoalition, blieb mit rund 333.000 Stimmen (≈0,67 % der 49,6 Mio. gültigen Zweitstimmen) unterhalb der Sperrklausel. Dem Bündnis Sahra Wagenknecht (BSW), seit 2024 Teil der Landesregierungen in Thüringen und Brandenburg, fehlten weniger als 10.000 Stimmen (≈0,02 %) zum Einzug in den Deutschen Bundestag. Mit dem Einzug einer oder beider Parteien hätte die gegenwärtige Regierungskoalition ihre absoluten Mehrheit verfehlt.1
Vor diesem Hintergrund ist das vorliegende Analyseprojekt entstanden. Das Ziel war es, auf Basis der amtlichen Wahldaten der Bundeswahlleiterin die Diskrepanz zwischen Stimmenanteilen und Sitzanteilen systematisch zu untersuchen. Neben der direkten Gegenüberstellung wurden folgende Metriken untersucht:

  • Direkte Differenz zwischen erreichten Sitzen und Stimmen
  • Gallagher-Index (Methode der kleinsten Quadrate) als quadratische Abweichungsmetrik
  • Direkte Konvertierungsratevon Stimmanteilen zu Sitzanteilen

▶ Anleitung zur Benutzung der Grafiken:

Alle Grafiken auf dieser Seite wurden mit Plotly erstellt und sind interaktiv. Hier ist eine Übersicht ihrer Funktionen:

  • Download — klicke auf das Kamera Symbol (📷) um die gewählte Ansicht als PNG Datei herunterzuladen.
  • Hover — bewege den Mauszeiger über Datenpunkte oder Segmente um detailiterte Werte angezeigt zu bekommen.
  • Zoom — klicke und ziehe ein Rechteck über eine Fläche der Grafil um diese zu vergrößern.
  • Pan — halte den Mausklick nach dem Vergrößern um über die Grafik zu schwenken.
  • Reset view — benutze einen dopelten Mausklick in der Grafik oder das (⌂) Haus Symbol um dei Grafik zurückzusetzen.
  • Legendeneintrag wählen — klicke auf einen Legendeneintrag um den entsprechenden Eintrag zu verbergen pder anzuzeigen. Ein doppelter Mausklick verbirgt oder zeigt alle anderen Einträge an.
  • Listenfelder und Tasten — die Hauptgrafik enthält Filtertasten und Listen um nach ausgewählten Kategorien zu filtern.

1. Übersicht: Sitzanteil und Stimmenanteil

Die Übersichtsgrafik stellt für jede Bundestagswahl seit 1949 die Stimmenanteile (unterer Balken) und Sitzanteile (oberer Balken) gewählter Parteien gegenüber. Eine vertikale Referenzlinie markiert die 50-Prozent-Schwelle, die benötigt wird, um eine mehrheitsfähige Regierung zu bilden.
Die Grafik visualisiert die Entwicklung von der dominanten Stellung der Volksparteien (CDU/CSU und SPD) in den frühen Jahrzehnten hin zu einem zunehmend fragmentierten Mehrparteiensystem seit den 1990er Jahren. Die FDP, die seit 1949 regelmäßig den Volksparteien bei der Erreichung einer absoluten Mehrheit half, verpasste in zwei der letzten vier Bundestagswahlen den Einzug ins Parlament. Gleichzeitig traten ab 2013 neue Akteure (AfD, BSW, weitere Kleinparteien) deutlicher in Erscheinung.
Die Grafik stellt die Asymmetrie zwischen Stimmen- und Sitzanteilen direkt gegenüber und verdeutlicht den Effekt der Fünf-Prozent-Hürde. In Wahlen mit knappen Mehrheitsverhältnissen, wie 2025, verstärkt sich dieser Effekt erheblich. Stimmen für Parteien unterhalb der Fünf-Prozent-Hürde (z. B. FDP und BSW) werden bei der Mandatsverteilung unter erfolgreichen Parteien aufgeteilt und erlauben Regierungskoalitionen mit engen Mehrheiten.


2. Direkte Differenz zwischen Stimm- und Sitzanteil

Die zweite Grafik visualisiert die zeitliche Entwicklung der Differenz zwischen Sitzanteil und Stimmenanteil für ausgewählte Parteien (CDU/CSU, SPD, FDP, GRÜNE, AfD). Positive Werte signalisieren eine Überrepräsentation im Parlament, negative Werte eine Unterrepräsentation.
Historisch zeigen insbesondere CDU/CSU und SPD regelmäßig positive Abweichungen, während kleinere Parteien geringere oder schwankende Differenzen aufweisen. Auffällig ist die starke negative Abweichung der Grünen im Jahr 1990 sowie deutliche positive Ausschläge bei CDU/CSU in einzelnen Wahlperioden (z. B. 2013).
Die Differenzanalyse offenbart zwei zentrale Mechanismen: Erstens führt die Sperrklausel zu systematischen Überrepräsentationen der etablierten Parteien, je mehr Parteien knapp unter fünf Prozent verbleiben. Zweitens steigt die Varianz der Differenzen mit zunehmender Fragmentierung des Parteiensystems. Besonders 2013 zeigt, wie stark der Ausschluss der FDP die Sitzverteilung zugunsten der größeren Parteien verschob. Das äußerst knappe Scheitern von FDP und BSW im Wahljahr 2025 erzeugte eine disproportionale Mandatsverteilung zugunsten der fünf im Parlament vertretenen Parteien (mit Ausnahme des SSW). Die gezeigten Differenzen sind das direkte Resultat der Beschränkungen des Wahlsystems.


3. Gallagher Index

Der Gallagher-Index misst die Disproportionalität zwischen Stimm- und Sitzanteilen auf einer Skala von 0 (kaum Disproportionalität, z.B. 0,64 Schweden 2022) bis 20+ (sehr hohe Disproportionalität, z.B. 23,64 Vereinigtes Königreich 2024).2
Für deutsche Bundestagswahlen zeigt die Zeitreihe vergleichsweise niedrige Werte in den 1970er und frühen 1980er Jahren. Alle anderen Wahljahre erreichen mittlere bis relativ leicht erhöhte Werte. Die eingefärbten Schwellenbereiche verdeutlichen unterschiedliche Intensitätsstufen der Disproportionalität.
Quadratische Abweichungsmaße gewichten größere Differenzen überproportional stark. Entsprechend spiegeln hohe Gallagher-Werte Wahljahre wider, in denen strukturelle Verzerrungen besonders ausgeprägt sind. Schon 1949, vor Einführung der Fünf-Prozent-Hürde auf Bundesebene, wurde der zweithöchste Indexwert gemessen. 2013 markiert eine signifikante Steigung infolge des FDP-Scheiterns. 2025 lag erneut in einem erhöhten Bereich, bleibt jedoch zwei Prozentpunkte hinter dem Höchstwert zurück. Damit untermalt der Index die Ergebnisse der Differenzanalyse und zeigt, dass je mehr Stimmen unterhalb der Sperrklausel verbleiben, desto stärker die Sitzverteilung vom Stimmenverhältnis abweicht. Für 2025 bedeutet der Nichteinzug von FDP und BSW, die zusammen mit den sonstigen Parteien 13 % der Wählerstimmen ausmachten eine erneut erhöhte Disproportionalität im Gallagher Index.


4. Konvertierungsrate von Stimm- zu Sitzanteilen

Der Boxplot zeigt die Konvertierungsrate von Stimmen zu Bundestagssitzen je Partei über mehrere Wahlperioden. Werte oberhalb von 100 % signalisieren eine überproportionale Mandatsumsetzung, Werte darunter eine Unterkonvertierung. Wahljahre, in denen Parteien an der Sperrklausel scheiterten, wurden in der Grafik nicht berücksichtigt.
Die Grafik visualisiert, dass die Volksparteien eine konstante Konvertierungsrate von über 100% aufweisen jedoch 120% nicht überschreiten. Dagegen zeigen kleinere Parteien stärkere Streuungen und im Durchschnitt etwas niedrigere Raten. Extreme Ausreißer und durchschnittlich niedrigere Konvertierungsraten sind insbesondere bei Parteien mit zeitweisem Scheitern an der Sperrklausel sichtbar.
Der Südschleswigsche Wählerverband (SSW) ist als Vertretung der dänischen Minderheit von der Fünf-Prozent-Hürde befreit.3 Dies ermöglichte ihm 1949 (vor Einführung der Hürde), 2021 und 2025 jeweils einen Sitz im Bundestag zu erreichen. Das einzige Mandate führt aufgrund des sehr kleinen Stimmenanteils im Vergleich zur Gesamtheit des Bundestages zu statistischen Ausreißern.


Zusammenfassung

Die kombinierte Betrachtung aller vier Grafiken ergibt ein konsistentes Bild:

  1. Der knappe Nichteinzug mehrerer kleinerer Parteien erhöht die Gesamtdisproportionalität und verteilt Stimmen mit einer Tendenz für größere Parteien um.
  2. Der Gallagher-Index quantifiziert diese Verzerrung auf Systemebene und weist 2025 als Wahl mit erhöhter Disproportionalität aus. Insgesamt liegt Deutschland jedoch im internationalen Durchschnitt der Disproportionalität.
  3. Die Konvertierungsrate verdeutlicht den Mandatsbonus größerer Parteien sowie die erhöhte Volatilität kleinerer Parteien im Kontext der Sperrklausel.

Im Lichte der eingangs geschilderten knappen Verfehlung der Fünf-Prozent-Hürde durch FDP und BSW wird ersichtlich, dass wenige zehntausend Stimmen die parlamentarische Mehrheitsstruktur substanziell verändern können. Die Bundestagswahl 2025 ist damit ein empirisches Beispiel für die hohe Hebelwirkung institutioneller Schwellenregeln in kompetitiven Wahlen in einem Mehrparteiensystem.




Quellen

  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": "Stimmanteil"})
    #3rd seats row
    seatPerc = seatShare.loc[y].copy()
    rows.append(seatPerc)
    meta.append({"year": y, "row_type": "Sitzanteil"})

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 == "Stimmanteil")
seatsMask = (row_type == "Sitzanteil")
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 ("Stimmanteil", "Sitzanteil")):
                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>Bundestagswahlen: Sitzanteil vs. Stimmanteil - Alle Jahre</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>Bundestagswahlen: Sitzanteil vs. Stimmanteil - {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/Stimmanteile/Sitzanteile)
        dict(
            type="buttons",
            direction="right",
            x=0.0, xanchor="left",
            y=1.07, yanchor="bottom",
            pad = {"t":10,"b":0},
            buttons=[
                dict(
                    label="Alle",
                    method="restyle",
                    args=[{"x": xAll}],
                ),
                dict(
                    label="Nur Stimmanteile",
                    method="restyle",
                    args=[{"x": xVotes}],
                ),
                dict(
                    label="Nur Sitzanteile",
                    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="Alle Parteien",
                    method="restyle",
                    args=[{"x": xAll}],
                ),
                dict(
                    label="Nur Koalitionsparteien",
                    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>"
                "Anteil: %{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>Bundestagswahlen: Sitzanteil vs. Stimmanteil</b>",
    xaxis_title="<b>Anteil (%)</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="Partei",
    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 zwischen Stimm- und Sitzanteil</b>",
    yaxis_title="<b>Diskrepanz (%)</b>",
    xaxis_title="<b>Wahljahr</b>",
    xaxis=dict(range=[1948, 2026], unifiedhovertitle=dict(text="<b>Diskrepanz im Wahljahr %{x}</b>")),
    yaxis=dict(range=[-4, 8]),
    legend_title="<b>Partei</b>",
    plot_bgcolor="#ffffff",
    height=700,
    margin=dict(t=100),
)

discFig.show()

Export chart:


discFig.write_html("diskrepanzen_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>Wahljahr %{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 Wahljahr</b>",
    yaxis_title="<b>Index (%)</b>",
    xaxis_title="<b>Wahljahr</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>Sitzkonvertierung von Stimmanteil zu Sitzanteil</b>",
    yaxis_title="<b>Konvertierungsrate (%)</b>",
    xaxis_title="<b>Partei</b>",
    yaxis=dict(range=[20, 140]),
    plot_bgcolor="#ffffff",
    height=600,
    showlegend=False,
)

boxFig.show()

Export chart:


boxFig.write_html("boxplot_chart.html")