A Stubborn 25 Year Gender Gap: Showing the Hidden Truth In Python
A Stubborn 25 Year Gender Gap: Showing the Hidden Truth In Python
A hands-on tutorial using World Bank labor force data
Thursday, October 29 is the UN International Day of Care and Support, a reminder of how much unpaid work is done, and of who is doing most of it.
The answer, globally, is women.
Data at Depth is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.
When women carry the bulk of unpaid care — childcare, elder care, cooking, water collection — they have fewer hours left for paid employment.
Now one great source of data is the The World Bank, as they track these kinds of statistics directly across more than 190 countries And the pattern in that data is not what most readers expect. A commmon question that arises is: which countries have the widest gap?
The intuitive answer is the poorest ones. However, the data says otherwise.
And as with all my articles, let’s do this hands-on with some real Python data visualization code — using the dumbbell chart, a form that data visualization czar Alberto Cairo has championed as an honest way to compare two related values.
NOTE: The complete notebook file (dumbbell_gender_gap.ipynb) and dataset can be accessed on my GitHub: [HERE]
The Dataset
The World Bank publishes labor force participation rates as two separate indicators: SL.TLF.CACT.FE.ZS (female) and SL.TLF.CACT.MA.ZS (male), covering 187 countries from 1990 to 2025, plus regional and income-group aggregates.
After loading and merging both files (saved as female_lfp.csv and male_lfp.csv), we have four columns to work with:
iso3— World Bank three-letter country or aggregate codecountry— country or group nameyear— year of observationvalue— labor force participation rate (%)
import pandas as pd
f = pd.read_csv(’female_lfp.csv’)
m = pd.read_csv(’male_lfp.csv’)
print(f.columns.tolist())
print(f.shape)
print(f.head())Running this prints the column names and the first five rows — exactly what you want to see before you do anything else with a dataset you’ve never touched.
Chart 1: The Dumbbell — Income Groups By Gender
The dumbbell chart (also called a connected dot plot) puts two related values on the same row, with a line connecting them.
The length of that line is the gap.
In Alberto Cairo’s work — particularly The Functional Art (2012) and How Charts Lie (2019) — the connected dot plot appears as the cleanest way to show a comparison between two values. It avoids the kinds of visual tricks that bar charts can invite (ie. misleading bar chart comparisons)
The dumbbell chart makes the comparison work that a bar chart can’t.
Here, let’s use it to compare female and male labor force participation for the World Bank’s four income groups in 2025:
import plotly.graph_objects as go
# Filter to income group aggregates for 2025
INC_NAMES = [’High income’, ‘Low income’, ‘Lower middle income’, ‘Upper middle income’]
inc_f = f[f[’country’].isin(INC_NAMES) & f[’iso3’].isna()][[’country’, ‘year’, ‘value’]].rename(columns={’value’: ‘female’})
inc_m = m[m[’country’].isin(INC_NAMES) & m[’iso3’].isna()][[’country’, ‘year’, ‘value’]].rename(columns={’value’: ‘male’})
inc = inc_f.merge(inc_m, on=[’country’, ‘year’])
inc_2025 = inc[inc[’year’] == 2025].copy()
# Sort by male LFP so lower-middle income (highest male) appears at top
inc_2025 = inc_2025.sort_values(’male’)
fig = go.Figure()
# Draw connecting lines
for _, row in inc_2025.iterrows():
fig.add_trace(go.Scatter(
x=[row[’female’], row[’male’]],
y=[row[’country’], row[’country’]],
mode=’lines’,
line=dict(color=’#B8B8B8’, width=5),
hoverinfo=’skip’
))
# Women dots (grey)
fig.add_trace(go.Scatter(
x=inc_2025[’female’], y=inc_2025[’country’],
mode=’markers’, name=’Women’,
marker=dict(color=’#6E6E6E’, size=20)
))
# Men dots (red)
fig.add_trace(go.Scatter(
x=inc_2025[’male’], y=inc_2025[’country’],
mode=’markers’, name=’Men’,
marker=dict(color=’#D62728’, size=20)
))
fig.update_layout(
title=’Lower-middle income countries have the widest gender labor gap’,
xaxis_title=’Labor force participation rate (%)’,
template=’plotly_white’
)
fig.show()The key thing to look at here in the code is not the dots themselves, but the the length of the connecting line. The longer the line, the wider the gap.
Wow, that first line (lower middle income) is remarkably long! Visually, you can see why it is called the “dumbell” chart. We can make those “weights” larger to closer resemble a dumbell if we want.
Now what does this chart actually tell us, you ask?
We can look at the line length for each row. For lower-middle income countries, women participate at 39.2% and men at 76.8%.
This is a 37.6 percentage point gap. That is more than double the gap in high-income countries and nearly double the gap in low-income countries. Definitely not insignificant. In fact, this shows a massive difference.
And we can clearly see this visually with a dumbell chart. The lower-middle income row is simply much longer than any other.
So far, we’re getting a clearer picture. Now let’s see this from a different angle.
Chart 2: The Global Trend, 2000–2025
The next question the dumbbell raises: is this getting better over time? In other words, is the gap decreasing?
Let’s pull the glogal aggregates for both genders and plot the trend:
world_f = f[f[’country’] == ‘World’][[’year’, ‘value’]].rename(columns={’value’: ‘female’}).dropna()
world_m = m[m[’country’] == ‘World’][[’year’, ‘value’]].rename(columns={’value’: ‘male’}).dropna()
world = world_f.merge(world_m, on=’year’).sort_values(’year’)
world = world[world[’year’].between(2000, 2025)]
import plotly.express as px
trend_f = world[[’year’, ‘female’]].rename(columns={’female’: ‘rate’})
trend_f[’Gender’] = ‘Women’
trend_m = world[[’year’, ‘male’]].rename(columns={’male’: ‘rate’})
trend_m[’Gender’] = ‘Men’
trend_long = pd.concat([trend_f, trend_m])
fig = px.line(trend_long, x=’year’, y=’rate’, color=’Gender’,
title=’The world gender gap in paid work has barely narrowed since 2000’,
labels={’rate’: ‘Labor force participation (%)’, ‘year’: ‘Year’})
fig.show()Two lines — men above, women below — and the gap between them is the story. Here’s what it looks like:
Notice what happens in 2020 (Covid created quite the dent in the labour force, as we can see).
With the overall trend however, we can see that the gap in 2000 was 27.1 percentage points, and in 2025 it is 24.1. Three points narrower over 25 years — that’s not a lot. And both lines actually fell.
Not a positive indicator from a development perspective.
Chart 3: The Countries With The Widest Gaps
A logical next question, then, is where are the worst cases? We can create a ranked bar chart to assist us here:
# Country-level data 2025 — exclude World Bank regional aggregates
AGG_CODES = {
‘WLD’,’EAS’,’EAP’,’ECA’,’ECS’,’LCN’,’LAC’,’MNA’,’MEA’,’NAC’,’SAS’,’SSA’,
‘AFE’,’AFW’,’ARB’,’CSS’,’CEB’,’EAR’,’TEA’,’EMU’,’TEC’,’EUU’,’FCV’,’HPC’,
‘IBD’,’IBT’,’IDB’,’IDX’,’IDA’,’LTE’,’LDC’,’LMY’,’OED’,’HIC’,’UMC’,’LMC’,
‘LIC’,’TLA’,’TMN’,’TSA’,’TSS’,’PST’,’PRE’
}
country_f = f[(f[’year’] == 2025) & f[’iso3’].notna() &
f[’iso3’].str.match(r’^[A-Z]{3}$’, na=False) &
~f[’iso3’].isin(AGG_CODES)]
country_m = m[(m[’year’] == 2025) & m[’iso3’].notna() &
m[’iso3’].str.match(r’^[A-Z]{3}$’, na=False) &
~m[’iso3’].isin(AGG_CODES)]
countries = country_f.merge(country_m, on=[’iso3’, ‘country’, ‘year’],
suffixes=(’_f’, ‘_m’))
countries[’gap’] = countries[’value_m’] - countries[’value_f’]
top10 = countries.nlargest(10, ‘gap’)
fig = px.bar(top10.sort_values(’gap’), x=’gap’, y=’country’, orientation=’h’,
title=’Eight of the ten widest gender gaps are in the Middle East and North Africa’,
labels={’gap’: ‘Gender gap (pp)’, ‘country’: ‘’})
fig.show()In this code, if we sort by gap size, flip the axis horizontal, and the regional pattern jumps out immediately:
As we can see, this is not a global problem distributed evenly around the world.
Afghanistan leads at 64.7pp — and is highlighted effectifly in red.
Eight of the ten countries are in the Middle East and North Africa: Iraq, Oman, Yemen, Iran, Algeria, Egypt, Saudi Arabia and Syria.
The chart also hints at something the income-group dumbbell doesn’t show. These extreme gaps are not evenly spread across lower-middle income countries. They cluster by region and reflect specific legal, policy and social-norm environments.
Very cool data to look at. Now where are we improving (by income group)?
Chart 4: Which Income Groups Are Improving?
The last question we can use data can answer: is the lower-middle income gap improving over time, or is it stuck? Let’s create a line chart to help us:
INC_NAMES = [’High income’, ‘Low income’, ‘Lower middle income’, ‘Upper middle income’]
inc_f = f[f[’country’].isin(INC_NAMES) & f[’iso3’].isna()][[’country’, ‘year’, ‘value’]].rename(columns={’value’: ‘female’})
inc_m = m[m[’country’].isin(INC_NAMES) & m[’iso3’].isna()][[’country’, ‘year’, ‘value’]].rename(columns={’value’: ‘male’})
inc = inc_f.merge(inc_m, on=[’country’, ‘year’])
inc[’gap’] = inc[’male’] - inc[’female’]
inc_trend = inc[inc[’year’].between(2000, 2025)].rename(columns={’country’: ‘Income group’, ‘gap’: ‘gap_pp’})
fig = px.line(inc_trend, x=’year’, y=’gap_pp’, color=’Income group’,
title=’The lower-middle income gap is narrowing — but still leads by a wide margin’,
labels={’gap_pp’: ‘Gender gap (pp)’, ‘year’: ‘Year’})
fig.show()In this code, we are creating four lines within one scale :
Again, as we can see, the lower-middle income group sits so far above the others that it almost looks like its own chart.
Using best practices for data storytelling, we can bring attention to both the high and low income disparities by bolding and using bright colors.
We can see a slight bend in the line. Is this cause for hope? Maybe, or maybe not.At very low income levels, necessity draws women into subsistence agriculture or informal work, which keeps participation rates higher.
Whereas, at higher income levels, better legal protections, flexible labour markets and stronger public care infrastructure all pull women into formal employment.
So have attitudes and policies changed globally to encourage more women into the workforce? The statistics here show that in many regions of the world, perhaps this is not the case.
In Summary…
When I was building the dumbbell chart for the first time in this article, my eyese were immediately drawn towards the lower-middle income row. It’s just so much longer than the others. This style of chart works perfectly for what we’re trying to show here.
The dumbbell puts women and men on the same row and makes the VAST distance between them the visual subject. That’s Mr. Cairo’s insight, and it’s a good one.
NOTE: The complete notebook file (dumbbell_gender_gap.ipynb) and dataset can be accessed on my GitHub: [HERE]
Data at Depth is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.
Invite your friends and earn rewards
© 2026 John Loewen
Via Dei Cerchi, Rome, Latium, Italy