EXPLAIN ANALYZE

Mientras que EXPLAIN te muestra el plan que MySQL planea seguir para ejecutar una consulta, EXPLAIN ANALYZE va un paso más allá: ejecuta la consulta de verdad y mide los tiempos reales de cada operación. Esto te permite ver no solo cuántas filas estima MySQL que va a procesar, sino cuántas procesó realmente, cuánto tiempo tardó cada fase y dónde están los verdaderos cuellos de botella.

Esta sentencia está disponible desde MySQL 8.0.18 y se ha convertido en una herramienta indispensable para la optimización de consultas. Donde EXPLAIN te da el mapa, EXPLAIN ANALYZE te da el mapa y el cronómetro.

Diferencia fundamental con EXPLAIN

Es esencial entender que EXPLAIN ANALYZE no es una variante decorativa de EXPLAIN. La diferencia es que ejecuta la consulta completa. Esto tiene consecuencias importantes.

Cuando ejecutas un EXPLAIN normal, MySQL analiza la consulta, consulta las estadísticas de las tablas y genera el plan de ejecución sin tocar los datos. Es instantáneo y no tiene efectos secundarios. En cambio, EXPLAIN ANALYZE ejecuta cada operación del plan, mide cuánto tarda, cuenta las filas que realmente pasan por cada nodo y luego descarta el resultado para devolverte únicamente las métricas de ejecución.

Esto significa que si la consulta original tarda 30 segundos, EXPLAIN ANALYZE también tardará al menos 30 segundos. Y si la consulta sin EXPLAIN ANALYZE devolvería un millón de filas, igualmente procesará ese millón de filas internamente. No hay atajo: para medir tiempos reales, hay que ejecutar de verdad.

Sintaxis

La sintaxis es directa. Solo necesitas anteponer EXPLAIN ANALYZE a tu sentencia SELECT:

EXPLAIN ANALYZE
SELECT c.nombre, COUNT(*) AS total_pedidos
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.id
GROUP BY c.nombre;

No se puede combinar con FORMAT=JSON ni FORMAT=TABLE. EXPLAIN ANALYZE siempre devuelve el resultado en formato de árbol (el mismo formato que EXPLAIN FORMAT=TREE), pero enriquecido con métricas reales de ejecución. Cada nodo del árbol muestra tanto las estimaciones del optimizador como los valores medidos.

Leer la salida

La salida de EXPLAIN ANALYZE es un árbol de texto con indentación que representa la jerarquía de operaciones. Veamos un ejemplo completo para aprender a interpretarlo.

EXPLAIN ANALYZE
SELECT c.nombre, p.total, p.fecha
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.id
WHERE p.total > 300
ORDER BY p.total DESC;
-> Sort: p.total DESC  (actual time=0.287..0.291 rows=18 loops=1)
    -> Stream results  (actual time=0.058..0.214 rows=18 loops=1)
        -> Nested loop join  (actual time=0.055..0.196 rows=18 loops=1)
            -> Filter: (p.total > 300.00)  (cost=5.25 rows=16) (actual time=0.034..0.089 rows=18 loops=1)
                -> Table scan on p  (cost=5.25 rows=50) (actual time=0.029..0.064 rows=50 loops=1)
            -> Single-row index lookup on c using PRIMARY (id=p.cliente_id)  (cost=0.27 rows=1) (actual time=0.005..0.005 rows=1 loops=18)

Cada nodo contiene dos bloques de información. El primero, entre paréntesis con cost y rows, son las estimaciones del optimizador (las mismas que verías con EXPLAIN FORMAT=TREE). El segundo bloque, con actual time, rows y loops, son los valores reales medidos durante la ejecución.

El campo actual time muestra dos valores separados por ... El primer valor es el tiempo en milisegundos hasta que el nodo devolvió su primera fila. El segundo es el tiempo hasta que devolvió la última fila. Ambos se miden desde el inicio de la ejecución del nodo, no desde el inicio de la consulta completa.

El campo rows dentro de actual indica cuántas filas produjo realmente ese nodo en cada iteración. Compáralo con el rows de la estimación para detectar discrepancias.

El campo loops indica cuántas veces se ejecutó ese nodo. En un nested loop join, el nodo interno se ejecuta una vez por cada fila del nodo externo. En nuestro ejemplo, el lookup en clientes tiene loops=18 porque se ejecuta una vez por cada una de las 18 filas que pasaron el filtro en pedidos.

Detectar discrepancias entre estimación y realidad

Una de las aplicaciones más valiosas de EXPLAIN ANALYZE es detectar cuándo las estimaciones del optimizador están muy alejadas de la realidad. Estas discrepancias pueden llevar al optimizador a elegir un plan subóptimo.

EXPLAIN ANALYZE
SELECT *
FROM productos
WHERE categoria_id = 1 AND precio > 500;
-> Filter: ((productos.precio > 500.00) AND (productos.categoria_id = 1))  (cost=3.25 rows=3) (actual time=0.031..0.058 rows=8 loops=1)
    -> Table scan on productos  (cost=3.25 rows=30) (actual time=0.025..0.042 rows=30 loops=1)

Observa la discrepancia: el optimizador estimó que 3 filas pasarían el filtro, pero en realidad fueron 8. Una diferencia de este tipo en una tabla pequeña no tiene consecuencias graves, pero imagina que ocurre en una tabla con millones de filas donde el optimizador estima 100 filas y realmente son 500,000. En ese caso podría elegir un nested loop join cuando un hash join sería mucho más eficiente.

Cuando detectes discrepancias grandes, ejecuta ANALYZE TABLE para actualizar las estadísticas:

ANALYZE TABLE productos;

Después de actualizar las estadísticas, vuelve a ejecutar EXPLAIN ANALYZE y comprueba si las estimaciones se acercan más a la realidad. Si las discrepancias persisten, puede ser necesario usar histogramas de estadísticas (disponibles desde MySQL 8.0) para dar al optimizador información más detallada sobre la distribución de valores.

Encontrar la parte lenta de una consulta

El verdadero poder de EXPLAIN ANALYZE se muestra cuando tienes una consulta compleja y necesitas saber exactamente qué parte es la responsable de la lentitud. Veamos un ejemplo con varias tablas.

EXPLAIN ANALYZE
SELECT c.nombre, c.email,
       COUNT(p.id) AS num_pedidos,
       SUM(p.total) AS gasto_total
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.id
JOIN detalle_pedidos dp ON dp.pedido_id = p.id
JOIN productos pr ON pr.id = dp.producto_id
WHERE pr.precio > 100
GROUP BY c.id, c.nombre, c.email
ORDER BY gasto_total DESC
LIMIT 5;
-> Limit: 5 row(s)  (actual time=0.892..0.894 rows=5 loops=1)
    -> Sort: gasto_total DESC, limit input to 5 row(s) per chunk  (actual time=0.891..0.893 rows=5 loops=1)
        -> Table scan on <temporary>  (actual time=0.812..0.821 rows=15 loops=1)
            -> Aggregate using temporary table  (actual time=0.810..0.810 rows=15 loops=1)
                -> Nested loop join  (actual time=0.089..0.685 rows=62 loops=1)
                    -> Nested loop join  (actual time=0.072..0.425 rows=62 loops=1)
                        -> Nested loop join  (actual time=0.051..0.178 rows=22 loops=1)
                            -> Filter: (pr.precio > 100.00)  (cost=3.25 rows=10) (actual time=0.032..0.065 rows=22 loops=1)
                                -> Table scan on pr  (cost=3.25 rows=30) (actual time=0.027..0.048 rows=30 loops=1)
                            -> Index lookup on dp using producto_id (producto_id=pr.id)  (cost=0.62 rows=2) (actual time=0.004..0.004 rows=1 loops=22)
                        -> Single-row index lookup on p using PRIMARY (id=dp.pedido_id)  (cost=0.27 rows=1) (actual time=0.003..0.003 rows=1 loops=22)
                    -> Single-row index lookup on c using PRIMARY (id=p.cliente_id)  (cost=0.27 rows=1) (actual time=0.003..0.004 rows=1 loops=62)

Para encontrar el cuello de botella, busca los nodos con el mayor actual time y el mayor número de rows. En este caso, la operación de agregación con tabla temporal (Aggregate using temporary table) acumula la mayor parte del tiempo (0.810 ms). Para una consulta más compleja con tablas grandes, esta misma estructura de análisis te permitiría identificar si el problema está en un escaneo de tabla, un join ineficiente o una ordenación costosa.

La regla general es mirar el segundo valor de actual time (el tiempo total del nodo) y el campo loops. El coste real de un nodo es su actual time multiplicado por loops. Un nodo que tarda 0.5 ms pero se ejecuta 10,000 veces (loops=10000) consume 5 segundos en total.

Ejemplo práctico: optimizar paso a paso

Supongamos que tienes una consulta que busca los pedidos recientes de clientes de una ciudad específica y quieres optimizarla. Primero analizamos el estado actual.

EXPLAIN ANALYZE
SELECT c.nombre, p.fecha, p.total
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.id
WHERE c.ciudad = 'Barcelona'
  AND p.fecha >= '2026-01-01'
ORDER BY p.fecha DESC;
-> Sort: p.fecha DESC  (actual time=0.385..0.389 rows=6 loops=1)
    -> Stream results  (actual time=0.098..0.342 rows=6 loops=1)
        -> Nested loop join  (actual time=0.094..0.328 rows=6 loops=1)
            -> Filter: (c.ciudad = 'Barcelona')  (cost=2.05 rows=3) (actual time=0.042..0.085 rows=4 loops=1)
                -> Table scan on c  (cost=2.05 rows=20) (actual time=0.035..0.068 rows=20 loops=1)
            -> Filter: (p.fecha >= '2026-01-01')  (cost=1.02 rows=5) (actual time=0.028..0.055 rows=2 loops=4)
                -> Index lookup on p using cliente_id (cliente_id=c.id)  (cost=1.02 rows=5) (actual time=0.024..0.042 rows=5 loops=4)

El Table scan on c nos indica que no hay índice sobre la columna ciudad de clientes. Creamos uno:

CREATE INDEX idx_ciudad ON clientes (ciudad);

Después volvemos a ejecutar EXPLAIN ANALYZE para medir el impacto:

EXPLAIN ANALYZE
SELECT c.nombre, p.fecha, p.total
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.id
WHERE c.ciudad = 'Barcelona'
  AND p.fecha >= '2026-01-01'
ORDER BY p.fecha DESC;
-> Sort: p.fecha DESC  (actual time=0.198..0.201 rows=6 loops=1)
    -> Stream results  (actual time=0.052..0.168 rows=6 loops=1)
        -> Nested loop join  (actual time=0.048..0.155 rows=6 loops=1)
            -> Index lookup on c using idx_ciudad (ciudad='Barcelona')  (cost=1.12 rows=4) (actual time=0.028..0.035 rows=4 loops=1)
            -> Filter: (p.fecha >= '2026-01-01')  (cost=1.02 rows=5) (actual time=0.018..0.027 rows=2 loops=4)
                -> Index lookup on p using cliente_id (cliente_id=c.id)  (cost=1.02 rows=5) (actual time=0.014..0.021 rows=5 loops=4)

El Table scan on c desapareció y fue reemplazado por un Index lookup on c using idx_ciudad. El tiempo total de la consulta bajó de 0.389 ms a 0.201 ms. En tablas con miles o millones de filas, esta diferencia se amplificaría enormemente.

Precaución con sentencias que modifican datos

Dado que EXPLAIN ANALYZE ejecuta la consulta real, debes tener mucho cuidado al usarlo con sentencias que modifican datos. En MySQL, EXPLAIN ANALYZE solo funciona con sentencias SELECT. No puedes usarlo directamente con UPDATE, DELETE o INSERT.

Sin embargo, hay una trampa sutil. Si tu SELECT contiene una función almacenada que modifica datos, o una subconsulta que activa un trigger con efectos secundarios, esas modificaciones sí se ejecutarán. EXPLAIN ANALYZE ejecuta la consulta de verdad, así que cualquier efecto secundario durante la ejecución ocurrirá.

La recomendación es utilizar EXPLAIN ANALYZE siempre en un entorno de desarrollo o staging, nunca directamente en producción con consultas que no conozcas bien. Si necesitas analizar una consulta en producción, empieza con un EXPLAIN normal (que no ejecuta la consulta) y solo usa EXPLAIN ANALYZE cuando estés seguro de que es seguro ejecutarla.

Interpretar tiempos muy pequeños

Cuando trabajas con tablas pequeñas, los tiempos que muestra EXPLAIN ANALYZE serán fracciones de milisegundo, lo que puede hacer difícil distinguir entre un plan bueno y uno malo. No te dejes engañar por la aparente velocidad.

Un full table scan de 30 filas tarda 0.05 ms. El mismo patrón en una tabla con 3 millones de filas tardará varias decenas de segundos. Lo que importa no es el tiempo absoluto que ves en desarrollo, sino la estructura del plan: si hay full table scans, tablas temporales, ordenaciones externas (filesort) o nested loops con muchas iteraciones. Estos patrones que son imperceptibles con datos de prueba se convierten en los peores cuellos de botella cuando la base de datos crece.

Para obtener mediciones más significativas en desarrollo, puedes cargar datos de prueba representativos. Tablas con al menos decenas de miles de filas te darán tiempos lo suficientemente altos como para distinguir entre planes buenos y malos.

EXPLAIN ANALYZE vs EXPLAIN vs Profiling

Cada herramienta de diagnóstico tiene su lugar en el flujo de trabajo de optimización.

Usa EXPLAIN como primer paso. Es instantáneo, no ejecuta la consulta y te da una visión general del plan. Con EXPLAIN puedes detectar rápidamente full table scans, joins sin índice y subconsultas mal optimizadas. Es seguro usarlo en producción.

Usa EXPLAIN ANALYZE cuando necesites confirmar que las estimaciones del optimizador son correctas, cuando quieras medir los tiempos reales de cada fase, o cuando una consulta es lenta pero el plan de EXPLAIN parece razonable. Recuerda que ejecuta la consulta, así que úsalo con precaución en producción.

Usa profiling cuando necesites un desglose temporal de las fases internas de MySQL (parsing, optimización, ejecución, envío de datos). El profiling te muestra información diferente a EXPLAIN ANALYZE: mientras que EXPLAIN ANALYZE se centra en las operaciones del plan de ejecución (scans, joins, sorts), el profiling te muestra las fases del servidor (opening tables, sending data, sorting result).

Lo habitual es empezar con EXPLAIN para obtener una visión rápida, pasar a EXPLAIN ANALYZE si necesitas mediciones reales, y recurrir al profiling solo cuando necesites diagnosticar problemas que no se reflejan en el plan de ejecución, como tiempos elevados en apertura de tablas o adquisición de locks.

Errores comunes

El error más frecuente con EXPLAIN ANALYZE es usarlo para medir tiempos absolutos y tomar esos números como referencia de rendimiento. Los tiempos varían dependiendo de la carga del servidor, el estado de la caché, la concurrencia de otras consultas y muchos otros factores. Ejecuta EXPLAIN ANALYZE varias veces y observa la tendencia, no un valor aislado.

Otro error habitual es olvidar que EXPLAIN ANALYZE incluye el tiempo de descarte del resultado. La consulta se ejecuta completa pero el resultado no se envía al cliente, sino que se descarta después de recopilar las métricas. Esto significa que el tiempo total puede ser ligeramente diferente al tiempo real de la consulta cuando la ejecutas sin EXPLAIN ANALYZE, especialmente si el resultado es muy grande y el envío de datos al cliente es significativo.

También es un error usar EXPLAIN ANALYZE en consultas extremadamente lentas sin antes revisarlas con EXPLAIN. Si una consulta tarda 10 minutos, EXPLAIN ANALYZE también tardará al menos 10 minutos. Empieza siempre con EXPLAIN para tener una idea del problema, y luego usa EXPLAIN ANALYZE cuando tengas una hipótesis que verificar.

Cuándo usar EXPLAIN ANALYZE

Usa EXPLAIN ANALYZE cuando las estimaciones de EXPLAIN no coincidan con el comportamiento que observas, cuando necesites demostrar con datos que un cambio (un nuevo índice, una reescritura de la consulta) realmente mejora el rendimiento, o cuando quieras entender exactamente cómo fluyen los datos a través de cada operación del plan.

Es especialmente útil en procesos de optimización de consultas donde necesitas comparar el antes y el después de un cambio. Ejecuta EXPLAIN ANALYZE antes de aplicar tu optimización, guarda la salida, aplica el cambio, y ejecútalo de nuevo. La comparación directa de tiempos y filas te confirmará si la optimización tuvo el efecto esperado.

En combinación con EXPLAIN para diagnóstico rápido y profiling para análisis de fases internas, EXPLAIN ANALYZE completa el conjunto de herramientas que necesitas para mantener tus consultas MySQL funcionando de forma óptima.

Escrito por Eduardo Lázaro