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
August 30, 2026
ENOE, INEGI · Quarterly, 2005–2026 · By sex, age, education, and state
Labor force participation broken down by cuts INEGI doesn’t publish on its own site — 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: ["14 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",
"14 a 19": "14 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}%`
})
]
});
}
graficaSerieMultiple = (corte, categorias, { width = 680, height = 320 } = {}) => {
const serie = categorias.flatMap(cat => 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(serie, {
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 })
]
});
}
graficaSerieCorte = (serie, { width = 680, height = 260, color = "var(--teal)" } = {}) => {
const ultimo = serie[serie.length - 1];
return Plot.plot({
width, height, marginLeft: 46, marginBottom: 32,
style: { background: "transparent", fontFamily: "IBM Plex Sans, sans-serif", fontSize: "13px" },
y: { label: "%", grid: true, nice: true },
x: { label: null },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.areaY(serie, {
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(serie, {
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.
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. The state breakdown uses the 32 state-level estimates the quarterly ENOE supports (unlike the monthly release).