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
August 30, 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 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 = ({
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: ["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(--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}%`
})
]
});
}
graficaSerieMultiple = (indicador, corte, categorias, etiquetaY, { width = 680, height = 320 } = {}) => {
const serie = categorias.flatMap(cat => 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(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, etiquetaY, { width = 680, height = 260, color = "var(--clay)" } = {}) => {
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: etiquetaY, 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 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 — 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).