html`<div class="indicator-grid">${Object.keys(definiciones).map(codigo => {
const serie = nacionalDe(codigo);
const ultimo = serie[serie.length - 1];
const def = definiciones[codigo];
const div = document.createElement("div");
div.className = "indicator-card";
div.innerHTML = `<div class="indicator-name">${def.nombre}
<span class="indicator-value">${ultimo.valor}%</span>
</div>
<div class="indicator-meta">± ${(1.96 * ultimo.ee).toFixed(2)} pp · ${ultimo.anio}-Q${ultimo.trimestre}</div>
<div class="indicator-desc">${def.texto}</div>`;
return div;
})}</div>`Labor Market MX — Unemployment
Modified
August 30, 2026
ENOE, INEGI · Quarterly, 2005–2026 · By sex, age, education, and state
Third page in this series, same pattern as Participation and Informality: the labor underutilization ladder (TD, TDAMPL, SUBUTIL) broken down by cuts INEGI doesn’t publish on its own site. None of these cuts uses a custom definition: every one is the same 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.map(d => ({ ...d, fecha: new Date(d.anio, (d.trimestre - 1) * 3, 1) }))
definiciones = ({
TD: {
nombre: "Unemployment",
texto: "Share of the labor force that is not working but is actively looking for work."
},
TDAMPL: {
nombre: "Broader unemployment",
texto: "The unemployment rate, widened in both numerator and denominator to include people available to work who aren't actively searching (discouraged workers). Not a custom definition: it only combines categories ENOE already reports separately."
},
SUBUTIL: {
nombre: "Labor underutilization",
texto: "Same as broader unemployment, adding people who already have a job but work fewer hours than they'd like (underemployed). The broadest measure here of how much available labor is going unused."
}
})
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" }
})
// Same fixed order as Participation/Informality -- never sorted by
// indicator level, so the axis never implies a ranking where there's only
// a categorization.
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"]
})
// categoria_origen labels in the CSV are Spanish -- map to English for
// display. 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"
})
nacionalDe = codigo => datos
.filter(d => d.indicador === codigo && d.corte === "nacional")
.sort((a, b) => a.fecha - b.fecha)
filasCorte = (indicador, corte) => datos
.filter(d => d.indicador === indicador && d.corte === corte)
.map(d => ({ ...d, categoria_origen: etiquetaEn[d.categoria_origen] ?? d.categoria_origen }))
ultimoPeriodoCorte = (indicador, corte) => {
const filas = filasCorte(indicador, corte);
const fechaMax = new Date(Math.max(...filas.map(d => +d.fecha)));
return filas.filter(d => +d.fecha === +fechaMax);
}
serieCategoria = (indicador, corte, categoria) => filasCorte(indicador, corte)
.filter(d => d.categoria_origen === categoria)
.sort((a, b) => a.fecha - b.fecha);
graficaBarras = (filas, dominioX, etiquetaY, { 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: etiquetaY, grid: true },
marks: [
Plot.gridY({ stroke: "var(--rule)" }),
Plot.barY(filas, {
x: "categoria_origen", y: "valor",
fill: "var(--ink-soft)", 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, etiquetaY, { 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: "Greys", label: etiquetaY, legend: true
},
marks: [
Plot.geo(features, {
fill: d => d.valor,
stroke: "var(--ink)", strokeWidth: 1,
tip: true,
title: d => `${d.nombre}\n${etiquetaY}: ${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 = (indicador, corte, categorias, etiquetaY, { width = 680, height = 320 } = {}) => {
const serie = categorias.flatMap(cat => serieCategoria(indicador, corte, cat));
const serieConHuecos = categorias.flatMap(cat => conHuecos(serieCategoria(indicador, corte, cat)));
const ultimoPorCategoria = categorias.map(cat => {
const s = serieCategoria(indicador, 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: etiquetaY, 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, etiquetaY, { width = 680, height = 260, color = "var(--ink-soft)" } = {}) => {
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: etiquetaY, 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 definition and breakdown
Time series
Full history for the definition and 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. TD, TDAMPL, and SUBUTIL are the same underutilization ladder shown there, broken down further by the cuts INEGI doesn’t publish. 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). 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.