JOIN en SQL: unir tablas por sus claves

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.

Tres clientes y tres pedidos: Ana tiene dos pedidos, Mateo uno y Tomás ninguno. El INNER JOIN da tres filas, sin Tomás; el LEFT JOIN da cuatro, con Tomás y NULL en el pedidoINNER JOIN y LEFT JOIN con filas de verdadclientes (3)idnombre1Ana8Mateo12Tomáspedidos (3)idcliente_id1141118ON pedidos.cliente_id = clientes.idTomás (12) no tiene ningún pedido.Ana tiene dos: sale dos veces.INNER JOIN: 3 filasnombrepedidoAna1Ana4Mateo11LEFT JOIN: 4 filasnombrepedidoAna1Ana4Mateo11TomásNULLINNER JOIN solo deja las parejas que encajan. LEFT JOIN conserva además las filas de la izquierdaque no encajan con nada, y rellena con NULL las columnas de la derecha.

Con tres clientes y tres pedidos de la base. Un cliente con dos pedidos sale dos veces; uno sin pedidos solo sale con LEFT JOIN. Esquema: decodigo.com.

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

JOINDevuelvePara
JOIN / INNER JOINsolo las parejas que encajanlo normal: pedidos con su cliente
LEFT JOINtodas las de la izquierda, con NULL si no hay parejaincluir lo que no tiene relación; buscar lo que falta
RIGHT JOINtodas las de la derechacasi nunca: mejor un LEFT JOIN al revés
FULL JOINtodas las de los dos ladoscomparar dos tablas; no existe en MySQL
Autouniónuna tabla unida consigo mismajerarquí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.