Una subconsulta (también llamada subquery o consulta anidada) es un SELECT dentro de otro SELECT. Se puede usar en el WHERE, en el FROM o incluso en el SELECT. Permiten responder preguntas complejas en una sola instrucción SQL, sin necesidad de guardar resultados intermedios en variables o tablas temporales.
En este capítulo cubrimos los tipos más usados: subconsultas escalares, subconsultas en IN / NOT IN, EXISTS / NOT EXISTS, subconsultas correlacionadas y tablas derivadas (subquery en el FROM).
Subconsulta escalar en WHERE
-- Productos cuyo precio es superior a la media
SELECT nombre, precio
FROM productos
WHERE precio > (SELECT AVG(precio) FROM productos)
ORDER BY precio DESC;
-- La subconsulta devuelve un único valor (escalar)
-- Si devolviera más de una fila, la consulta fallaría
-- Producto más caro de cada categoría
SELECT p.nombre, p.precio, c.nombre AS categoria
FROM productos p
JOIN categorias c ON c.id = p.id_categoria
WHERE p.precio = (
SELECT MAX(precio) FROM productos WHERE id_categoria = p.id_categoria
);
IN con subconsulta
-- Clientes que han hecho al menos un pedido
SELECT nombre, email FROM clientes
WHERE id IN (SELECT DISTINCT id_cliente FROM pedidos);
-- Clientes que han pedido productos de la categoría 'Electrónica'
SELECT DISTINCT c.nombre
FROM clientes c
WHERE c.id IN (
SELECT p.id_cliente
FROM pedidos p
JOIN lineas_pedido lp ON lp.id_pedido = p.id
JOIN productos pr ON pr.id = lp.id_producto
JOIN categorias cat ON cat.id = pr.id_categoria
WHERE cat.nombre = 'Electrónica'
);
NOT IN: cuidado con los NULL
-- Clientes que NO han hecho ningún pedido SELECT nombre FROM clientes WHERE id NOT IN (SELECT id_cliente FROM pedidos); -- TRAMPA: si la subconsulta devuelve algún NULL, NOT IN devuelve vacío para todos -- Porque: 1 NOT IN (2, 3, NULL) → 1 != 2 AND 1 != 3 AND 1 != NULL → AND NULL → NULL (falso) -- Solución: filtrar NULLs en la subconsulta WHERE id NOT IN (SELECT id_cliente FROM pedidos WHERE id_cliente IS NOT NULL); -- O usar NOT EXISTS, que no tiene este problema:
EXISTS y NOT EXISTS
-- Clientes con al menos un pedido (EXISTS) SELECT nombre FROM clientes c WHERE EXISTS (SELECT 1 FROM pedidos WHERE id_cliente = c.id); -- Clientes sin ningún pedido (NOT EXISTS) — equivale al LEFT JOIN IS NULL SELECT nombre FROM clientes c WHERE NOT EXISTS (SELECT 1 FROM pedidos WHERE id_cliente = c.id); -- EXISTS detiene la búsqueda en cuanto encuentra la primera fila que cumple -- la condición (es eficiente aunque la subconsulta devuelva muchas filas)
Subconsulta correlacionada
Una subconsulta correlacionada hace referencia a una columna de la consulta externa. Se ejecuta una vez por cada fila de la consulta externa:
-- Para cada cliente, obtener el total de su último pedido
SELECT
c.nombre,
(SELECT p.total
FROM pedidos p
WHERE p.id_cliente = c.id
ORDER BY p.fecha DESC
LIMIT 1) AS ultimo_total
FROM clientes c;
-- Si un cliente no tiene pedidos, la subconsulta devuelve NULL
Tabla derivada (subquery en FROM)
-- Calcular el precio medio por categoría y comparar cada producto con él
SELECT
pr.nombre,
pr.precio,
medias.precio_medio_cat,
ROUND(pr.precio - medias.precio_medio_cat, 2) AS diferencia
FROM productos pr
JOIN (
SELECT id_categoria, ROUND(AVG(precio), 2) AS precio_medio_cat
FROM productos
GROUP BY id_categoria
) AS medias ON medias.id_categoria = pr.id_categoria
ORDER BY pr.id_categoria, diferencia DESC;
WITH (CTE): alternativa más legible a las tablas derivadas
Las Common Table Expressions (CTEs), disponibles en MySQL 8.0 y PostgreSQL, hacen más legibles las subconsultas complejas:
WITH medias_cat AS (
SELECT id_categoria, ROUND(AVG(precio), 2) AS precio_medio
FROM productos
GROUP BY id_categoria
)
SELECT
pr.nombre,
pr.precio,
m.precio_medio,
ROUND(pr.precio - m.precio_medio, 2) AS diferencia
FROM productos pr
JOIN medias_cat m ON m.id_categoria = pr.id_categoria;
