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}-T${ultimo.trimestre}</div>
<div class="indicator-desc">${def.texto}</div>`;
return div;
})}</div>`Mercado Laboral MX — Informalidad
Fecha de última modificación
24 de septiembre de 2026
ENOE, INEGI · Trimestral, 2005–2026 · Por sexo, edad, escolaridad y entidad
Segunda página de esta serie, mismo patrón que Participación: cuatro definiciones de informalidad, cada una desglosada por sexo, grupo de edad, nivel educativo y entidad. Ningún corte usa una definición propia: todos son la misma fórmula aplicada dentro de cada grupo, usando campos que la ENOE ya trae precodificados.
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) }))
etiquetaMarco = ({
tradicional: "ENOE",
enoen: "ENOEN",
enoe_2023: "ENOE 2023+"
})
definiciones = ({
TIL1: {
nombre: "Informalidad laboral",
texto: "Ocupados sin la protección legal y social de un empleo formal (seguridad social, prestaciones), sin importar si el negocio está registrado o no."
},
TIL2: {
nombre: "Informalidad laboral, sin agropecuario",
texto: "Lo mismo que informalidad laboral, excluyendo el campo — un sector con informalidad estructuralmente alta que puede oscurecer la tendencia del resto de la economía."
},
TOSI1: {
nombre: "Ocupación en el sector informal",
texto: "Ocupados que trabajan para un negocio que opera sin registrarse ante las autoridades — mide el tipo de negocio, no si la persona en particular tiene seguridad social."
},
TOSI2: {
nombre: "Ocupación en el sector informal, sin agropecuario",
texto: "El mismo conteo de ocupación en el sector informal, pero como proporción de la población ocupada no agropecuaria en vez de la ocupada total. El sector informal ya es no agropecuario, así que esta tasa queda por encima de la anterior."
}
})
entidadPorCodigo = ({
"1": "Aguascalientes", "2": "Baja California", "3": "Baja California Sur",
"4": "Campeche", "5": "Coahuila de Zaragoza", "6": "Colima", "7": "Chiapas",
"8": "Chihuahua", "9": "Ciudad de México", "10": "Durango", "11": "Guanajuato",
"12": "Guerrero", "13": "Hidalgo", "14": "Jalisco", "15": "México",
"16": "Michoacán de Ocampo", "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 de Ignacio de la Llave", "31": "Yucatán", "32": "Zacatecas"
})
cortes = ({
sexo: { nombre: "Sexo" },
edad: { nombre: "Edad" },
nivel_educativo: { nombre: "Nivel educativo" },
entidad: { nombre: "Entidad" }
})
// Mismo orden fijo que Participacion -- nunca ordenado por nivel del
// indicador, para no sugerir un ranking donde solo hay una categorizacion.
ordenCategorias = ({
sexo: ["Hombre", "Mujer"],
edad: ["15 a 19", "20 a 29", "30 a 39", "40 a 49", "50 a 59", "60 y más"],
nivel_educativo: ["Primaria incompleta", "Primaria completa", "Secundaria completa", "Medio superior y superior"]
})
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)
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}%`
})
]
});
}
// Un trimestre genuinamente ausente de los datos (2020-T2, el hueco de la
// ETOE -- ver periodos_validos() en el pipeline privado) no es lo mismo que
// un trimestre que Plot sepa omitir: lineY/areaY solo conectan las filas
// que existen, así que los trimestres vecinos quedan unidos por una
// diagonal recta que se lee como una transición real, no como un dato
// faltante. Insertar un punto explícito con valor indefinido en cualquier
// hueco así, para que las marcas de Plot se corten ahí en vez de conectar
// -- Plot trata un canal de posición indefinido/NaN como "inválido" e
// interrumpe la línea/área en ese punto, el mismo mecanismo que
// line().defined() en d3. Se hace spread de ...actual primero para que
// categoria_origen (necesario para agrupar por categoría en la gráfica
// multilínea) se propague; solo fecha/valor/ee se sobrescriben.
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}-T${d.trimestre} (${etiquetaMarco[d.regimen] ?? d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("es-MX")}`
})),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}
// nice:true escala automáticamente al alcance de cada marca, incluida la
// banda de confianza -- y 2020-T3 (primer trimestre de vuelta tras el hueco
// de la ETOE) tiene una banda mucho más ancha de lo normal en algunos
// estados por una muestra reducida pospandemia, lo que por sí solo puede
// estirar todo el eje Y y aplanar el resto de una serie de 20 años. Ajustar
// el dominio solo a las estimaciones puntuales, para que la incertidumbre
// de un trimestre ruidoso no distorsione la escala de todos los demás.
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}-T${d.trimestre} (${etiquetaMarco[d.regimen] ?? d.regimen})\n${d.valor}% ± ${(1.96 * d.ee).toFixed(2)} pp\nn = ${d.n_obs.toLocaleString("es-MX")}`
})),
Plot.axisX({ stroke: "var(--rule)", tickSize: 0 }),
Plot.axisY({ stroke: "var(--rule)", tickSize: 0 })
]
});
}En conjunto
Por definición y corte
Serie de tiempo
Historial completo de la definición y el corte elegidos arriba: todas las categorías juntas en una sola gráfica. Entidad es la excepción — 32 líneas encimadas no se leen — así que ahí se elige una a la vez.
Todas las series de arriba son originales, sin ajuste estacional.
Cómo está hecho
Mismo pipeline y mismo repositorio del sitio que la página de panorama — ver su sección “Cómo está hecho” para la descripción completa. Las cuatro definiciones de informalidad están validadas contra las series originales que INEGI publica en su Banco de Información Económica (BIE) antes de llegar a esta página. La banda sombreada y las líneas de error son un intervalo de 95% de confianza (±1.96 errores estándar) del diseño muestral complejo de la ENOE. El corte por entidad usa las 32 estimaciones estatales que la ENOE trimestral sí soporta (a diferencia de la mensual). 2020-T2 (la ETOE, encuesta telefónica sustituta durante el confinamiento más estricto) se excluye por completo de la serie en vez de mostrarse como cero o interpolarse — su metodología no es comparable con la ENOE regular, así que es un hueco real, no un dato faltante.