html`<div class="indicator-card" style="max-width:280px">
<div class="indicator-name">Labor force participation, national
<span class="indicator-value">${ultimoNacional.valor}%</span>
</div>
<div class="indicator-meta">± ${(1.96 * ultimoNacional.ee).toFixed(2)} pp · ${ultimoNacional.anio}-Q${ultimoNacional.trimestre}</div>
</div>`Labor Market MX — Participation
Modified
September 4, 2026
ENOE, INEGI · Quarterly, 2005–2026 · By sex, age, education, and state
Labor force participation broken down by sex, age group, education level, and state. None of these cuts uses a custom definition: every one is the same TPEA formula applied within each group, using fields the ENOE already reports pre-coded.
raw = FileAttachment("data/labor-indicators-cuts.csv").csv({ typed: true })
mx = FileAttachment("data/mx-estados.json").json()
datos = raw
.filter(d => d.indicador === "TPEA")
.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
nacional = datos.filter(d => d.corte === "nacional").sort((a, b) => a.fecha - b.fecha)
ultimoNacional = nacional[nacional.length - 1]
entidadPorCodigo = ({
"1": "Aguascalientes", "2": "Baja California", "3": "Baja California Sur",
"4": "Campeche", "5": "Coahuila", "6": "Colima", "7": "Chiapas",
"8": "Chihuahua", "9": "Mexico City", "10": "Durango", "11": "Guanajuato",
"12": "Guerrero", "13": "Hidalgo", "14": "Jalisco", "15": "Mexico State",
"16": "Michoacán", "17": "Morelos", "18": "Nayarit",
"19": "Nuevo León", "20": "Oaxaca", "21": "Puebla", "22": "Querétaro",
"23": "Quintana Roo", "24": "San Luis Potosí", "25": "Sinaloa", "26": "Sonora",
"27": "Tabasco", "28": "Tamaulipas", "29": "Tlaxcala",
"30": "Veracruz", "31": "Yucatán", "32": "Zacatecas"
})
cortes = ({
sexo: { nombre: "Sex" },
edad: { nombre: "Age" },
nivel_educativo: { nombre: "Education" },
entidad: { nombre: "State" }
})
// Fixed category order per cut -- bars and the category selector always
// follow this (chronological for age, increasing for schooling), never
// sorted by TPEA level.
ordenCategorias = ({
sexo: ["Men", "Women"],
edad: ["15 to 19", "20 to 29", "30 to 39", "40 to 49", "50 to 59", "60 and over"],
nivel_educativo: ["Incomplete primary", "Primary", "Secondary", "High school and beyond"]
})
// The categoria_origen labels in the CSV are Spanish (shared with the
// private dashboard's pipeline) -- map them to the English labels above
// for display, without touching the underlying data. Five states use their
// full official Spanish name in the CSV (INEGI's own catalog form) rather
// than the short form entidadPorCodigo uses -- without these five, the map
// silently drops exactly those five states (valorPorEntidad.get() finds no
// match, same failure mode as the zero-padded id bug elsewhere on this
// site: no console error, just an absent shape).
etiquetaEn = ({
"Hombre": "Men", "Mujer": "Women",
"15 a 19": "15 to 19", "20 a 29": "20 to 29", "30 a 39": "30 to 39",
"40 a 49": "40 to 49", "50 a 59": "50 to 59", "60 y más": "60 and over",
"Primaria incompleta": "Incomplete primary", "Primaria completa": "Primary",
"Secundaria completa": "Secondary", "Medio superior y superior": "High school and beyond",
"Ciudad de México": "Mexico City", "Coahuila de Zaragoza": "Coahuila",
"México": "Mexico State", "Michoacán de Ocampo": "Michoacán",
"Veracruz de Ignacio de la Llave": "Veracruz"
})
filasCorte = corte => datos
.filter(d => d.corte === corte)
.map(d => ({ ...d, categoria_origen: etiquetaEn[d.categoria_origen] ?? d.categoria_origen }))
ultimoPeriodoCorte = corte => {
const filas = filasCorte(corte);
const fechaMax = new Date(Math.max(...filas.map(d => +d.fecha)));
return filas.filter(d => +d.fecha === +fechaMax);
}
serieCategoria = (corte, categoria) => filasCorte(corte)
.filter(d => d.categoria_origen === categoria)
.sort((a, b) => a.fecha - b.fecha);
graficaBarras = (filas, dominioX, { width = 640, height = 380 } = {}) => {
return Plot.plot({
width, height,
marginBottom: 86, marginLeft: 46, marginRight: 20,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "12px" },
x: { label: null, domain: dominioX, tickRotate: -30 },
y: { label: "TPEA (%)", grid: true },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.barY(filas, {
x: "categoria_origen", y: "valor",
fill: "var(--teal)", fillOpacity: 0.85
}),
Plot.ruleX(filas, {
x: "categoria_origen",
y1: d => d.valor - 1.96 * d.ee, y2: d => d.valor + 1.96 * d.ee,
stroke: "var(--ink-soft)", strokeWidth: 1.5
}),
Plot.text(filas, {
x: "categoria_origen", y: d => d.valor,
text: d => `${d.valor}%`, dy: -12,
fill: "var(--ink)", fontWeight: 600
}),
Plot.ruleY([0], { stroke: "var(--rule)" })
]
});
}
mxFeatures = topojson.feature(mx, mx.objects.state).features
graficaMapa = (filas, { width = 640, height = 440 } = {}) => {
const valorPorEntidad = new Map(filas.map(d => [d.categoria_origen, d.valor]));
const features = mxFeatures.map(f => ({
...f,
nombre: entidadPorCodigo[+f.properties.id],
valor: valorPorEntidad.get(entidadPorCodigo[+f.properties.id])
}));
return Plot.plot({
width, height,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "12px", color: "var(--ink)" },
projection: { type: "mercator", domain: { type: "FeatureCollection", features } },
color: {
type: "linear", scheme: "BuGn", label: "TPEA (%)", legend: true
},
marks: [
Plot.geo(features, {
fill: d => d.valor,
stroke: "var(--ink)", strokeWidth: 1,
tip: true,
title: d => `${d.nombre}\nTPEA: ${d.valor}%`
})
]
});
}
// A quarter that's genuinely absent from the data (2020-Q2, the ETOE gap --
// see periodos_validos() in the private pipeline) isn't the same as a
// quarter Plot knows to skip: lineY/areaY just connect whatever rows exist,
// so the neighboring quarters get bridged by a straight diagonal that reads
// as a real transition rather than a missing one. Insert an explicit
// undefined-value point at any such gap so Plot's marks break there instead
// -- Plot treats an undefined/NaN position channel as "invalid" and
// interrupts the line/area at that index, the same mechanism as d3's
// line().defined(). Spread ...actual first so categoria_origen (needed for
// per-category grouping in the multi-line chart) carries through; only
// fecha/valor/ee are overwritten.
conHuecos = serie => {
const resultado = [];
for (let i = 0; i < serie.length; i++) {
resultado.push(serie[i]);
if (i < serie.length - 1) {
const actual = serie[i], siguiente = serie[i + 1];
const meses = (siguiente.fecha.getFullYear() - actual.fecha.getFullYear()) * 12
+ (siguiente.fecha.getMonth() - actual.fecha.getMonth());
if (meses > 3) {
resultado.push({
...actual,
fecha: new Date(actual.fecha.getFullYear(), actual.fecha.getMonth() + 3, 1),
valor: undefined, ee: undefined
});
}
}
}
return resultado;
}
graficaSerieMultiple = (corte, categorias, { width = 680, height = 320 } = {}) => {
const serie = categorias.flatMap(cat => serieCategoria(corte, cat));
const serieConHuecos = categorias.flatMap(cat => conHuecos(serieCategoria(corte, cat)));
const ultimoPorCategoria = categorias.map(cat => {
const s = serieCategoria(corte, cat);
return s[s.length - 1];
}).filter(Boolean);
return Plot.plot({
width, height, marginLeft: 46, marginBottom: 32, marginRight: 100,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "TPEA (%)", grid: true, nice: true },
x: { label: null },
color: { legend: true, domain: categorias, scheme: "tableau10" },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.lineY(serieConHuecos, {
x: "fecha", y: "valor", z: "categoria_origen",
stroke: "categoria_origen", strokeWidth: 2,
strokeLinejoin: "round", strokeLinecap: "round"
}),
Plot.dot(ultimoPorCategoria, {
x: "fecha", y: "valor", r: 3.5, fill: "categoria_origen",
stroke: "var(--plot-background)", strokeWidth: 1.5
}),
Plot.tip(serie, Plot.pointerX({
x: "fecha", y: "valor",
title: d => `${d.categoria_origen}\n${d.anio}-Q${d.trimestre} (${d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("en-US")}`
})),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}
// nice:true auto-scales to the extent of every mark, including the
// confidence band -- and 2020-Q3 (first quarter back after the ETOE gap)
// has a much wider band than usual in some states from a reduced
// post-pandemic sample, which alone can stretch the whole y-axis and flatten
// the rest of a 20-year series. Fit the domain to the point estimates only,
// so one noisy quarter's uncertainty doesn't distort every other quarter's
// scale.
dominioAjustadoSerie = (serie, margenMinimo = 0.4) => {
const valores = serie.map(d => d.valor);
const min = Math.min(...valores), max = Math.max(...valores);
const margen = Math.max((max - min) * 0.15, margenMinimo);
return [min - margen, max + margen];
}
graficaSerieCorte = (serie, { width = 680, height = 260, color = "var(--teal)" } = {}) => {
const ultimo = serie[serie.length - 1];
const serieConHuecos = conHuecos(serie);
return Plot.plot({
width, height, marginLeft: 46, marginBottom: 32,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "%", grid: true, domain: dominioAjustadoSerie(serie) },
x: { label: null },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.areaY(serieConHuecos, {
x: "fecha", y1: d => d.valor - 1.96 * d.ee, y2: d => d.valor + 1.96 * d.ee,
fill: color, fillOpacity: 0.1
}),
Plot.lineY(serieConHuecos, {
x: "fecha", y: "valor", stroke: color, strokeWidth: 2,
strokeLinejoin: "round", strokeLinecap: "round"
}),
Plot.dot(serie, {
x: "fecha", y: "valor", r: 4, fill: color,
stroke: "var(--plot-background)", strokeWidth: 2,
filter: d => d === ultimo
}),
Plot.tip(serie, Plot.pointerX({
x: "fecha", y: "valor",
title: d => `${d.anio}-Q${d.trimestre} (${d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("en-US")}`
})),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}Overall
By breakdown
Time series
Full history for the breakdown above: every category on one chart. State is the exception — 32 overlapping lines aren’t readable — so there it’s one state at a time.
The gap between women and men, by age
Participation differs by sex more than by any other breakdown on this page. The gap is shown here as (men − women) / men, so it has no units and the age groups can share one chart. The line in ink is the national gap without the age split.
serieCruzada = (origen, destino) => datos
.filter(d => d.corte === "sexo_edad" && d.categoria_origen === origen &&
d.categoria_destino === destino)
.sort((a, b) => a.fecha - b.fecha)
gruposEdad = [...new Set(datos
.filter(d => d.corte === "sexo_edad")
.map(d => d.categoria_destino))]
.sort()
// Normalized gap by age group. Men and women are paired by quarter rather
// than by position: if one series ever lacked a quarter, a positional zip
// would silently misalign everything after it.
brechaPorEdad = grupo => {
const h = serieCruzada("Hombre", grupo);
const m = new Map(serieCruzada("Mujer", grupo).map(d => [`${d.anio}-${d.trimestre}`, d]));
return h.flatMap(dh => {
const dm = m.get(`${dh.anio}-${dh.trimestre}`);
if (!dm || !dh.valor) return [];
return [{ fecha: dh.fecha, anio: dh.anio, trimestre: dh.trimestre,
grupo, valor: (dh.valor - dm.valor) / dh.valor, H: dh.valor, M: dm.valor,
ee: eeBrecha(dh, dm) }];
});
}
// Standard error of the gap g = (H - M) / H by the delta method:
// dg/dH = M / H^2, dg/dM = -1 / H, so Var(g) ~ (M/H^2)^2 Var(H) + (1/H)^2 Var(M).
// Men and women are disjoint domains of the same survey, so their estimates
// are treated as independent; that ignores any covariance from shared strata
// and PSUs, the same approximation the rest of the site makes when it
// combines two estimates. Bands and tooltips are +-1.96 of this.
eeBrecha = (dh, dm) => Math.sqrt(
Math.pow(dm.valor / (dh.valor * dh.valor), 2) * dh.ee * dh.ee +
Math.pow(1 / dh.valor, 2) * dm.ee * dm.ee
)
// National gap without the age split, from the marginal "sexo" cut.
// filasCorte (and so serieCategoria) translates categoria_origen through
// etiquetaEn on this page, so the sex labels must be asked for in their
// translated form. serieCruzada above reads `datos` directly, which is not
// translated, so it keeps the Spanish labels. Asking for "Hombre" here
// returned nothing and the chart threw on an undefined last element.
brechaAgregada = {
const h = serieCategoria("sexo", etiquetaEn["Hombre"]);
const m = new Map(serieCategoria("sexo", etiquetaEn["Mujer"]).map(d => [`${d.anio}-${d.trimestre}`, d]));
return h.flatMap(dh => {
const dm = m.get(`${dh.anio}-${dh.trimestre}`);
if (!dm || !dh.valor) return [];
return [{ fecha: dh.fecha, anio: dh.anio, trimestre: dh.trimestre,
grupo: "__total__", valor: (dh.valor - dm.valor) / dh.valor, H: dh.valor, M: dm.valor,
ee: eeBrecha(dh, dm) }];
});
}
// Ordered cool -> warm palette from theme.scss, not a single-hue ramp: six
// series that cross each other are not separable by lightness alone.
colorEdad = grupo => {
const i = gruposEdad.indexOf(grupo);
return i < 0 ? "var(--ink)" : `var(--edad-${i + 1})`;
}
// Pushes apart end-of-line labels that would overlap. Works in pixels and
// converts back to data units, which is why both charts fix their y-domain
// instead of using nice: without a known domain there is no way back.
etiquetasSeparadas = (items, dominio, alto, margenSup, margenInf, minGap) => {
const ih = alto - margenSup - margenInf;
const [d0, d1] = dominio;
const aPixel = v => margenSup + ih * (d1 - v) / (d1 - d0);
const aValor = p => d1 - (p - margenSup) * (d1 - d0) / ih;
const arr = items.map(it => ({ ...it, y: aPixel(it.valor) })).sort((a, b) => a.y - b.y);
for (let i = 1; i < arr.length; i++) if (arr[i].y - arr[i - 1].y < minGap) arr[i].y = arr[i - 1].y + minGap;
const exceso = arr.length ? arr[arr.length - 1].y - (margenSup + ih) : 0;
if (exceso > 0) {
arr.forEach(it => { it.y -= exceso; });
for (let i = 1; i < arr.length; i++) if (arr[i].y - arr[i - 1].y < minGap) arr[i].y = arr[i - 1].y + minGap;
}
if (arr.length && arr[0].y < margenSup) { const f = margenSup - arr[0].y; arr.forEach(it => { it.y += f; }); }
return arr.map(it => ({ ...it, valorEtiqueta: aValor(it.y) }));
}
dominioBrecha = [0.30, 0.70]
dominioNiveles = [15, 68]
graficaBrechaEdad = () => {
const alto = 416, mt = 30, mb = 34;
const datosEdad = gruposEdad.flatMap(g => conHuecos(brechaPorEdad(g)));
const total = conHuecos(brechaAgregada);
// .filter(Boolean): an empty series yields an undefined last element, and
// the label separator reads .valor from it. Drop it and render without
// that label rather than throwing and losing the whole chart.
const finales = [...gruposEdad.map(g => { const s = brechaPorEdad(g); return s[s.length - 1]; }),
brechaAgregada[brechaAgregada.length - 1]].filter(Boolean);
const etiquetas = etiquetasSeparadas(finales, dominioBrecha, alto, mt, mb, 15);
return Plot.plot({
width: 680, height: alto, marginLeft: 46, marginBottom: mb, marginTop: mt, marginRight: 96,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "(men − women) / men", grid: true, domain: dominioBrecha },
x: { label: null },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
// 95% band on the national line only. Six overlapping bands would
// hide the lines; every series carries its interval in the tooltip.
Plot.areaY(total, { x: "fecha",
y1: d => d.valor == null ? undefined : d.valor - 1.96 * d.ee,
y2: d => d.valor == null ? undefined : d.valor + 1.96 * d.ee,
fill: "var(--ink)", fillOpacity: 0.09 }),
Plot.lineY(datosEdad, { x: "fecha", y: "valor", z: "grupo",
stroke: d => colorEdad(d.grupo), strokeWidth: 1.7, strokeLinejoin: "round", strokeLinecap: "round" }),
Plot.lineY(total, { x: "fecha", y: "valor",
stroke: "var(--ink)", strokeWidth: 2.6, strokeLinejoin: "round", strokeLinecap: "round" }),
Plot.link(etiquetas.filter(e => Math.abs(e.valorEtiqueta - e.valor) > 0.004), {
x1: "fecha", y1: "valor", x2: "fecha", y2: "valorEtiqueta",
stroke: d => colorEdad(d.grupo), strokeWidth: 0.75, strokeOpacity: 0.5, dx: 3 }),
Plot.text(etiquetas, { x: "fecha", y: "valorEtiqueta", text: d => d.grupo === "__total__" ? "All ages" : (etiquetaEn[d.grupo] ?? d.grupo),
dx: 10, textAnchor: "start", fontSize: 11, fill: d => colorEdad(d.grupo),
fontWeight: d => d.grupo === "__total__" ? 600 : 400 }),
// The national line belongs in the tip as well. The text below promises
// an interval for every series, and a pointer bound to datosEdad alone
// resolves a hover over the ink line to the nearest age group instead.
Plot.tip([...datosEdad, ...total].filter(d => d.valor != null), Plot.pointer({ x: "fecha", y: "valor",
title: d => `${d.anio}-Q${d.trimestre} · ${d.grupo === "__total__" ? "All ages" : (etiquetaEn[d.grupo] ?? d.grupo)}\ngap ${d.valor.toFixed(3)} ± ${(1.96 * d.ee).toFixed(3)}\nmen ${d.H}% · women ${d.M}%` })),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}
graficaNivelesMujeresEdad = () => {
const alto = 366, mt = 30, mb = 34;
const datosM = gruposEdad.flatMap(g => conHuecos(serieCruzada("Mujer", g).map(d => ({ ...d, grupo: g }))));
const finales = gruposEdad
.map(g => { const s = serieCruzada("Mujer", g); return s.length ? { ...s[s.length - 1], grupo: g } : null; })
.filter(Boolean);
const etiquetas = etiquetasSeparadas(finales, dominioNiveles, alto, mt, mb, 15);
return Plot.plot({
width: 680, height: alto, marginLeft: 46, marginBottom: mb, marginTop: mt, marginRight: 96,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "Participation (%)", grid: true, domain: dominioNiveles },
x: { label: null },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.lineY(datosM, { x: "fecha", y: "valor", z: "grupo",
stroke: d => colorEdad(d.grupo), strokeWidth: 1.9, strokeLinejoin: "round", strokeLinecap: "round" }),
Plot.link(etiquetas.filter(e => Math.abs(e.valorEtiqueta - e.valor) > 0.5), {
x1: "fecha", y1: "valor", x2: "fecha", y2: "valorEtiqueta",
stroke: d => colorEdad(d.grupo), strokeWidth: 0.75, strokeOpacity: 0.5, dx: 3 }),
Plot.text(etiquetas, { x: "fecha", y: "valorEtiqueta", text: d => d.grupo === "__total__" ? "All ages" : (etiquetaEn[d.grupo] ?? d.grupo),
dx: 10, textAnchor: "start", fontSize: 11, fill: d => colorEdad(d.grupo) }),
Plot.tip(datosM.filter(d => d.valor != null), Plot.pointer({ x: "fecha", y: "valor",
title: d => `${d.anio}-Q${d.trimestre} · women ${etiquetaEn[d.grupo] ?? d.grupo}\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp` })),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}A narrowing gap looks the same whether women enter the labor force or men leave it. The chart below shows women’s participation on its own, by age group, so the two can be told apart.
Between 2005 and 2026 the national gap narrowed from 0.50 to 0.38, its lowest value in the series. In 2005 women’s participation was about half of men’s; in 2026-Q2 it is about 62% of men’s.
Most of that narrowing came from women entering the labor force. Among women aged 30 to 39, participation rose from 49.9% in 2005 to a peak of 63.4%; among those 50 to 59, from 39.3% to 53.2%. In every age group between 20 and 59, women’s participation reached its highest value between 2023-Q4 and 2025-Q4 and has been flat or slightly lower since.
The gap has kept narrowing after those peaks. In 2026-Q2 the groups 40 to 49, 50 to 59, and 60 and over, and the national series, all show their narrowest gap on record. Since each group’s peak in women’s participation, men’s participation has fallen in five of the six groups, in each case by more than women’s.
The youngest group is the exception. Among people aged 15 to 19 the gap is essentially where it was in 2005, at 0.50, while participation fell for both women, from 24.8% to 18.0%, and men, from 49.3% to 36.1%.
All series above are original, without seasonal adjustment.
How this is built
Same pipeline and same public repository as the overview page — see its “How this is built” section for the full description. The only difference here: the source data includes the demographic and state breakdowns, which the overview page’s national figures don’t need. The shaded band and error bars are a 95% confidence interval (±1.96 standard errors) from the ENOE’s complex survey design. For the gap by age, (men − women) / men, the interval comes from the delta method applied to the two survey estimates, treating men and women as independent domains; it is drawn as a band on the national line and shown in the tooltip for every series. The state breakdown uses the 32 state-level estimates the quarterly ENOE supports (unlike the monthly release). 2020-Q2 (the ETOE, a phone-based substitute survey run during the strictest pandemic lockdown) is excluded entirely from the series rather than shown as zero or interpolated — its methodology isn’t comparable to the regular ENOE, so it’s a genuine gap, not missing data.