Lección 4 de 8. Los datos de una base relacional están repartidos en varias tablas a propósito, y el JOIN en SQL es cómo se vuelven a juntar: los pedidos con sus clientes, las ventas con sus productos. Veremos los tipos de JOIN con filas de verdad, no con diagramas de círculos, cómo encontrar lo que falta y el JOIN que multiplica los totales sin que te des cuenta.
Por qué hace falta unir tablas
En la base «tienda», cada pedido guarda solo el número del cliente, cliente_id, no su nombre. Es a propósito: si el nombre estuviera repetido en cada pedido y el cliente lo cambiara, habría que corregirlo en todos. La contrapartida es que, para ver los pedidos con el nombre de quien los hizo, hay que unir las dos tablas emparejando cada pedido con su cliente. Eso es un JOIN.
SELECT pedidos.id, pedidos.fecha, clientes.nombre FROM pedidos JOIN clientes ON clientes.id = pedidos.cliente_id ORDER BY pedidos.id LIMIT 5;
Ábrela en el editor y cámbiala →
resultado+----+------------+----------------+ | id | fecha | nombre | +----+------------+----------------+ | 1 | 2026-01-10 | Ana Torres | | 2 | 2026-01-15 | Luis Fernández | | 3 | 2026-01-28 | Camila Rojas | | 4 | 2026-02-04 | Ana Torres | | 5 | 2026-02-19 | Diego Martínez | +----+------------+----------------+
La condición del ON dice cómo se emparejan las filas: la clave foránea de una tabla con la clave primaria de la otra. Como las dos tablas tienen una columna id, hay que decir de cuál hablamos con tabla.columna. Para no escribir el nombre entero cada vez se usan alias de tabla, una letra o dos detrás del nombre:
SELECT p.id, p.fecha, c.nombre, c.pais FROM pedidos AS p JOIN clientes AS c ON c.id = p.cliente_id WHERE c.pais = 'España' ORDER BY p.fecha;
Ábrela en el editor y cámbiala →
resultado+----+------------+----------------+--------+ | id | fecha | nombre | pais | +----+------------+----------------+--------+ | 2 | 2026-01-15 | Luis Fernández | España | | 7 | 2026-03-11 | Luis Fernández | España | | 9 | 2026-04-08 | Lucía Gómez | España | | 16 | 2026-07-14 | Paula Morales | España | | 18 | 2026-08-21 | Luis Fernández | España | | 21 | 2026-10-02 | Lucía Gómez | España | +----+------------+----------------+--------+
INNER JOIN y LEFT JOIN
JOIN a secas es un INNER JOIN: solo deja las parejas que encajan. Un cliente sin pedidos no aparece, porque no hay ningún pedido con el que emparejarlo. Si quieres todos los clientes, tengan pedidos o no, usa LEFT JOIN: conserva todas las filas de la tabla de la izquierda, y cuando no encuentra pareja rellena las columnas de la otra tabla con NULL.
La diferencia se ve al contar pedidos por cliente. Con INNER JOIN, Tomás Ortega desaparece del informe; con LEFT JOIN sale, con cero pedidos:
SELECT c.nombre, COUNT(*) AS pedidos FROM clientes AS c JOIN pedidos AS p ON p.cliente_id = c.id GROUP BY c.id ORDER BY pedidos, c.nombre LIMIT 4;
Ábrela en el editor y cámbiala →
INNER JOIN+----------------+---------+ | nombre | pedidos | +----------------+---------+ | Mateo Herrera | 1 | | Paula Morales | 1 | | Sofía Castro | 1 | | Andrés Navarro | 2 | +----------------+---------+
SELECT c.nombre, COUNT(p.id) AS pedidos FROM clientes AS c LEFT JOIN pedidos AS p ON p.cliente_id = c.id GROUP BY c.id ORDER BY pedidos, c.nombre LIMIT 4;
Ábrela en el editor y cámbiala →
LEFT JOIN+---------------+---------+ | nombre | pedidos | +---------------+---------+ | Tomás Ortega | 0 | | Mateo Herrera | 1 | | Paula Morales | 1 | | Sofía Castro | 1 | +---------------+---------+
Fíjate en que la segunda consulta cuenta COUNT(p.id) y no COUNT(*). Para Tomás, el LEFT JOIN produce una fila, la de los NULL; COUNT(*) la contaría y diría que tiene un pedido. COUNT(p.id) no cuenta los NULL y da el cero correcto:
SELECT c.nombre, COUNT(*) AS con_asterisco, COUNT(p.id) AS con_columna FROM clientes AS c LEFT JOIN pedidos AS p ON p.cliente_id = c.id WHERE c.id = 12 GROUP BY c.id;
Ábrela en el editor y cámbiala →
resultado+--------------+---------------+-------------+ | nombre | con_asterisco | con_columna | +--------------+---------------+-------------+ | Tomás Ortega | 1 | 0 | +--------------+---------------+-------------+
Encontrar lo que falta
Esos NULL del LEFT JOIN son útiles en sí mismos: las filas sin pareja son exactamente las que tienen NULL en la clave de la otra tabla. Es la forma clásica de buscar lo que falta, como los clientes que nunca han comprado o los productos que nunca se han vendido:
SELECT c.nombre, c.alta FROM clientes AS c LEFT JOIN pedidos AS p ON p.cliente_id = c.id WHERE p.id IS NULL;
Ábrela en el editor y cámbiala →
resultado+--------------+------------+ | nombre | alta | +--------------+------------+ | Tomás Ortega | 2026-05-16 | +--------------+------------+
SELECT pr.id, pr.nombre, pr.stock FROM productos AS pr LEFT JOIN detalle AS d ON d.producto_id = pr.id WHERE d.pedido_id IS NULL;
Ábrela en el editor y cámbiala →
resultado+----+----------------+-------+ | id | nombre | stock | +----+----------------+-------+ | 15 | Cable HDMI 2 m | 200 | +----+----------------+-------+
Ojo con dónde pones las condiciones. Una condición sobre la tabla de la derecha en el WHERE descarta las filas de NULL y convierte, sin avisar, el LEFT JOIN en un INNER JOIN; si debe aplicarse solo al emparejar, va en el ON. Es una de las rarezas de SQL más frecuentes.
Varias tablas a la vez
Los JOIN se encadenan. Para saber cuánto se ha vendido de cada categoría hay que recorrer el camino entero del esquema: la línea de detalle lleva al producto, y el producto a su categoría:
SELECT cat.nombre AS categoria,
SUM(d.cantidad) AS unidades,
ROUND(SUM(d.cantidad * d.precio_unitario), 2) AS ventas
FROM detalle AS d
JOIN productos AS pr ON pr.id = d.producto_id
JOIN categorias AS cat ON cat.id = pr.categoria_id
GROUP BY cat.nombre
ORDER BY ventas DESC;Ábrela en el editor y cámbiala →
resultado+----------------+----------+---------+ | categoria | unidades | ventas | +----------------+----------+---------+ | Portátiles | 6 | 8797.96 | | Monitores | 12 | 3533.4 | | Periféricos | 17 | 1077.43 | | Almacenamiento | 21 | 1076.3 | | Redes | 7 | 697.5 | +----------------+----------+---------+
Falta algo: la base para portátil se vendió una vez, pero no tiene categoría, y el INNER JOIN con categorias la ha dejado fuera sin decir nada. Con un LEFT JOIN en ese último paso aparece, y COALESCE, que devuelve el primer valor que no sea NULL, le pone un nombre:
SELECT COALESCE(cat.nombre, '(sin categoría)') AS categoria,
SUM(d.cantidad) AS unidades,
ROUND(SUM(d.cantidad * d.precio_unitario), 2) AS ventas
FROM detalle AS d
JOIN productos AS pr ON pr.id = d.producto_id
LEFT JOIN categorias AS cat ON cat.id = pr.categoria_id
GROUP BY cat.nombre
ORDER BY ventas DESC;Ábrela en el editor y cámbiala →
resultado+-----------------+----------+---------+ | categoria | unidades | ventas | +-----------------+----------+---------+ | Portátiles | 6 | 8797.96 | | Monitores | 12 | 3533.4 | | Periféricos | 17 | 1077.43 | | Almacenamiento | 21 | 1076.3 | | Redes | 7 | 697.5 | | (sin categoría) | 1 | 29.0 | +-----------------+----------+---------+
RIGHT JOIN y FULL JOIN
RIGHT JOIN es el espejo de LEFT JOIN: conserva todas las filas de la tabla de la derecha. Casi nadie lo usa, porque se puede escribir siempre como un LEFT JOIN cambiando el orden de las tablas. FULL JOIN (o FULL OUTER JOIN) conserva las de los dos lados. SQLite los admite desde la versión 3.39:
SELECT pr.nombre AS producto, cat.nombre AS categoria FROM productos AS pr FULL JOIN categorias AS cat ON cat.id = pr.categoria_id WHERE pr.id IS NULL OR cat.id IS NULL;
Ábrela en el editor y cámbiala →
resultado+--------------------+-----------+ | producto | categoria | +--------------------+-----------+ | Base para portátil | NULL | | Cable HDMI 2 m | NULL | +--------------------+-----------+
Esta consulta busca lo que no encaja por ninguno de los dos lados: productos sin categoría y categorías sin productos. En la base de ejemplo todas las categorías tienen algún producto, así que solo salen los dos productos huérfanos.
En PostgreSQL y MySQL
PostgreSQL admite FULL JOIN desde siempre y da el mismo resultado. MySQL no lo tiene, ni siquiera en la versión 8.4, y la misma consulta da un error de sintaxis:
MySQL 8.4ERROR 1064 (42000) at line 1: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FULL JOIN categorias AS cat ON cat.id = pr.categoria_id WHERE pr.id IS NULL OR c' at line 3
En MySQL se consigue lo mismo uniendo un LEFT JOIN y un RIGHT JOIN con UNION.
Unir una tabla consigo misma
En empleados, la columna jefe_id apunta a otra fila de la misma tabla. Para ver cada empleado junto al nombre de su jefe, se une la tabla consigo misma, con dos alias distintos como si fueran dos tablas. Es una autounión:
SELECT e.nombre AS empleado, e.puesto, j.nombre AS jefe FROM empleados AS e LEFT JOIN empleados AS j ON j.id = e.jefe_id ORDER BY e.id;
Ábrela en el editor y cámbiala →
resultado+---------------+-----------------+--------------+ | empleado | puesto | jefe | +---------------+-----------------+--------------+ | Elena Vargas | Directora | NULL | | Raúl Peña | Jefe de ventas | Elena Vargas | | Marta Ruiz | Jefa de almacén | Elena Vargas | | Iván Soto | Vendedor | Raúl Peña | | Carla Medina | Vendedora | Raúl Peña | | Hugo Salas | Vendedor | Raúl Peña | | Nadia Fuentes | Mozo de almacén | Marta Ruiz | | Óscar Lara | Mozo de almacén | Marta Ruiz | +---------------+-----------------+--------------+
Es un LEFT JOIN para que la directora, que no tiene jefe, no desaparezca. Así se ve un nivel del organigrama; para recorrerlo entero, de la directora al último empleado, hace falta una consulta recursiva, que está en la lección 5.
El JOIN que multiplica
Un JOIN devuelve una fila por cada pareja que encaja. Si un cliente tiene cuatro pedidos, sus datos salen cuatro veces, y eso está bien mientras lo tengas en cuenta. El problema llega al sumar: si sumas una columna de la tabla «de uno» después de unirla con la tabla «de muchos», cada valor se suma tantas veces como parejas tenga. Mira el stock del portátil Aero 14 sumado antes y después de unirlo con sus ventas:
SELECT SUM(stock) AS stock FROM productos WHERE id = 1; SELECT SUM(pr.stock) AS stock_mal FROM productos AS pr JOIN detalle AS d ON d.producto_id = pr.id WHERE pr.id = 1;
Ábrela en el editor y cámbiala →
sin JOIN+-------+ | stock | +-------+ | 8 | +-------+
con JOIN+-----------+ | stock_mal | +-----------+ | 24 | +-----------+
El portátil aparece en tres pedidos, así que su stock de 8 se cuenta tres veces. Nadie escribiría esta consulta a propósito, pero el mismo error se cuela en informes con varias tablas unidas. La solución es agregar cada tabla por separado antes de unirlas, con una subconsulta, y está explicada en las rarezas.
Resumen
| JOIN | Devuelve | Para |
|---|---|---|
JOIN / INNER JOIN | solo las parejas que encajan | lo normal: pedidos con su cliente |
LEFT JOIN | todas las de la izquierda, con NULL si no hay pareja | incluir lo que no tiene relación; buscar lo que falta |
RIGHT JOIN | todas las de la derecha | casi nunca: mejor un LEFT JOIN al revés |
FULL JOIN | todas las de los dos lados | comparar dos tablas; no existe en MySQL |
| Autounión | una tabla unida consigo misma | jerarquías: empleado y jefe |
Retos
Reto 1. Muestra cada línea del pedido 8 con el nombre del producto, la cantidad, el precio unitario y el subtotal.
Ver la solución
SELECT pr.nombre, d.cantidad, d.precio_unitario,
ROUND(d.cantidad * d.precio_unitario, 2) AS subtotal
FROM detalle AS d
JOIN productos AS pr ON pr.id = d.producto_id
WHERE d.pedido_id = 8
ORDER BY subtotal DESC;Ábrela en el editor y cámbiala →
resultado+------------------+----------+-----------------+----------+ | nombre | cantidad | precio_unitario | subtotal | +------------------+----------+-----------------+----------+ | Portátil Aero 14 | 2 | 1299.99 | 2599.98 | | Monitor 27" 4K | 2 | 429.9 | 859.8 | | Teclado mecánico | 2 | 89.5 | 179.0 | +------------------+----------+-----------------+----------+
Reto 2. ¿Cuánto ha gastado cada cliente en pedidos entregados? Incluye a los que no han gastado nada, con un 0.
Ver la solución
SELECT c.nombre,
ROUND(COALESCE(SUM(d.cantidad * d.precio_unitario), 0), 2) AS gastado
FROM clientes AS c
LEFT JOIN pedidos AS p ON p.cliente_id = c.id AND p.estado = 'entregado'
LEFT JOIN detalle AS d ON d.pedido_id = p.id
GROUP BY c.id
ORDER BY gastado DESC;Ábrela en el editor y cámbiala →
resultado+----------------+---------+ | nombre | gastado | +----------------+---------+ | Jorge Ramírez | 3638.78 | | Luis Fernández | 3088.7 | | Diego Martínez | 2122.49 | | Ana Torres | 1944.68 | | Paula Morales | 1388.99 | | Andrés Navarro | 498.6 | | Valentina Díaz | 397.7 | | Mateo Herrera | 396.98 | | Camila Rojas | 340.88 | | Lucía Gómez | 148.0 | | Sofía Castro | 0.0 | | Tomás Ortega | 0.0 | +----------------+---------+
La condición p.estado = 'entregado' va en el ON, no en el WHERE: en el WHERE eliminaría a los clientes sin pedidos entregados. Y COALESCE convierte en 0 la suma de nada, que sería NULL.
Reto 3. ¿Qué categorías compró Ana Torres (cliente 1)? Cada categoría una sola vez.
Ver la solución
SELECT DISTINCT cat.nombre FROM pedidos AS p JOIN detalle AS d ON d.pedido_id = p.id JOIN productos AS pr ON pr.id = d.producto_id JOIN categorias AS cat ON cat.id = pr.categoria_id WHERE p.cliente_id = 1 ORDER BY cat.nombre;
Ábrela en el editor y cámbiala →
resultado+----------------+ | nombre | +----------------+ | Almacenamiento | | Monitores | | Periféricos | | Portátiles | | Redes | +----------------+
Sin DISTINCT, «Periféricos» saldría varias veces, una por cada línea de pedido de esa categoría.
Todas las consultas de esta lección se ejecutaron en SQLite 3.46.1 sobre la base de ejemplo «tienda» antes de publicarla, y los resultados están copiados de esa ejecución; las diferencias con PostgreSQL 17 y MySQL 8.4 se comprobaron en esos motores. ¿Quieres probar algo? Usa el editor de SQL, que ya trae la base cargada.
