html`<div class="indicator-grid indicator-grid-4">${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 — Informality
Modified
September 4, 2026
ENOE, INEGI · Quarterly, 2005–2026 · By sex, age, education, and state
Second page in this series, same pattern as Participation: four informality definitions, each broken down by sex, age group, education level, and state. 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 = ({
TIL1: {
nombre: "Labor informality",
texto: "Workers without the legal and social protection of a formal job (social insurance, benefits), regardless of whether the business itself is registered."
},
TIL2: {
nombre: "Labor informality, excluding agriculture",
texto: "Same as labor informality, excluding farming — a sector with structurally high informality that can obscure the trend in the rest of the economy."
},
TOSI1: {
nombre: "Informal-sector employment",
texto: "Workers employed by a business that operates without registering with the authorities — measures the type of business, not whether the individual worker has social insurance."
},
TOSI2: {
nombre: "Informal-sector employment, excluding agriculture",
texto: "Same as informal-sector employment, excluding farming."
}
})
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 -- never sorted by indicator level, so
// the axis never implies a ranking where there's only a categorization.
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"]
})
// 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",
"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"
})
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(--clay)", 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: "OrRd", 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(--clay)" } = {}) => {
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.
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. All four informality definitions are validated against INEGI’s own Banco de Indicadores before they reach this page. 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.