Demographic Change in Danish Kommuner¶
There is increasing awareness of the recent increase of foreign nationals making Denmark their home, particularly in cities like Copenhagen, but what is the picture in the rest of the country?
Looking at just the headline figure: (16% nationally), could flatten local patterns and concentrating on the entire population potentially means that the ageing population masks interesting trends. Since the restrictions from COVID-19 were lifted, migration has altered.
We will take a look at the nuances of this picture. Since 2021:
In which kommuner is the population decreasing, increasing and staying the same?
In which kommuner are working age foreign nationals replacing the loss of working age Danes?
- Are there kommuner where the population is increasing due to the arrival of working age foreign nationals?
- Are there kommuner where working age Danes are leaving and not being replaced by foreign nationals?
Assumptions and Data Selection:-
- Working age: 18-66 years
- Time period 2025 Q3 (most recent data)
- Time period 2021 Q3 (comparing seasonal like-with-like, immediately after the pandemic)
Dansk version
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
import pandas as pd
import numpy as np
import geopandas as gpd
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap, SymLogNorm
from matplotlib.colors import TwoSlopeNorm
import matplotlib.patches as mpatches
Population Change Since 2021¶
We can see that the general trend is for people to move from the edges of the country into the bigger cities. There are some smaller kommuner that are bucking the trend.
def build_colormap():
#colour blind friendly
colors = [
(0.0, "#6B3F02"), # orangey brown (large loss)
(0.25, "#F9D35A"), # light yellow
(0.5, "#CCD6D3"), # white at zero
(0.75, "#86D0E0"), # light blue
(1.0, "#002C39"), # dark blue (large gain)
]
return LinearSegmentedColormap.from_list("yellow_white_purple", colors, N=256)
df= pd.read_csv("./raw/change_since_pandemic_clean.csv", encoding="utf-8")
gdf = gpd.read_file("./raw/cleaned_kommune_copenhagen.geojson")
merged = gdf.merge(df[["Kommune", "Change"]], left_on="label_dk", right_on="Kommune", how="left")
# --- SymLogNorm: log-like scale with linear zone around 0 ---
vmin = np.nanmin(merged["Change"])
vmax = np.nanmax(merged["Change"])
bound = max(abs(vmin if vmin is not None else 0.0), abs(vmax if vmax is not None else 0.0)) or 1.0
linthresh = 500
norm = SymLogNorm(linthresh=linthresh, linscale=1, vmin=-bound, vmax=bound)
cmap = build_colormap()
# Plot
fig, ax = plt.subplots(figsize=(8.5, 10))
merged.plot(column="Change", cmap=cmap, norm=norm, linewidth=0.5, edgecolor="#888888", ax=ax, missing_kwds={
"color": "#f0f0f0", "hatch": "///", "label": "No data"
})
ax.set_axis_off()
fig.text(0.5, 0.2, "© Fair og Fornuftig 2025, Source: statbank.dk/FOLK1C",
ha='center', va='center', fontsize=11, color='#191C1B', alpha=0.8)
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = fig.colorbar(sm, ax=ax, fraction=0.025, pad=0.02)
cbar.set_label("Change")
plt.title("Demographic Change of Working Age Population 2025-2021")
# watermark-style credit
plt.tight_layout()
fig.savefig("./images/change_map.png", dpi=220)
plt.show
<function matplotlib.pyplot.show(close=None, block=None)>
Danish Demographic Change¶
Concentrating on just Danes, we can see that the trend to move from smaller towns and villages to bigger cities is much more pronounced without factoring in working-age foreign nationals.
df = pd.read_csv("./raw/change_danish.csv", encoding="utf-8")
merged = gdf.merge(df[["Kommune", "Change"]], left_on="label_dk", right_on="Kommune", how="left")
# --- SymLogNorm: log-like scale with linear zone around 0 ---
vmin = np.nanmin(merged["Change"])
vmax = np.nanmax(merged["Change"])
bound = max(abs(vmin if vmin is not None else 0.0), abs(vmax if vmax is not None else 0.0)) or 1.0
linthresh = 500
norm = SymLogNorm(linthresh=linthresh, linscale=1, vmin=-bound, vmax=bound)
cmap = build_colormap()
# Plot
fig, ax = plt.subplots(figsize=(8.5, 10))
merged.plot(column="Change", cmap=cmap, norm=norm, linewidth=0.5, edgecolor="#888888", ax=ax, missing_kwds={
"color": "#f0f0f0", "hatch": "///", "label": "No data"
})
ax.set_axis_off()
plt.title("Demographic Change of Working Age Danes (2021-2025)")
fig.text(0.5, 0.2, "© Fair og Fornuftig 2025, Source: statbank.dk/FOLK1C",
ha='center', va='center', fontsize=11, color='#191C1B', alpha=0.8)
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = fig.colorbar(sm, ax=ax, fraction=0.025, pad=0.02)
cbar.set_label("Change")
plt.tight_layout()
fig.savefig("./images/dk_change_map.png", dpi=220)
plt.show
<function matplotlib.pyplot.show(close=None, block=None)>
Foreign National Demographic Change¶
Here we see that but for a couple of kommuner, the net migration of working age foreign nationals has been positive everywhere.
df = pd.read_csv("./raw/foreign_national_change.csv", encoding="utf-8")
merged = gdf.merge(df[["Kommune", "Change"]], left_on="label_dk", right_on="Kommune", how="left")
# --- SymLogNorm: log-like scale with linear zone around 0 ---
vmin = np.nanmin(merged["Change"])
vmax = np.nanmax(merged["Change"])
bound = max(abs(vmin if vmin is not None else 0.0), abs(vmax if vmax is not None else 0.0)) or 1.0
linthresh = 500
norm = SymLogNorm(linthresh=linthresh, linscale=1, vmin=-bound, vmax=bound)
cmap = build_colormap()
# Plot
fig, ax = plt.subplots(figsize=(8.5, 10))
merged.plot(column="Change", cmap=cmap, norm=norm, linewidth=0.5, edgecolor="#888888", ax=ax, missing_kwds={
"color": "#f0f0f0", "hatch": "///", "label": "No data"
})
ax.set_axis_off()
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = fig.colorbar(sm, ax=ax, fraction=0.025, pad=0.02)
cbar.set_label("Change")
plt.title("Net Migration of Working Age Foreigners (2025-2021)")
fig.text(0.5, 0.2, "© Fair og Fornuftig 2025, Source: statbank.dk/FOLK1C",
ha='center', va='center', fontsize=11, color='#191C1B', alpha=0.8)
plt.tight_layout()
fig.savefig("./images/foreign_change_map.png", dpi=220)
plt.show
<function matplotlib.pyplot.show(close=None, block=None)>
df_national= pd.read_csv("./raw/change_since_pandemic_clean.csv", encoding="utf-8")
df_danish = pd.read_csv("./raw/change_danish.csv", encoding="utf-8")
df_foreign = pd.read_csv("./raw/foreign_national_change.csv", encoding="utf-8")
# Rename 'Change' columns before merging
df_national = df_national.rename(columns={"Change": "Total"})
df_danish = df_danish.rename(columns={"Change": "Danish"})
df_foreign = df_foreign.rename(columns={"Change": "Foreign National"})
# Merge all on 'Kommune'
df_all = (
df_national[["Kommune", "Total"]]
.merge(df_danish[["Kommune", "Danish"]], on="Kommune", how="left")
.merge(df_foreign[["Kommune", "Foreign National"]], on="Kommune", how="left")
)
Data Exploration¶
Classifying municipal growth patterns into different categorical groupings
SMALL = 100 # treat anything between -SMALL and +SMALL as ~stable/rounding noise
eps = 1e-9
# clean types just in case
df_all["Kommune"] = df_all["Kommune"].astype(str).str.strip()
for c in ["Total","Danish","Foreign National"]:
df_all[c] = pd.to_numeric(df_all[c], errors="coerce").fillna(0)
# helper metrics
df_all["replacement_ratio"] = np.where(
df_all["Danish"] < -SMALL,
df_all["Foreign National"] / (-df_all["Danish"] + eps),
np.nan
)
df_all["foreign_share_of_growth"] = np.where(
df_all["Total"] > SMALL,
df_all["Foreign National"] / (df_all["Total"] + eps),
np.nan
)
def classify(row):
T = row["Total"]
D = row["Danish"]
F = row["Foreign National"]
# near-zero helpers
T_pos = T > SMALL
T_neg = T < -SMALL
T_flat = not T_pos and not T_neg
D_pos, D_neg = D > SMALL, D < -SMALL
F_pos, F_neg = F > SMALL, F < -SMALL
# priority order of mutually exclusive categories
if T_pos and D_neg and F_pos:
# growth but Danes down -> foreigners are the driver
return "Growth driven by foreigners"
if T_flat and D_neg and F_pos:
# total ~flat but foreigners offset Danish decline
return "Stable because of foreigners"
if T_pos and D_pos and F_pos:
return "Dual growth (Danes + Foreigners)"
if T_pos and D_pos and not F_pos:
return "Growth driven by Danes"
if T_neg and D_neg and not F_pos:
return "Working age population decline"
if T_neg and D_neg and F_pos:
return "Decline despite foreign inflow"
if T_flat and ((D_pos and F_neg) or (D_neg and F_pos)):
return "Stable: offsetting churn"
return "Small change"
df_all["Typology"] = df_all.apply(classify, axis=1)
df_all[df_all["Typology"] == "Growth driven by foreigners"].sort_values("Foreign National", ascending=False).head(10)
df_all["Foreign_share_of_total_growth"] = (
df_all["Foreign National"] / (df_all["Total"].replace(0, np.nan))
)
# Clean merge keys
df_plot = df_all.copy()
df_plot["Kommune"] = df_plot["Kommune"].astype(str).str.strip()
gdf["label_dk"] = gdf["label_dk"].astype(str).str.strip()
merged = gdf.merge(df_plot, left_on="label_dk", right_on="Kommune", how="left")
# Color-blind friendly palette
PALETTE = {
"Growth driven by foreigners": "#0B6A55", # blue
"Stable because of foreigners": "#36B0CC", # light blue
"Dual growth (Danes + Foreigners)": "#1CD194", # green
"Growth driven by Danes": "#B58A21", # orange
"Working age population decline": "#FDA9AF", # light pink
"Decline despite foreign inflow": "#F9D35A", # yellow
"Small change": "#A3AFAB", # grey
}
merged["cat_color"] = merged["Typology"].map(PALETTE)
fig, ax = plt.subplots(figsize=(9, 10))
merged.plot(color=merged["cat_color"], linewidth=0.5, edgecolor="#888888", ax=ax,
missing_kwds={"color": "#f0f0f0", "hatch": "///", "label": "No data"})
ax.set_axis_off()
plt.title("Working-Age Dynamics in Danish Kommuner (2025–2021)")
fig.text(0.5, 0.1, "© Fair og Fornuftig 2025, Source: statbank.dk/FOLK1C",
ha='center', va='center', fontsize=12, color='#191C1B', alpha=0.8)
# Build legend in fixed, readable order
legend_order = [
"Dual growth (Danes + Foreigners)",
"Growth driven by foreigners",
"Working age population decline",
"Decline despite foreign inflow",
"Stable because of foreigners",
"Small change",
]
patches = [mpatches.Patch(color=PALETTE[k], label=k) for k in legend_order]
leg = ax.legend(handles=patches, title="Typology", loc="upper right", frameon=True)
for t in leg.get_texts():
t.set_fontsize(9)
plt.tight_layout()
fig.savefig("./images/typology_map.png", dpi=220)
plt.show()
# === SETTINGS ===
INPUT_CSV = "./raw/percentage of foreigners.csv"
Y_VAR = "Total" # "Total", "Danish", or "Foreign National"
OUTPUT_PNG = f"./images/scatter_{Y_VAR.lower()}_vs_foreign_share_typology_minimal.png"
TITLE = f"How Population Change Relates to Percentage of Foreign Nationals"
# Color-blind friendly palette
TYPOLOGY_COLOURS = {
"Growth driven by foreigners": "#0B6A55", # blue
"Stable because of foreigners": "#36B0CC", # light blue
"Dual growth (Danes + Foreigners)": "#1CD194", # green
"Growth driven by Danes": "#B58A21", # orange
"Working age population decline": "#FDA9AF", # light pink
"Decline despite foreign inflow": "#F9D35A", # yellow
"Small change": "#A3AFAB", # grey
}
# === 1) Load % foreign CSV ===
foreign = pd.read_csv(INPUT_CSV, encoding="utf-8")
if "Foreign citizen" in foreign.columns:
foreign["pct_foreign_2025Q3"] = pd.to_numeric(foreign["Foreign citizen"], errors="coerce")
else:
raise ValueError("CSV must contain a 'Foreign citizen' column.")
foreign = foreign.rename(columns={"region": "Kommune"})
# === 2) Merge with df_all ===
if "df_all" not in globals():
raise RuntimeError("df_all must exist (Kommune, Total, Danish, Foreign National, Typology).")
merged = df_all.merge(foreign[["Kommune", "pct_foreign_2025Q3"]], on="Kommune", how="left")
for c in ["Total", "Danish", "Foreign National"]:
merged[c] = pd.to_numeric(merged[c], errors="coerce")
# === 3) Plot ===
plt.style.use("seaborn-v0_8-whitegrid")
fig, ax = plt.subplots(figsize=(8.5, 6))
# Scatter by typology
for typ, group in merged.groupby("Typology"):
ax.scatter(
group["pct_foreign_2025Q3"],
group[Y_VAR],
s=55,
color=TYPOLOGY_COLOURS.get(typ, "#999999"),
label=typ,
alpha=0.8,
edgecolor="none",
)
# Trend line
x = merged["pct_foreign_2025Q3"]
y = merged[Y_VAR]
mask = x.notna() & y.notna()
coef = np.polyfit(x[mask], y[mask], 1)
poly = np.poly1d(coef)
x_line = np.linspace(x.min(), x.max(), 200)
ax.plot(x_line, poly(x_line), linestyle="-", color="#222222", lw=1.0, alpha=0.8)
# Equation + R² (discreet, top left)
r2 = 1 - np.sum((y[mask] - poly(x[mask]))**2) / np.sum((y[mask] - y[mask].mean())**2)
ax.text(
0.02, 0.97, f"y = {coef[0]:.1f}x + {coef[1]:.0f} R² = {r2:.2f}",
transform=ax.transAxes, ha="left", va="top", fontsize=9, color="#333333"
)
# Minimalist axes
ax.axhline(0, color="#888888", lw=0.6)
ax.set_xlabel("% foreign citizens (2025Q3)", fontsize=10, color="#333333")
ax.set_ylabel(f"{Y_VAR} change (2025–2021)", fontsize=10, color="#333333")
ax.set_title(TITLE, fontsize=11.5, color="#222222", pad=12)
ax.grid(True, linestyle=":", lw=0.5, alpha=0.3)
ax.set_xlim(0, 40)
# Legend — small and off to the side
ax.legend(
fontsize=10,
loc="center left", bbox_to_anchor=(1.0, 0.5), frameon=False
)
# Clean up borders
for spine in ax.spines.values():
spine.set_visible(False)
fig.text(0.5, 0.01, "© Fair og Fornuftig 2025, Source: statbank.dk/FOLK1C",
ha='left', va='center', fontsize=12, color='#191C1B', alpha=0.6)
fig.tight_layout()
plt.savefig(OUTPUT_PNG, dpi=220, bbox_inches="tight")
plt.show()
# === Kommuner where growth is driven by foreigners ===
growth_foreign = (
merged.loc[merged["Typology"] == "Growth driven by foreigners",
["Kommune", "Total", "Danish", "Foreign National", "pct_foreign_2025Q3"]]
.sort_values("pct_foreign_2025Q3", ascending=False)
.reset_index(drop=True)
)
# Display
growth_foreign.head(90)
| Kommune | Total | Danish | Foreign National | pct_foreign_2025Q3 | |
|---|---|---|---|---|---|
| 0 | Ishøj | 1129 | -185 | 1314 | 34 |
| 1 | Gladsaxe | 1651 | -528 | 2179 | 21 |
| 2 | Lyngby-Taarbæk | 1056 | -547 | 1603 | 19 |
| 3 | Billund | 389 | -555 | 944 | 19 |
| 4 | Sønderborg | 169 | -2026 | 2195 | 17 |
| 5 | Ikast-Brande | 775 | -359 | 1134 | 17 |
| 6 | Hvidovre | 357 | -528 | 885 | 17 |
| 7 | Rudersdal | 123 | -433 | 556 | 13 |
| 8 | Helsingør | 257 | -276 | 533 | 11 |
| 9 | Slagelse | 817 | -233 | 1050 | 11 |
| 10 | Viborg | 519 | -351 | 870 | 10 |
| 11 | Gribskov | 122 | -350 | 472 | 10 |
| 12 | Næstved | 708 | -282 | 990 | 9 |
| 13 | Faaborg-Midtfyn | 253 | -294 | 547 | 9 |
| 14 | Brønderslev-Dronninglund | 154 | -157 | 311 | 9 |
| 15 | Nyborg | 102 | -153 | 255 | 8 |
# === Kommuner where growth is held steady by foreigners ===
steady_foreign = (
merged.loc[merged["Typology"] == "Stable because of foreigners",
["Kommune", "Total", "Danish", "Foreign National", "pct_foreign_2025Q3"]]
.sort_values("pct_foreign_2025Q3", ascending=False)
.reset_index(drop=True)
)
steady_foreign.head(90)
| Kommune | Total | Danish | Foreign National | pct_foreign_2025Q3 | |
|---|---|---|---|---|---|
| 0 | Vejen | -51 | -693 | 642 | 15 |
| 1 | Halsnæs | -13 | -576 | 563 | 13 |
| 2 | Herning | 12 | -1165 | 1177 | 12 |
| 3 | Kerteminde | 70 | -268 | 338 | 10 |
| 4 | Stevns | -21 | -232 | 211 | 10 |
| 5 | Holstebro | 55 | -573 | 628 | 10 |
| 6 | Syddjurs | 97 | -256 | 353 | 9 |
# === Kommuner where growth is through foreigners and Danes ===
growth_both = (
merged.loc[merged["Typology"] == "Dual growth (Danes + Foreigners)",
["Kommune", "Total", "Danish", "Foreign National", "pct_foreign_2025Q3"]]
.sort_values("pct_foreign_2025Q3", ascending=False)
.reset_index(drop=True)
)
# Display
growth_both.head(90)
| Kommune | Total | Danish | Foreign National | pct_foreign_2025Q3 | |
|---|---|---|---|---|---|
| 0 | Høje-Taastrup | 5672 | 1368 | 4304 | 30 |
| 1 | Brøndby | 3972 | 992 | 2980 | 29 |
| 2 | Vallensbæk | 1545 | 342 | 1203 | 28 |
| 3 | Copenhagen | 23626 | 2202 | 21424 | 23 |
| 4 | Glostrup | 1804 | 277 | 1527 | 23 |
| 5 | Herlev | 2146 | 347 | 1799 | 21 |
| 6 | Rødovre | 2790 | 561 | 2229 | 21 |
| 7 | Ballerup | 3600 | 1014 | 2586 | 19 |
| 8 | Frederiksberg | 2963 | 1289 | 1674 | 17 |
| 9 | Horsens | 2970 | 929 | 2041 | 16 |
| 10 | Greve | 2215 | 1036 | 1179 | 15 |
| 11 | Vejle | 2772 | 335 | 2437 | 15 |
| 12 | Furesø | 821 | 443 | 378 | 14 |
| 13 | Hillerød | 2108 | 702 | 1406 | 14 |
| 14 | Århus | 15830 | 8683 | 7147 | 13 |
| 15 | Køge | 1618 | 372 | 1246 | 13 |
| 16 | Tårnby | 948 | 221 | 727 | 13 |
| 17 | Odense | 4088 | 1053 | 3035 | 12 |
| 18 | Hedensted | 1004 | 160 | 844 | 11 |
| 19 | Frederikssund | 1035 | 262 | 773 | 10 |
| 20 | Egedal | 1374 | 756 | 618 | 10 |
| 21 | Holbæk | 1017 | 293 | 724 | 10 |
| 22 | Roskilde | 2099 | 1047 | 1052 | 10 |
| 23 | Solrød | 948 | 503 | 445 | 10 |
| 24 | Aalborg | 2695 | 1042 | 1653 | 10 |
| 25 | Lejre | 776 | 419 | 357 | 9 |
| 26 | Odder | 492 | 309 | 183 | 9 |
| 27 | Favrskov | 589 | 160 | 429 | 9 |
| 28 | Silkeborg | 3252 | 2211 | 1041 | 9 |
| 29 | Skanderborg | 960 | 733 | 227 | 7 |
# === Foreigners arriving but kommune workforce still contracting ===
contracting_foreign = (
merged.loc[merged["Typology"] == "Decline despite foreign inflow",
["Kommune", "Total", "Danish", "Foreign National", "pct_foreign_2025Q3"]]
.sort_values("pct_foreign_2025Q3", ascending=False)
.reset_index(drop=True)
)
# Display
contracting_foreign.head(90)
| Kommune | Total | Danish | Foreign National | pct_foreign_2025Q3 | |
|---|---|---|---|---|---|
| 0 | Tønder | -1167 | -2032 | 865 | 18 |
| 1 | Aabenraa | -484 | -1844 | 1360 | 17 |
| 2 | Ærø | -207 | -341 | 134 | 15 |
| 3 | Ringkøbing-Skjern | -515 | -1376 | 861 | 15 |
| 4 | Varde | -542 | -1233 | 691 | 14 |
| 5 | Haderslev | -356 | -1450 | 1094 | 13 |
| 6 | Lolland | -1061 | -1906 | 845 | 13 |
| 7 | Lemvig | -662 | -906 | 244 | 12 |
| 8 | Vesthimmerland | -456 | -846 | 390 | 12 |
| 9 | Nordfyns | -345 | -659 | 314 | 11 |
| 10 | Norddjurs | -879 | -1206 | 327 | 11 |
| 11 | Struer | -587 | -715 | 128 | 11 |
| 12 | Thisted | -691 | -1016 | 325 | 11 |
| 13 | Frederikshavn | -1302 | -1893 | 591 | 11 |
| 14 | Esbjerg | -1396 | -2626 | 1230 | 11 |
| 15 | Kalundborg | -163 | -1190 | 1027 | 10 |
| 16 | Bornholm | -1009 | -1295 | 286 | 10 |
| 17 | Assens | -426 | -881 | 455 | 10 |
| 18 | Mariagerfjord | -397 | -844 | 447 | 10 |
| 19 | Vordingborg | -560 | -1089 | 529 | 10 |
| 20 | Guldborgsund | -1125 | -1679 | 554 | 9 |
| 21 | Skive | -909 | -1282 | 373 | 9 |
| 22 | Hjørring | -939 | -1444 | 505 | 9 |
| 23 | Odsherred | -883 | -1089 | 206 | 8 |
| 24 | Jammerbugt | -265 | -628 | 363 | 8 |
| 25 | Svendborg | -376 | -687 | 311 | 7 |
# Typology List of Kommuner
# === Typology List of Kommuner ===
typology_pct = (
merged.loc[
(merged["Typology"] != "Dual growth (Danes + Foreigners)") &
(merged["pct_foreign_2025Q3"] >= 10),
["Kommune", "Typology", "pct_foreign_2025Q3"]
]
.sort_values("pct_foreign_2025Q3", ascending=False)
.reset_index(drop=True)
)
typology_pct.head(90)
| Kommune | Typology | pct_foreign_2025Q3 | |
|---|---|---|---|
| 0 | Ishøj | Growth driven by foreigners | 34 |
| 1 | Albertslund | Small change | 23 |
| 2 | Gladsaxe | Growth driven by foreigners | 21 |
| 3 | Lyngby-Taarbæk | Growth driven by foreigners | 19 |
| 4 | Billund | Growth driven by foreigners | 19 |
| 5 | Tønder | Decline despite foreign inflow | 18 |
| 6 | Gentofte | Small change | 17 |
| 7 | Aabenraa | Decline despite foreign inflow | 17 |
| 8 | Sønderborg | Growth driven by foreigners | 17 |
| 9 | Hvidovre | Growth driven by foreigners | 17 |
| 10 | Fredensborg | Small change | 17 |
| 11 | Ikast-Brande | Growth driven by foreigners | 17 |
| 12 | Ærø | Decline despite foreign inflow | 15 |
| 13 | Vejen | Stable because of foreigners | 15 |
| 14 | Ringkøbing-Skjern | Decline despite foreign inflow | 15 |
| 15 | Ringsted | Small change | 15 |
| 16 | Varde | Decline despite foreign inflow | 14 |
| 17 | Rudersdal | Growth driven by foreigners | 13 |
| 18 | Halsnæs | Stable because of foreigners | 13 |
| 19 | Lolland | Decline despite foreign inflow | 13 |
| 20 | Haderslev | Decline despite foreign inflow | 13 |
| 21 | Kolding | Small change | 13 |
| 22 | Samsø | Small change | 13 |
| 23 | Vesthimmerland | Decline despite foreign inflow | 12 |
| 24 | Lemvig | Decline despite foreign inflow | 12 |
| 25 | Hørsholm | Small change | 12 |
| 26 | Faxe | Small change | 12 |
| 27 | Herning | Stable because of foreigners | 12 |
| 28 | Fredericia | Small change | 12 |
| 29 | Thisted | Decline despite foreign inflow | 11 |
| 30 | Frederikshavn | Decline despite foreign inflow | 11 |
| 31 | Slagelse | Growth driven by foreigners | 11 |
| 32 | Helsingør | Growth driven by foreigners | 11 |
| 33 | Norddjurs | Decline despite foreign inflow | 11 |
| 34 | Struer | Decline despite foreign inflow | 11 |
| 35 | Langeland | Working age population decline | 11 |
| 36 | Esbjerg | Decline despite foreign inflow | 11 |
| 37 | Nordfyns | Decline despite foreign inflow | 11 |
| 38 | Gribskov | Growth driven by foreigners | 10 |
| 39 | Kalundborg | Decline despite foreign inflow | 10 |
| 40 | Bornholm | Decline despite foreign inflow | 10 |
| 41 | Allerød | Small change | 10 |
| 42 | Kerteminde | Stable because of foreigners | 10 |
| 43 | Fanø | Working age population decline | 10 |
| 44 | Assens | Decline despite foreign inflow | 10 |
| 45 | Vordingborg | Decline despite foreign inflow | 10 |
| 46 | Stevns | Stable because of foreigners | 10 |
| 47 | Holstebro | Stable because of foreigners | 10 |
| 48 | Randers | Small change | 10 |
| 49 | Viborg | Growth driven by foreigners | 10 |
| 50 | Mariagerfjord | Decline despite foreign inflow | 10 |
Summary¶
Some kommuner are now so dependent on international residents that it no longer makes sense to plan without their input.
In places where the working-age population is stable or growing, it’s often internationals holding that line: paying tax, filling essential roles and keeping services viable. The demographic balance has already shifted; the local governance hasn’t caught up.
The conversation needs to move on from 'attracting international workforce' and onto 'how do we make these valued members of our community thrive, do they have special challenges to meet?'