MakeoverMonday: London Unemployment - Young Londoners Hit Hardest

MakeoverMonday
Python
Economics
Data Viz
London leads UK regions in unemployment at 7.7%, with youth rates reaching 24.6% - a 35% surge in one year
Author

chokotto

Published

March 30, 2026

Overview

This week’s MakeoverMonday examines London’s estimated unemployment rates from the ONS Regional Labour Market Statistics (X02). Rather than a single headline number, we unpack the structural inequality hidden in the data: which age groups are bearing the burden, how London compares to the rest of the UK, and how rapidly the situation has deteriorated.

Data

Show code
import pandas as pd
import numpy as np
import plotly.graph_objects as go
import plotly.express as px
Show code
regional = pd.DataFrame({
    "Region": [
        "London", "North East", "West Midlands", "Yorkshire & Humber",
        "East Midlands", "North West", "East of England",
        "South East", "South West", "Scotland", "Wales", "Northern Ireland",
    ],
    "Rate_All": [7.7, 7.0, 6.0, 5.8, 5.5, 4.6, 4.1, 4.0, 3.9, 3.8, 3.5, 2.2],
    "Rate_Youth": [24.6, 23.5, 19.0, 16.4, 18.4, 13.4, 14.9, 13.4, 11.4, 10.4, 8.9, 5.9],
})

london_ts = pd.DataFrame({
    "Period": [
        "Jan-Mar 2019", "Jan-Mar 2020", "Jan-Mar 2021",
        "Jan-Mar 2022", "Jan-Mar 2023", "Jan-Mar 2024", "Jan-Mar 2025",
        "Nov-Jan 2026",
    ],
    "All_16plus": [4.4, 4.7, 6.9, 4.8, 4.9, 4.5, 6.2, 7.7],
    "Age_16_24":  [14.0, 13.2, 16.9, 14.9, 17.3, 11.9, 18.6, 24.6],
    "Age_25_34":  [3.6, 3.5, 5.1, 3.4, 4.2, 4.2, 4.8, 7.1],
    "Age_35_49":  [3.0, 3.8, 5.2, 3.2, 2.9, 3.1, 4.2, 5.4],
    "Age_50_64":  [3.3, 5.0, 7.7, 3.1, 4.3, 4.3, 5.8, 5.2],
})

london_age = pd.DataFrame({
    "Age_Group": ["16-24", "25-34", "35-49", "50-64", "65+"],
    "Rate": [24.6, 7.1, 5.4, 5.2, 3.0],
    "YoY_Change_pct": [35.2, 42.3, 39.2, 3.3, -37.6],
    "Level_thousands": [135.2, 105.9, 98.7, 60.9, 6.2],
})

borough = pd.DataFrame({
    "Borough": [
        "Newham", "Barking & Dagenham", "Brent", "Haringey", "Hackney",
        "Tower Hamlets", "Waltham Forest", "Greenwich", "Lambeth", "Lewisham",
        "Southwark", "Enfield", "Ealing", "Croydon", "Camden",
        "Islington", "Redbridge", "Hounslow", "Hammersmith & Fulham",
        "Barnet", "Hillingdon", "Harrow", "Merton", "Sutton",
        "Havering", "Bromley", "Richmond upon Thames",
        "Kingston upon Thames", "Wandsworth", "Bexley",
    ],
    "Rate_2025Q2": [
        8.7, 8.2, 8.2, 7.5, 7.3,
        7.1, 7.0, 6.8, 6.7, 6.5,
        6.3, 6.2, 6.0, 5.8, 5.7,
        5.5, 5.4, 5.3, 5.1,
        5.0, 4.9, 4.8, 4.5, 4.3,
        4.2, 4.0, 3.9,
        3.8, 3.7, 3.7,
    ],
    "YoY_Change_pp": [
        2.4, 1.0, 2.1, 0.8, 0.5,
        0.3, 2.0, 2.2, 2.0, 0.7,
        0.4, 0.9, 0.6, 0.3, 0.2,
        -0.3, 0.4, 0.1, -0.2,
        0.3, 0.1, -0.1, -0.3, -0.2,
        0.1, -0.5, -0.3,
        -0.4, -0.8, -0.2,
    ],
})

print(f"Regions: {len(regional)} | London time series: {len(london_ts)} periods")
print(f"Borough data: {len(borough)} boroughs")
print(f"London overall rate: {regional.loc[regional['Region']=='London','Rate_All'].values[0]}%")
print(f"London youth rate: {regional.loc[regional['Region']=='London','Rate_Youth'].values[0]}%")
Regions: 12 | London time series: 8 periods
Borough data: 30 boroughs
London overall rate: 7.7%
London youth rate: 24.6%

My Makeover

What I Changed

The original article presents unemployment through separate static charts. This makeover restructures the data into a multi-layered inequality narrative:

  1. Hero - Regional comparison: London leads all UK regions, with youth rates nearly double the next worst region
  2. Age disparity: the 16-24 cohort at 24.6% dwarfs every other age group
  3. Trend acceleration: how London’s unemployment trajectory has changed since 2019
  4. Borough map: spatial inequality within London itself

London Leads UK Regions - And the Youth Gap Is Even Wider

Show code
df = regional.sort_values("Rate_All", ascending=True).reset_index(drop=True)

fig = go.Figure()

fig.add_trace(go.Bar(
    y=df["Region"], x=df["Rate_All"],
    orientation="h", name="All 16+",
    marker_color=[ACCENT if r == "London" else MUTED for r in df["Region"]],
    text=[f"{v:.1f}%" for v in df["Rate_All"]],
    textposition="outside", cliponaxis=False,
    hovertemplate="%{y}<br>All 16+: %{x:.1f}%<extra></extra>",
))

fig.add_trace(go.Scatter(
    y=df["Region"], x=df["Rate_Youth"],
    mode="markers+text", name="Youth 16-24",
    marker=dict(size=10, color=WARNING, symbol="diamond"),
    text=[f"{v:.0f}%" for v in df["Rate_Youth"]],
    textposition="middle right",
    textfont=dict(size=10, color=WARNING),
    hovertemplate="%{y}<br>Youth 16-24: %{x:.1f}%<extra></extra>",
))

fig.update_layout(
    **THEME,
    title=make_title(
        "London's unemployment rate is the highest in the UK at 7.7% "
        "- but youth rates tell an even starker story at 24.6%"
    ),
    xaxis=dict(
        title="Unemployment Rate (%)", showgrid=True,
        gridcolor="#e2e8f0", zeroline=False, range=[0, 28],
    ),
    yaxis=dict(title=""),
    showlegend=False,
    height=500,
    margin=dict(t=100, b=140, l=160, r=80),
    barmode="overlay",
)

add_legend_note(
    fig,
    "<span style='color:#e63946'>&#9632;</span> All ages (16+)  "
    "<span style='color:#f59e0b'>&#9670;</span> Youth (16-24)",
    y=-0.15,
)
add_source(fig, y=-0.24)
assert_no_title_overlap(fig)
fig.show()
fig.write_image("chart-1.png", width=1200, height=600, scale=2)

The Age Divide: Youth Unemployment 3x the London Average

Show code
df_age = london_age.sort_values("Rate", ascending=True).reset_index(drop=True)

age_colors = []
for _, row in df_age.iterrows():
    if row["Age_Group"] == "16-24":
        age_colors.append(ACCENT)
    elif row["YoY_Change_pct"] > 30:
        age_colors.append(WARNING)
    else:
        age_colors.append(MUTED)

fig2 = go.Figure()
fig2.add_trace(go.Bar(
    y=df_age["Age_Group"], x=df_age["Rate"],
    orientation="h",
    marker_color=age_colors,
    text=[f'{r:.1f}%  (YoY: {"+" if c > 0 else ""}{c:.0f}%)'
          for r, c in zip(df_age["Rate"], df_age["YoY_Change_pct"])],
    textposition="outside", cliponaxis=False,
    hovertemplate=(
        "<b>Age %{y}</b><br>"
        "Rate: %{x:.1f}%<br>"
        "Unemployed: %{customdata[0]:.0f}k"
        "<extra></extra>"
    ),
    customdata=df_age[["Level_thousands"]].values,
))

fig2.update_layout(
    **THEME,
    title=make_title(
        "One in four young Londoners (16-24) is unemployed "
        "- and the 25-34 cohort surged 42% year-on-year"
    ),
    xaxis=dict(
        title="Unemployment Rate (%)", showgrid=True,
        gridcolor="#e2e8f0", zeroline=False, range=[0, 32],
    ),
    yaxis=dict(title=""),
    showlegend=False,
    height=380,
    margin=dict(t=100, b=120, l=80, r=180),
)

add_legend_note(
    fig2,
    "<span style='color:#e63946'>&#9632;</span> Highest rate  "
    "<span style='color:#f59e0b'>&#9632;</span> Fastest YoY growth (>30%)  "
    "<span style='color:#94a3b8'>&#9632;</span> Others",
    y=-0.20,
)
add_source(fig2, y=-0.28)
assert_no_title_overlap(fig2)
fig2.show()

Trend Since 2019: Youth Unemployment Accelerating Away

Show code
groups = {
    "Age 16-24": {"col": "Age_16_24", "color": ACCENT, "width": 3},
    "All 16+":   {"col": "All_16plus", "color": SECONDARY, "width": 2.5},
    "Age 25-34": {"col": "Age_25_34", "color": WARNING, "width": 1.5},
    "Age 50-64": {"col": "Age_50_64", "color": MUTED, "width": 1.5},
}

fig3 = go.Figure()
for label, cfg in groups.items():
    fig3.add_trace(go.Scatter(
        x=london_ts["Period"], y=london_ts[cfg["col"]],
        mode="lines+markers", name=label,
        line=dict(color=cfg["color"], width=cfg["width"]),
        marker=dict(size=6),
        hovertemplate=f"{label}: %{{y:.1f}}%<extra></extra>",
    ))

    last_val = london_ts[cfg["col"]].iloc[-1]
    fig3.add_annotation(
        x=london_ts["Period"].iloc[-1], y=last_val,
        text=f" {label}: {last_val:.1f}%",
        showarrow=False, xanchor="left",
        font=dict(size=10, color=cfg["color"]),
    )

fig3.update_layout(
    **THEME,
    title=make_title(
        "Youth unemployment has surpassed its COVID peak "
        "- and the gap with other age groups keeps widening"
    ),
    xaxis=dict(title="", showgrid=True, gridcolor="#e2e8f0"),
    yaxis=dict(
        title="Unemployment Rate (%)", showgrid=True,
        gridcolor="#e2e8f0", zeroline=False,
    ),
    showlegend=False,
    height=460,
    margin=dict(t=100, b=120, l=60, r=160),
)

add_source(fig3, y=-0.18)
assert_no_title_overlap(fig3)
fig3.show()

Borough Inequality: A 5-Percentage-Point Gap Within the Same City

Show code
df_b = borough.sort_values("Rate_2025Q2", ascending=True).reset_index(drop=True)

london_avg = 5.4
boro_colors = []
for _, row in df_b.iterrows():
    if row["Rate_2025Q2"] >= 7.0:
        boro_colors.append(ACCENT)
    elif row["Rate_2025Q2"] >= london_avg:
        boro_colors.append(WARNING)
    else:
        boro_colors.append(MUTED)

fig4 = go.Figure()
fig4.add_trace(go.Bar(
    y=df_b["Borough"], x=df_b["Rate_2025Q2"],
    orientation="h",
    marker_color=boro_colors,
    text=[f'{r:.1f}%  ({"+" if c > 0 else ""}{c:.1f}pp)'
          for r, c in zip(df_b["Rate_2025Q2"], df_b["YoY_Change_pp"])],
    textposition="outside", cliponaxis=False,
    hovertemplate="%{y}<br>Rate: %{x:.1f}%<extra></extra>",
))

fig4.add_vline(
    x=london_avg, line_dash="dash", line_color=SECONDARY,
    annotation_text=f"London avg: {london_avg}%",
    annotation_position="top right",
    annotation_font_color=SECONDARY,
    annotation_font_size=10,
)

fig4.update_layout(
    **THEME,
    title=make_title(
        "Newham (8.7%) vs Bexley (3.7%): "
        "living in the same city, facing different labour markets"
    ),
    xaxis=dict(
        title="Unemployment Rate (%)", showgrid=True,
        gridcolor="#e2e8f0", zeroline=False, range=[0, 11],
    ),
    yaxis=dict(title="", tickfont=dict(size=11)),
    showlegend=False,
    height=750,
    margin=dict(t=100, b=120, l=200, r=120),
)

add_legend_note(
    fig4,
    "<span style='color:#e63946'>&#9632;</span> 7%+  "
    "<span style='color:#f59e0b'>&#9632;</span> Above avg  "
    "<span style='color:#94a3b8'>&#9632;</span> Below avg  "
    "&#183;  Labels show YoY change in pp",
    y=-0.08,
)
add_source(fig4, y=-0.12)
assert_no_title_overlap(fig4)
fig4.show()

Key Takeaways

  1. London leads the UK in unemployment: at 7.7%, London’s rate is nearly double the South West (3.9%) and three times Northern Ireland (2.2%)
  2. Youth unemployment is the crisis within the crisis: one in four 16-24 year-olds in London is unemployed (24.6%), exceeding the COVID-era peak and growing 35% year-on-year
  3. The 25-34 cohort is the fastest-deteriorating group: while youth gets the headlines, the 42% year-on-year surge in 25-34 unemployment signals a broadening problem
  4. Spatial inequality is stark: within the same city, Newham (8.7%) faces more than double the unemployment of Bexley or Wandsworth (3.7%) - and the boroughs getting worse are concentrated in East London

This post is part of the MakeoverMonday weekly data visualization project.

CautionDisclaimer

This analysis is for educational and practice purposes only. ONS Labour Force Survey estimates are not seasonally adjusted and subject to sampling variability. Borough-level figures are modelled estimates from the Annual Population Survey and should be interpreted with caution.