Tema
JOINs
Hasta ahora consultaste una tabla a la vez. Pero la información útil casi siempre está repartida: el pedido tiene el cliente_id, y el nombre del cliente está en otra tabla. Los JOINs combinan tablas usando sus claves. Es el tema que separa a quien "sabe un poco de SQL" de quien realmente lo domina, así que aquí lo veremos con diagramas y resultados de cada tipo.
Concepto
Un JOIN es como unir dos listas por un dato en común. Tienes una lista de pedidos con número de cliente y una libreta de clientes con su nombre; para cada pedido buscas al cliente con ese número y pegas la información en la misma fila.
txt
pedidos clientes
┌────┬────────────┐ ┌────┬─────────────┐
│ id │ cliente_id │──── busca ───►│ id │ nombre │
├────┼────────────┤ ├────┼─────────────┤
│ 1 │ 1 │ │ 1 │ Ana López │
│ 2 │ 2 │ │ 2 │ Carlos Ruiz │
└────┴────────────┘ └────┴─────────────┘
resultado
┌───────────┬────────────┬─────────────┐
│ pedido_id │ cliente_id │ nombre │
├───────────┼────────────┼─────────────┤
│ 1 │ 1 │ Ana López │
│ 2 │ 2 │ Carlos Ruiz │
└───────────┴────────────┴─────────────┘Sintaxis general:
sql
SELECT columnas
FROM tabla_a
[INNER | LEFT | RIGHT | FULL] JOIN tabla_b
ON tabla_a.columna = tabla_b.columna;Los tipos de JOIN de un vistazo
txt
INNER JOIN LEFT JOIN RIGHT JOIN FULL JOIN
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ A ├█┤ B │ │███├█┤ B │ │ A ├█┤███│ │███├█┤███│
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘
solo coincidencias todo A + coincid. todo B + coincid. todo A y todo B| Tipo | Devuelve | Si no hay pareja |
|---|---|---|
INNER JOIN | Solo filas con coincidencia en ambas tablas | Se descarta |
LEFT JOIN | Todas las filas de la izquierda | Columnas de la derecha en NULL |
RIGHT JOIN | Todas las filas de la derecha | Columnas de la izquierda en NULL |
FULL JOIN | Todas las filas de ambas | NULL del lado que falte |
CROSS JOIN | Todas las combinaciones posibles | — |
Recuerda los "casos especiales" de nuestros datos: el Cable USB-C no tiene categoría, la categoría Deportes no tiene productos y Lucía no tiene pedidos. Con ellos verás la diferencia entre cada JOIN.
INNER JOIN
sql
SELECT p.nombre AS producto, c.nombre AS categoria, p.precio
FROM productos p
INNER JOIN categorias c ON p.categoria_id = c.id
ORDER BY c.nombre, p.nombre;| producto | categoria | precio |
|---|---|---|
| Audífonos Bluetooth | Electrónica | 899.00 |
| Monitor 24 pulgadas | Electrónica | 3499.00 |
| Teclado mecánico | Electrónica | 1250.00 |
| Lámpara de escritorio | Hogar | 459.00 |
| Taza térmica | Hogar | 199.00 |
| Cien años de soledad | Libros | 289.00 |
| Libro Aprende SQL | Libros | 349.00 |
Son 7 filas: el Cable USB-C desaparece (no tiene categoría con la cual coincidir) y Deportes tampoco aparece (ningún producto la referencia).
Detalles de sintaxis:
productos pdefine un alias de tabla: escribesp.nombreen lugar deproductos.nombre.- Cuando dos tablas tienen columnas con el mismo nombre (
nombre,id), debes indicar de qué tabla es cada una. JOINa secas equivale aINNER JOIN.
Buena práctica
Usa siempre la sintaxis JOIN ... ON. La forma antigua FROM a, b WHERE a.id = b.a_id funciona, pero mezcla las condiciones de unión con los filtros y si olvidas la condición obtienes un producto cartesiano gigante.
LEFT JOIN
Devuelve todas las filas de la tabla izquierda, tengan o no pareja.
sql
SELECT c.nombre AS categoria, p.nombre AS producto
FROM categorias c
LEFT JOIN productos p ON p.categoria_id = c.id
ORDER BY c.id, p.id;| categoria | producto |
|---|---|
| Electrónica | Audífonos Bluetooth |
| Electrónica | Teclado mecánico |
| Electrónica | Monitor 24 pulgadas |
| Libros | Libro Aprende SQL |
| Libros | Cien años de soledad |
| Hogar | Lámpara de escritorio |
| Hogar | Taza térmica |
| Deportes | NULL |
Ahora Deportes sí aparece, con NULL en el producto.
Contar incluyendo los ceros
Caso real: "pedidos por cliente, incluidos los que no han comprado".
sql
SELECT cl.nombre, COUNT(pe.id) AS pedidos
FROM clientes cl
LEFT JOIN pedidos pe ON pe.cliente_id = cl.id
GROUP BY cl.id, cl.nombre
ORDER BY pedidos DESC, cl.nombre;| nombre | pedidos |
|---|---|
| Ana López | 2 |
| Carlos Ruiz | 1 |
| Jorge Díaz | 1 |
| María Pérez | 1 |
| Lucía Gómez | 0 |
Truco clave: se usa COUNT(pe.id) y no COUNT(*). Para Lucía la fila existe (con NULL en las columnas del pedido), así que COUNT(*) daría 1; COUNT(pe.id) ignora el NULL y da 0.
Anti-join: lo que NO tiene pareja
sql
-- Clientes que nunca han hecho un pedido
SELECT cl.nombre, cl.email
FROM clientes cl
LEFT JOIN pedidos pe ON pe.cliente_id = cl.id
WHERE pe.id IS NULL;| nombre | |
|---|---|
| Lucía Gómez | lucia@correo.com |
Muy útil para campañas de marketing ("clientes sin compras") o limpieza de datos ("categorías vacías").
Error común: filtrar la tabla derecha en el WHERE
sql
-- Intención: todos los clientes con sus pedidos ENTREGADOS (0 si no tienen)
SELECT cl.nombre, COUNT(pe.id) AS entregados
FROM clientes cl
LEFT JOIN pedidos pe ON pe.cliente_id = cl.id
WHERE pe.estado = 'entregado' -- ¡convierte el LEFT en INNER!
GROUP BY cl.id, cl.nombre;Solo salen Ana y Carlos: el WHERE elimina las filas con NULL. La condición sobre la tabla derecha va en el ON:
sql
SELECT cl.nombre, COUNT(pe.id) AS entregados
FROM clientes cl
LEFT JOIN pedidos pe ON pe.cliente_id = cl.id AND pe.estado = 'entregado'
GROUP BY cl.id, cl.nombre
ORDER BY cl.id;| nombre | entregados |
|---|---|
| Ana López | 1 |
| Carlos Ruiz | 1 |
| María Pérez | 0 |
| Jorge Díaz | 0 |
| Lucía Gómez | 0 |
RIGHT JOIN
Es un LEFT JOIN al revés: conserva todas las filas de la tabla derecha.
sql
SELECT p.nombre AS producto, c.nombre AS categoria
FROM productos p
RIGHT JOIN categorias c ON p.categoria_id = c.id
ORDER BY c.id;Da el mismo resultado que el LEFT JOIN de categorías de arriba (Deportes con producto NULL). En la práctica casi todo el mundo usa LEFT JOIN y acomoda el orden de las tablas, porque se lee más natural.
FULL OUTER JOIN
Conserva todas las filas de ambas tablas.
sql
-- PostgreSQL, SQL Server y SQLite 3.39+
SELECT p.nombre AS producto, c.nombre AS categoria
FROM productos p
FULL OUTER JOIN categorias c ON p.categoria_id = c.id
ORDER BY c.id, p.id;| producto | categoria |
|---|---|
| Audífonos Bluetooth | Electrónica |
| Teclado mecánico | Electrónica |
| Monitor 24 pulgadas | Electrónica |
| Libro Aprende SQL | Libros |
| Cien años de soledad | Libros |
| Lámpara de escritorio | Hogar |
| Taza térmica | Hogar |
| NULL | Deportes |
| Cable USB-C | NULL |
Aparecen ambos casos especiales: Deportes sin producto y el Cable sin categoría.
Emulación en MySQL
MySQL no tiene FULL JOIN. Se emula uniendo un LEFT y un RIGHT con UNION (que elimina duplicados):
sql
SELECT p.nombre AS producto, c.nombre AS categoria
FROM productos p
LEFT JOIN categorias c ON p.categoria_id = c.id
UNION
SELECT p.nombre, c.nombre
FROM productos p
RIGHT JOIN categorias c ON p.categoria_id = c.id;UNION vs UNION ALL
UNION junta los resultados de dos consultas y elimina filas repetidas. UNION ALL las conserva (y es más rápido). Ambas consultas deben tener el mismo número de columnas con tipos compatibles.
CROSS JOIN
Combina cada fila de una tabla con cada fila de la otra (producto cartesiano). Si una tabla tiene 3 filas y la otra 4, salen 12.
sql
-- Generar todas las variantes de una playera
CREATE TABLE tallas (talla VARCHAR(5));
CREATE TABLE colores (color VARCHAR(20));
INSERT INTO tallas VALUES ('CH'), ('M'), ('G');
INSERT INTO colores VALUES ('Negro'), ('Blanco');
SELECT t.talla, c.color
FROM tallas t
CROSS JOIN colores c
ORDER BY t.talla, c.color;| talla | color |
|---|---|
| CH | Blanco |
| CH | Negro |
| G | Blanco |
| G | Negro |
| M | Blanco |
| M | Negro |
Útil para generar combinaciones, calendarios o matrices de reportes. Peligroso por accidente: dos tablas de 10,000 filas generan 100 millones.
SELF JOIN: una tabla consigo misma
Se une una tabla con ella misma usando dos alias. Ejemplo: parejas de clientes que viven en la misma ciudad.
sql
SELECT a.nombre AS cliente_1, b.nombre AS cliente_2, a.ciudad
FROM clientes a
JOIN clientes b
ON a.ciudad = b.ciudad
AND a.id < b.id; -- evita (Ana, Ana) y parejas repetidas (Jorge, Ana)| cliente_1 | cliente_2 | ciudad |
|---|---|---|
| Ana López | Jorge Díaz | Ciudad de México |
El uso más común del self join es con jerarquías: una tabla empleados con columna jefe_id que apunta a otro empleado.
sql
CREATE TABLE empleados (
id INT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
jefe_id INT NULL,
FOREIGN KEY (jefe_id) REFERENCES empleados(id)
);
INSERT INTO empleados VALUES
(1, 'Laura (Directora)', NULL),
(2, 'Mario (Ventas)', 1),
(3, 'Sofía (Sistemas)', 1),
(4, 'Iván (Vendedor)', 2);
SELECT e.nombre AS empleado, j.nombre AS jefe
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.id
ORDER BY e.id;| empleado | jefe |
|---|---|
| Laura (Directora) | NULL |
| Mario (Ventas) | Laura (Directora) |
| Sofía (Sistemas) | Laura (Directora) |
| Iván (Vendedor) | Mario (Ventas) |
Unir más de dos tablas
Los JOINs se encadenan. El detalle completo de cada pedido necesita cuatro tablas:
txt
clientes ◄── pedidos ◄── detalle_pedido ──► productossql
SELECT
pe.id AS pedido,
pe.fecha,
cl.nombre AS cliente,
pr.nombre AS producto,
d.cantidad,
d.precio_unitario,
d.cantidad * d.precio_unitario AS subtotal
FROM pedidos pe
JOIN clientes cl ON cl.id = pe.cliente_id
JOIN detalle_pedido d ON d.pedido_id = pe.id
JOIN productos pr ON pr.id = d.producto_id
ORDER BY pe.id, pr.nombre;| pedido | fecha | cliente | producto | cantidad | precio_unitario | subtotal |
|---|---|---|---|---|---|---|
| 1 | 2024-05-02 | Ana López | Audífonos Bluetooth | 1 | 899.00 | 899.00 |
| 1 | 2024-05-02 | Ana López | Libro Aprende SQL | 2 | 349.00 | 698.00 |
| 2 | 2024-05-10 | Carlos Ruiz | Taza térmica | 3 | 199.00 | 597.00 |
| 2 | 2024-05-10 | Carlos Ruiz | Teclado mecánico | 1 | 1250.00 | 1250.00 |
| 3 | 2024-06-01 | Ana López | Monitor 24 pulgadas | 1 | 3499.00 | 3499.00 |
| 4 | 2024-06-15 | María Pérez | Cien años de soledad | 1 | 289.00 | 289.00 |
| 4 | 2024-06-15 | María Pérez | Lámpara de escritorio | 1 | 459.00 | 459.00 |
| 4 | 2024-06-15 | María Pérez | Taza térmica | 2 | 199.00 | 398.00 |
| 5 | 2024-06-20 | Jorge Díaz | Audífonos Bluetooth | 1 | 899.00 | 899.00 |
JOIN + GROUP BY: cuánto ha gastado cada cliente
sql
SELECT
cl.nombre,
COUNT(DISTINCT pe.id) AS pedidos,
COALESCE(SUM(d.cantidad * d.precio_unitario), 0) AS total_gastado
FROM clientes cl
LEFT JOIN pedidos pe ON pe.cliente_id = cl.id AND pe.estado <> 'cancelado'
LEFT JOIN detalle_pedido d ON d.pedido_id = pe.id
GROUP BY cl.id, cl.nombre
ORDER BY total_gastado DESC;| nombre | pedidos | total_gastado |
|---|---|---|
| Ana López | 2 | 5096.00 |
| Carlos Ruiz | 1 | 1847.00 |
| María Pérez | 1 | 1146.00 |
| Jorge Díaz | 0 | 0 |
| Lucía Gómez | 0 | 0 |
COALESCE(..., 0) cambia el NULL de quien no compró por un 0. Jorge aparece con 0 porque su único pedido está cancelado.
Ventas por categoría
sql
SELECT
COALESCE(c.nombre, 'Sin categoría') AS categoria,
SUM(d.cantidad) AS piezas,
SUM(d.cantidad * d.precio_unitario) AS ventas
FROM detalle_pedido d
JOIN pedidos pe ON pe.id = d.pedido_id
JOIN productos pr ON pr.id = d.producto_id
LEFT JOIN categorias c ON c.id = pr.categoria_id
WHERE pe.estado <> 'cancelado'
GROUP BY c.nombre
ORDER BY ventas DESC;| categoria | piezas | ventas |
|---|---|---|
| Electrónica | 3 | 5648.00 |
| Hogar | 6 | 1454.00 |
| Libros | 3 | 987.00 |
USING y NATURAL JOIN
Si la columna se llama igual en ambas tablas puedes escribir JOIN tabla USING (columna). Evita NATURAL JOIN: une automáticamente por todas las columnas con el mismo nombre (como id o nombre) y produce resultados absurdos sin avisar.
Rendimiento
Las columnas que usas en el ON deben tener índice. Las PK ya lo tienen; en PostgreSQL las FK no se indexan solas (en MySQL/InnoDB sí). Lo verás en Índices y rendimiento.
Ejercicios
- Lista cada pedido con el nombre y la ciudad del cliente.
- Muestra todos los productos con el nombre de su categoría; si no tiene, muestra
'Sin categoría'. - ¿Qué productos nunca se han vendido? (Pista: anti-join con
detalle_pedido.) - Muestra el total de cada pedido junto al nombre del cliente y el estado, ordenado por total descendente.
- Lista las categorías con el número de productos de cada una, incluidas las que tienen 0.
Ver solución
sql
-- 1
SELECT pe.id, pe.fecha, cl.nombre, cl.ciudad
FROM pedidos pe
JOIN clientes cl ON cl.id = pe.cliente_id
ORDER BY pe.id;| id | fecha | nombre | ciudad |
|---|---|---|---|
| 1 | 2024-05-02 | Ana López | Ciudad de México |
| 2 | 2024-05-10 | Carlos Ruiz | Guadalajara |
| 3 | 2024-06-01 | Ana López | Ciudad de México |
| 4 | 2024-06-15 | María Pérez | Monterrey |
| 5 | 2024-06-20 | Jorge Díaz | Ciudad de México |
sql
-- 2
SELECT p.nombre, COALESCE(c.nombre, 'Sin categoría') AS categoria
FROM productos p
LEFT JOIN categorias c ON c.id = p.categoria_id
ORDER BY p.id;
-- 3
SELECT p.id, p.nombre
FROM productos p
LEFT JOIN detalle_pedido d ON d.producto_id = p.id
WHERE d.producto_id IS NULL;| id | nombre |
|---|---|
| 8 | Cable USB-C |
sql
-- 4
SELECT pe.id, cl.nombre, pe.estado,
SUM(d.cantidad * d.precio_unitario) AS total
FROM pedidos pe
JOIN clientes cl ON cl.id = pe.cliente_id
JOIN detalle_pedido d ON d.pedido_id = pe.id
GROUP BY pe.id, cl.nombre, pe.estado
ORDER BY total DESC;| id | nombre | estado | total |
|---|---|---|---|
| 3 | Ana López | enviado | 3499.00 |
| 2 | Carlos Ruiz | entregado | 1847.00 |
| 1 | Ana López | entregado | 1597.00 |
| 4 | María Pérez | pendiente | 1146.00 |
| 5 | Jorge Díaz | cancelado | 899.00 |
sql
-- 5
SELECT c.nombre, COUNT(p.id) AS productos
FROM categorias c
LEFT JOIN productos p ON p.categoria_id = c.id
GROUP BY c.id, c.nombre
ORDER BY c.id;| nombre | productos |
|---|---|
| Electrónica | 3 |
| Libros | 2 |
| Hogar | 2 |
| Deportes | 0 |
Resumen
INNER JOINdevuelve solo filas con pareja;LEFT JOINconserva toda la tabla izquierda.RIGHT JOINes el espejo del LEFT;FULL JOINconserva ambas (en MySQL se emula conUNION).CROSS JOINgenera todas las combinaciones; elSELF JOINune una tabla consigo misma (jerarquías).- Anti-join:
LEFT JOIN ... WHERE derecha.id IS NULLencuentra lo que no tiene pareja. - Con LEFT JOIN usa
COUNT(columna_derecha)y pon los filtros de la tabla derecha en elON. - Encadena JOINs para reconstruir la información completa (cliente → pedido → detalle → producto).