A diferencia del INNER JOIN, que solo devuelve filas con correspondencia en ambas tablas, los JOINs externos incluyen también filas que no tienen pareja en la otra tabla, rellenando con NULL las columnas de la tabla que no tiene correspondencia. Son esenciales para responder preguntas como «¿qué clientes no han hecho ningún pedido?» o «¿qué productos nunca se han vendido?».
En este capítulo cubrimos LEFT JOIN, RIGHT JOIN, cómo detectar registros huérfanos y la simulación de FULL OUTER JOIN en MySQL, que no lo soporta de forma nativa.
LEFT JOIN: todas las filas de la tabla izquierda
-- Todos los clientes, tengan o no pedidos
-- Las columnas de pedidos serán NULL para clientes sin pedidos
SELECT
c.nombre,
c.email,
p.id AS pedido_id,
p.estado,
p.total
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
ORDER BY c.nombre, p.fecha;
Detectar filas sin correspondencia
-- Clientes que NO han hecho ningún pedido SELECT c.nombre, c.email FROM clientes c LEFT JOIN pedidos p ON p.id_cliente = c.id WHERE p.id IS NULL; -- Productos que nunca han sido vendidos (no aparecen en lineas_pedido) SELECT pr.nombre, pr.precio, pr.stock FROM productos pr LEFT JOIN lineas_pedido lp ON lp.id_producto = pr.id WHERE lp.id IS NULL;
El patrón LEFT JOIN ... WHERE tabla_derecha.columna IS NULL es la forma estándar de encontrar registros huérfanos o elementos que no participan en una relación. Es más eficiente que usar NOT IN o NOT EXISTS en la mayoría de los casos.
Contar con LEFT JOIN
-- Número de pedidos por cliente (incluidos los que tienen cero pedidos)
SELECT
c.nombre,
COUNT(p.id) AS num_pedidos -- COUNT(columna) no cuenta NULL
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre
ORDER BY num_pedidos DESC;
RIGHT JOIN
RIGHT JOIN es el espejo del LEFT JOIN: incluye todas las filas de la tabla de la derecha aunque no tengan correspondencia en la de la izquierda. En la práctica, casi siempre se puede reescribir como un LEFT JOIN invirtiendo el orden de las tablas, y la mayoría de los desarrolladores prefieren usar siempre LEFT JOIN para mantener consistencia:
-- Estos dos son equivalentes: SELECT c.nombre, p.id FROM pedidos p RIGHT JOIN clientes c ON c.id = p.id_cliente; SELECT c.nombre, p.id FROM clientes c LEFT JOIN pedidos p ON p.id_cliente = c.id;
Condiciones en ON vs WHERE con LEFT JOIN
-- Diferencia crítica: filtrar en ON vs WHERE con LEFT JOIN -- Esto devuelve TODOS los clientes; para los de Madrid, muestra pedidos pagados -- Para el resto, muestra sus pedidos (de cualquier estado) o NULL si no hay pedidos SELECT c.nombre, p.estado FROM clientes c LEFT JOIN pedidos p ON p.id_cliente = c.id AND p.estado = 'pagado'; -- Esto devuelve SOLO clientes con al menos un pedido pagado -- (el LEFT JOIN se convierte efectivamente en INNER JOIN) SELECT c.nombre, p.estado FROM clientes c LEFT JOIN pedidos p ON p.id_cliente = c.id WHERE p.estado = 'pagado';
FULL OUTER JOIN: todos los registros de ambas tablas
MySQL no soporta FULL OUTER JOIN de forma nativa. La solución es combinar LEFT JOIN y RIGHT JOIN con UNION:
-- Simular FULL OUTER JOIN en MySQL SELECT c.nombre AS cliente, p.id AS pedido FROM clientes c LEFT JOIN pedidos p ON p.id_cliente = c.id UNION SELECT c.nombre AS cliente, p.id AS pedido FROM clientes c RIGHT JOIN pedidos p ON p.id_cliente = c.id WHERE c.id IS NULL; -- PostgreSQL tiene FULL OUTER JOIN nativo: SELECT c.nombre, p.id FROM clientes c FULL OUTER JOIN pedidos p ON p.id_cliente = c.id;
Múltiples LEFT JOINs
-- Todos los clientes con su último pedido y el número de productos que compraron
SELECT
c.nombre,
MAX(p.fecha) AS ultimo_pedido,
COUNT(DISTINCT lp.id_producto) AS productos_distintos
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
LEFT JOIN lineas_pedido lp ON lp.id_pedido = p.id
GROUP BY c.id, c.nombre
ORDER BY ultimo_pedido DESC NULLS LAST;
