Titel: Mietpreise München Description: Interaktive Mietpreis-Heatmap der 25 Stadtbezirke Dataset: https://datengartln.de/datasets/detail/cef955ef-7242-4e5d-bb5f-697df6bbe224/ Landing: true
Mietpreise München - Eine interaktive Heatmap¶
München ist eine der teuersten Städte Deutschlands, wenn es um Wohnraum geht. Aber wie stark unterscheiden sich die Mietpreise eigentlich zwischen den verschiedenen Stadtbezirken? Und wo lohnt sich der Erstbezug einer Neubauwohnung im Vergleich zur Wiedervermietung?
Diese Visualisierung zeigt die durchschnittlichen Nettokaltmieten (€/m²) für alle 25 Münchner Stadtbezirke. Mit dem Slider können Sie zwischen den Jahren wechseln und die Entwicklung der Mietpreise beobachten.
Hier finden Sie:
- Zwei Karten nebeneinander: Erstvermietung (Neubau) vs. Wiedervermietung (Bestand)
- Rot-Heatmap: Je dunkler die Farbe, desto teurer der Stadtbezirk
- Interaktiver Slider zum Wechseln zwischen den Jahren
- Beim Hovern: Exakte Mietpreise für jeden Stadtbezirk
Die Daten stammen vom Statistischen Amt der Landeshauptstadt München (link zum Dataset) und basieren auf Immobilienangeboten von Immobilien Scout GmbH.
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import geopandas as gpd
import requests
import json
import warnings
import plotly.io as pio
pio.renderers.default = 'notebook'
warnings.filterwarnings('ignore')
primary_color = "#07A299"
text_color = "#282a36"
# Lade Mietpreise (enthält Stadtbezirk-Namen)
df_wohnen = pd.read_excel("https://mstatistik.muenchen.de/indikatorenatlas/export/xlsx/export_wo.xlsx", sheet_name='WOHNEN')
def prepare_mieten(indikator):
df_temp = df_wohnen[
(df_wohnen['Indikator'] == indikator) &
(df_wohnen['Ausprägung'] == 'absolut') &
(df_wohnen['Raumbezug'] != 'Stadt München')
].copy()
df_temp['sb_nummer'] = df_temp['Raumbezug'].str.split(' ').str[0].astype(int).astype(str)
df_temp['Indikatorwert'] = df_temp['Indikatorwert'].astype(str).str.replace(',', '.').astype(float)
return df_temp
df_mieten_erst = prepare_mieten('Mieten Erstvermietung')
df_mieten_wieder = prepare_mieten('Mieten Wiedervermietung')
# Extrahiere Namen aus Wohndaten
stadtbezirke = dict(zip(
df_mieten_wieder['sb_nummer'],
df_mieten_wieder['Raumbezug'].str.split(' ', n=1).str[1]
))
# Lade Geodaten
response = requests.get("https://opendata.muenchen.de/api/3/action/package_search", params={"q": "Bezirksteile"})
geojson_url = next(r['url'] for r in response.json()["result"]["results"][0]['resources'] if r['format'].upper() == 'GEOJSON')
gdf = gpd.read_file(geojson_url).to_crs(epsg=4326)
gdf['sb_nummer'] = gdf['bt_nummer'].str.split('.').str[0].astype(int).astype(str)
gdf_stadtbezirke = gdf.dissolve(by='sb_nummer', aggfunc={'flaeche_qm': 'sum'}).reset_index()
gdf_stadtbezirke['name'] = gdf_stadtbezirke['sb_nummer'].map(stadtbezirke)
geojson_data = json.loads(gdf_stadtbezirke.to_json())
df = pd.DataFrame([f['properties'] for f in geojson_data['features']])
df['display_name'] = df['name']
# Zwei Karten mit Slider
jahre = sorted(df_mieten_erst['Jahr'].unique())
initial_jahr = max(jahre)
zmin = pd.concat([df_mieten_erst['Indikatorwert'], df_mieten_wieder['Indikatorwert']]).min()
zmax = pd.concat([df_mieten_erst['Indikatorwert'], df_mieten_wieder['Indikatorwert']]).max()
def create_trace(df_mieten_data, jahr, show_colorbar=False):
df_jahr = df.merge(df_mieten_data[df_mieten_data['Jahr'] == jahr][['sb_nummer', 'Indikatorwert']], on='sb_nummer', how='left')
return go.Choroplethmapbox(
geojson=geojson_data, locations=df_jahr['sb_nummer'], z=df_jahr['Indikatorwert'],
featureidkey="properties.sb_nummer", colorscale='Reds', zmin=zmin, zmax=zmax,
marker_opacity=0.7, marker_line_width=2, marker_line_color='white',
text=df_jahr['display_name'], customdata=df_jahr['Indikatorwert'],
hovertemplate='<b>%{text}</b><br>Miete: %{customdata:.2f} €/m²<extra></extra>',
showscale=show_colorbar, colorbar=dict(title='€/m²', x=1.02, len=0.7) if show_colorbar else None
)
fig = make_subplots(rows=1, cols=2, subplot_titles=('Erstvermietung (Neubau)', 'Wiedervermietung (Bestand)'),
specs=[[{'type': 'mapbox'}, {'type': 'mapbox'}]], horizontal_spacing=0.02)
fig.add_trace(create_trace(df_mieten_erst, initial_jahr), row=1, col=1)
fig.add_trace(create_trace(df_mieten_wieder, initial_jahr, show_colorbar=True), row=1, col=2)
frames = [
go.Frame(data=[create_trace(df_mieten_erst, j), create_trace(df_mieten_wieder, j, show_colorbar=True)],
name=str(j), layout=go.Layout(title={'text': f'Mietpreise München {j}', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 24, 'color': text_color}}))
for j in jahre
]
fig.update_layout(
title={'text': f'Mietpreise München {initial_jahr}', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 24, 'color': text_color}},
height=600, margin={"r": 0, "t": 80, "l": 0, "b": 100}, paper_bgcolor='rgba(0,0,0,0)',
sliders=[{'active': jahre.index(initial_jahr), 'y': 0, 'x': 0.5, 'xanchor': 'center', 'currentvalue': {'visible': False},
'len': 0.8, 'bgcolor': '#f9fafb', 'bordercolor': '#d1d5db', 'borderwidth': 1,
'steps': [{'args': [[str(j)], {'frame': {'duration': 300, 'redraw': True}, 'mode': 'immediate'}], 'label': str(j), 'method': 'animate'} for j in jahre]}]
)
fig.update_mapboxes(style="carto-positron", center=dict(lat=48.15, lon=11.57), zoom=9.5)
fig.frames = frames
fig.show()
# Statistik: Top 5 teuerste und günstigste Stadtbezirke
df_erst_2024 = df_mieten_erst[df_mieten_erst['Jahr'] == max(jahre)][['sb_nummer', 'Indikatorwert']].rename(columns={'Indikatorwert': 'Erstvermietung'})
df_wieder_2024 = df_mieten_wieder[df_mieten_wieder['Jahr'] == max(jahre)][['sb_nummer', 'Indikatorwert']].rename(columns={'Indikatorwert': 'Wiedervermietung'})
df_stats = df[['sb_nummer', 'name']].merge(df_erst_2024, on='sb_nummer').merge(df_wieder_2024, on='sb_nummer')
# Top 5 nach Erstvermietung
erst_top5 = df_stats.nlargest(5, 'Erstvermietung')[['name', 'Erstvermietung']].reset_index(drop=True)
erst_bottom5 = df_stats.nsmallest(5, 'Erstvermietung')[['name', 'Erstvermietung']].reset_index(drop=True)
# Top 5 nach Wiedervermietung
wieder_top5 = df_stats.nlargest(5, 'Wiedervermietung')[['name', 'Wiedervermietung']].reset_index(drop=True)
wieder_bottom5 = df_stats.nsmallest(5, 'Wiedervermietung')[['name', 'Wiedervermietung']].reset_index(drop=True)
# Kombiniere zu einer Tabelle
df_top5 = pd.concat([
erst_top5.rename(columns={'name': 'Neuvermietung', 'Erstvermietung': '€/m²'}),
wieder_top5.rename(columns={'name': 'Wiedervermietung', 'Wiedervermietung': '€/m² '})
], axis=1)
df_top5.index = df_top5.index + 1
df_bottom5 = pd.concat([
erst_bottom5.rename(columns={'name': 'Neuvermietung', 'Erstvermietung': '€/m²'}),
wieder_bottom5.rename(columns={'name': 'Wiedervermietung', 'Wiedervermietung': '€/m² '})
], axis=1)
df_bottom5.index = df_bottom5.index + 1
# Styling für schmalere Tabellen
style = '<style>table {width: auto !important;} td, th {white-space: nowrap; padding: 4px 12px !important;}</style>'
from IPython.display import HTML
display(HTML(style))
print(f"Mietpreise {max(jahre)}\n")
print("Top 5 teuerste:")
display(df_top5)
print("\nTop 5 günstigste:")
display(df_bottom5)
Mietpreise 2024 Top 5 teuerste:
| Neuvermietung | €/m² | Wiedervermietung | €/m² | |
|---|---|---|---|---|
| 1 | Altstadt - Lehel | 28.83 | Altstadt - Lehel | 25.20 |
| 2 | Schwabing - West | 28.26 | Schwabing - Freimann | 23.52 |
| 3 | Maxvorstadt | 28.24 | Ludwigsvorstadt - Isarvorstadt | 23.43 |
| 4 | Schwabing - Freimann | 27.92 | Maxvorstadt | 23.07 |
| 5 | Neuhausen - Nymphenburg | 27.25 | Schwabing - West | 22.89 |
Top 5 günstigste:
| Neuvermietung | €/m² | Wiedervermietung | €/m² | |
|---|---|---|---|---|
| 1 | Aubing - Lochhausen - Langwied | 21.70 | Aubing - Lochhausen - Langwied | 18.68 |
| 2 | Ramersdorf - Perlach | 22.42 | Feldmoching - Hasenbergl | 18.72 |
| 3 | Feldmoching - Hasenbergl | 22.76 | Trudering - Riem | 19.32 |
| 4 | Allach - Untermenzing | 23.12 | Hadern | 19.34 |
| 5 | Berg am Laim | 23.23 | Berg am Laim | 19.65 |
Wohndauer - Wie lange bleiben Münchner an ihrer Adresse?¶
Die durchschnittliche Wohndauer zeigt interessante Trends über die Zeit. Nach Jahren sinkender Wohndauer (mehr Umzüge) steigt sie seit 2017 wieder an - die Münchner bleiben länger in ihren Wohnungen.
# Lade Bevölkerungsdaten für Wohndauer
df_bev = pd.read_excel("https://mstatistik.muenchen.de/indikatorenatlas/export/xlsx/export_be.xlsx", sheet_name=1)
# Extrahiere Wohndauer an Adresse - STADTWEITER DURCHSCHNITT über Zeit
df_wohndauer_stadt = df_bev[
(df_bev['Indikator'] == 'Wohndauer Adresse') &
(df_bev[df_bev.columns[1]] == 'insgesamt') &
(df_bev['Raumbezug'].str.contains('Stadt München', na=False))
].copy()
df_wohndauer_stadt['Wohndauer'] = df_wohndauer_stadt['Indikatorwert'].astype(str).str.replace(',', '.').astype(float)
# Plot: Zeitliche Entwicklung der Wohndauer
fig = go.Figure()
fig.add_trace(go.Scatter(
x=df_wohndauer_stadt['Jahr'], y=df_wohndauer_stadt['Wohndauer'],
mode='lines+markers', name='Wohndauer (Jahre)',
line=dict(color=primary_color, width=3),
marker=dict(size=8),
hovertemplate='%{x}: %{y:.1f} Jahre<extra></extra>'
))
# Markiere den Wendepunkt 2016/2017
fig.add_vline(x=2016.5, line_dash="dash", line_color="gray", opacity=0.5)
fig.add_annotation(x=2016.5, y=11.3, text="Wendepunkt", showarrow=False,
font=dict(size=10, color="gray"))
fig.update_layout(
title={'text': 'Wohndauer in München 2000-2024<br><sup>Nach Jahren sinkender Wohndauer steigt sie seit 2017 wieder an</sup>',
'x': 0.5, 'xanchor': 'center', 'font': {'size': 18, 'color': text_color}},
height=450, paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
hovermode='x unified'
)
fig.update_xaxes(title_text='Jahr', gridcolor='#f0f0f0', dtick=2)
fig.update_yaxes(title_text='Wohndauer (Jahre)', gridcolor='#f0f0f0', range=[10.5, 11.5])
fig.show()
# Statistik
print(f"\n{'='*60}")
print("WOHNDAUER IN MÜNCHEN 2000-2024")
print(f"{'='*60}")
print(f"\n 2000: {df_wohndauer_stadt[df_wohndauer_stadt['Jahr']==2000]['Wohndauer'].values[0]:.1f} Jahre")
print(f" 2016: {df_wohndauer_stadt[df_wohndauer_stadt['Jahr']==2016]['Wohndauer'].values[0]:.1f} Jahre (Tiefpunkt)")
print(f" 2024: {df_wohndauer_stadt[df_wohndauer_stadt['Jahr']==2024]['Wohndauer'].values[0]:.1f} Jahre")
print(f"\nSeit 2016 steigt die durchschnittliche Wohndauer wieder an.")
============================================================ WOHNDAUER IN MÜNCHEN 2000-2024 ============================================================ 2000: 11.2 Jahre 2016: 10.8 Jahre (Tiefpunkt) 2024: 11.2 Jahre Seit 2016 steigt die durchschnittliche Wohndauer wieder an.
# Wohndauer pro Stadtbezirk - Veränderung über Zeit
df_wohndauer_bezirke = df_bev[
(df_bev['Indikator'] == 'Wohndauer Adresse') &
(df_bev[df_bev.columns[1]] == 'insgesamt') &
(~df_bev['Raumbezug'].str.contains('Stadt München', na=False))
].copy()
df_wohndauer_bezirke['sb_nummer'] = df_wohndauer_bezirke['Raumbezug'].str.split(' ').str[0].astype(int).astype(str)
df_wohndauer_bezirke['Wohndauer'] = df_wohndauer_bezirke['Indikatorwert'].astype(str).str.replace(',', '.').astype(float)
df_wohndauer_bezirke['name'] = df_wohndauer_bezirke['sb_nummer'].map(stadtbezirke)
# Berechne Veränderung 2016 vs 2024 (Wendepunkt bis heute)
df_2016 = df_wohndauer_bezirke[df_wohndauer_bezirke['Jahr'] == 2016][['sb_nummer', 'Wohndauer']].rename(columns={'Wohndauer': 'Wohndauer_2016'})
df_2024 = df_wohndauer_bezirke[df_wohndauer_bezirke['Jahr'] == 2024][['sb_nummer', 'Wohndauer', 'name']].rename(columns={'Wohndauer': 'Wohndauer_2024'})
df_change = df_2016.merge(df_2024, on='sb_nummer')
df_change['Veraenderung'] = df_change['Wohndauer_2024'] - df_change['Wohndauer_2016']
df_map = df.merge(df_change, on='sb_nummer', how='left')
# Karte: Veränderung der Wohndauer 2016-2024
fig_map = go.Figure()
fig_map.add_trace(go.Choroplethmapbox(
geojson=geojson_data, locations=df_map['sb_nummer'], z=df_map['Veraenderung'],
featureidkey="properties.sb_nummer",
colorscale=[[0, '#E74C3C'], [0.5, '#f5f5f5'], [1, '#27AE60']], # Rot-Weiß-Grün
zmid=0,
marker_opacity=0.8, marker_line_width=2, marker_line_color='white',
text=df_map['display_name'],
customdata=list(zip(df_map['Wohndauer_2016'], df_map['Wohndauer_2024'], df_map['Veraenderung'])),
hovertemplate='<b>%{text}</b><br>2016: %{customdata[0]:.1f} Jahre<br>2024: %{customdata[1]:.1f} Jahre<br>Veränderung: %{customdata[2]:+.1f} Jahre<extra></extra>',
showscale=True, colorbar=dict(title='Veränderung<br>(Jahre)', tickformat='+.1f')
))
fig_map.update_layout(
title={'text': 'Veränderung der Wohndauer 2016-2024 pro Stadtbezirk<br><sup>Grün = Menschen bleiben länger | Rot = Höhere Fluktuation</sup>',
'x': 0.5, 'xanchor': 'center', 'font': {'size': 18, 'color': text_color}},
height=550, margin={"r": 0, "t": 80, "l": 0, "b": 20}, paper_bgcolor='rgba(0,0,0,0)',
mapbox=dict(style="carto-positron", center=dict(lat=48.15, lon=11.57), zoom=9.5)
)
fig_map.show()
# Statistik: Top 5 Anstieg und Rückgang
df_sorted = df_change.sort_values('Veraenderung', ascending=False)
# Top 5 Anstieg (Menschen bleiben länger)
anstieg_top5 = df_sorted.head(5)[['name', 'Wohndauer_2016', 'Wohndauer_2024', 'Veraenderung']].reset_index(drop=True)
anstieg_top5.columns = ['Stadtbezirk', '2016', '2024', 'Δ']
# Top 5 Rückgang (höhere Fluktuation)
rueckgang_top5 = df_sorted.tail(5).sort_values('Veraenderung')[['name', 'Wohndauer_2016', 'Wohndauer_2024', 'Veraenderung']].reset_index(drop=True)
rueckgang_top5.columns = ['Stadtbezirk', '2016', '2024', 'Δ']
# Index bei 1 starten
anstieg_top5.index = anstieg_top5.index + 1
rueckgang_top5.index = rueckgang_top5.index + 1
print("Veränderung der Wohndauer 2016-2024 (in Jahren)\n")
print("Top 5 Anstieg (Menschen bleiben länger):")
display(anstieg_top5)
print("\nTop 5 Rückgang (höhere Fluktuation):")
display(rueckgang_top5)
Veränderung der Wohndauer 2016-2024 (in Jahren) Top 5 Anstieg (Menschen bleiben länger):
| Stadtbezirk | 2016 | 2024 | Δ | |
|---|---|---|---|---|
| 1 | Schwanthalerhöhe | 9.1 | 10.6 | 1.5 |
| 2 | Ludwigsvorstadt - Isarvorstadt | 8.3 | 9.7 | 1.4 |
| 3 | Obergiesing - Fasangarten | 9.9 | 10.9 | 1.0 |
| 4 | Trudering - Riem | 10.7 | 11.7 | 1.0 |
| 5 | Schwabing - Freimann | 9.7 | 10.7 | 1.0 |
Top 5 Rückgang (höhere Fluktuation):
| Stadtbezirk | 2016 | 2024 | Δ | |
|---|---|---|---|---|
| 1 | Aubing - Lochhausen - Langwied | 12.8 | 10.5 | -2.3 |
| 2 | Allach - Untermenzing | 12.8 | 12.1 | -0.7 |
| 3 | Thalkirchen - Obersendling - Forstenried - Für... | 11.3 | 11.1 | -0.2 |
| 4 | Pasing - Obermenzing | 11.4 | 11.3 | -0.1 |
| 5 | Bogenhausen | 11.4 | 11.3 | -0.1 |
# Konvergenz-Analyse: Gleichen sich die Stadtbezirke an?
df_konvergenz = df_wohndauer_bezirke.groupby('Jahr')['Wohndauer'].agg(['std', 'min', 'max', 'mean']).reset_index()
df_konvergenz['Spannweite'] = df_konvergenz['max'] - df_konvergenz['min']
fig = go.Figure()
# Standardabweichung
fig.add_trace(go.Scatter(
x=df_konvergenz['Jahr'], y=df_konvergenz['std'],
mode='lines+markers', name='Standardabweichung',
line=dict(color=primary_color, width=3),
marker=dict(size=8),
hovertemplate='%{x}: %{y:.2f} Jahre<extra></extra>'
))
fig.update_layout(
title={'text': 'Konvergenz der Wohndauer zwischen Stadtbezirken<br><sup>Sinkende Standardabweichung = Bezirke gleichen sich an</sup>',
'x': 0.5, 'xanchor': 'center', 'font': {'size': 18, 'color': text_color}},
height=400, paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)',
hovermode='x unified'
)
fig.update_xaxes(title_text='Jahr', gridcolor='#f0f0f0', dtick=2)
fig.update_yaxes(title_text='Standardabweichung (Jahre)', gridcolor='#f0f0f0')
fig.show()
# Statistik
print("Konvergenz-Analyse: Standardabweichung der Wohndauer zwischen Bezirken\n")
df_konv_stats = df_konvergenz[df_konvergenz['Jahr'].isin([2000, 2016, 2024])][['Jahr', 'std', 'min', 'max', 'Spannweite']].copy()
df_konv_stats.columns = ['Jahr', 'Std.abw.', 'Min', 'Max', 'Spannweite']
df_konv_stats = df_konv_stats.set_index('Jahr')
display(df_konv_stats.round(2))
Konvergenz-Analyse: Standardabweichung der Wohndauer zwischen Bezirken
| Std.abw. | Min | Max | Spannweite | |
|---|---|---|---|---|
| Jahr | ||||
| 2000 | 1.07 | 9.0 | 13.2 | 4.2 |
| 2016 | 1.25 | 8.3 | 12.8 | 4.5 |
| 2024 | 0.87 | 9.1 | 13.0 | 3.9 |
Fazit: Konvergenz statt allgemeiner Trend¶
Die Analyse zeigt: Die Stadtbezirke gleichen sich bei der Wohndauer an. Die Standardabweichung ist 2024 mit 0.87 Jahren auf einem historischen Tiefstand - niedriger als zu jedem anderen Zeitpunkt seit Beginn der Daten.
Was bedeutet das?
- In zentrale Bezirken mit traditionell kürzerer Wohndauer (wie Ludwigsvorstadt-Isarvorstadt oder Schwanthalerhöhe) wohnen die Menschen jetzt länger in ihren Wohnungen
- In den Außenbezirken mit hoher Wohndauer (wie Aubing-Lochhausen-Langwied) ziehen die Menschen mittlerweile früher aus
- Der stadtweite Anstieg seit 2016 ist daher teilweise ein Konvergenz-Effekt: Die Bezirke nähern sich einem gemeinsamen Niveau an