Día 4 – JOINs: relacionar tablas con INNER JOIN, LEFT JOIN, RIGHT JOIN
Página de inicio
Día 4 – JOINs: relacionar tablas con INNER JOIN, LEFT JOIN, RIGHT JOIN
Hoy vas a aprender una de las habilidades más poderosas de SQL: combinar información de varias tablas.
Hasta ahora has trabajado con una tabla a la vez. Pero en la realidad, las bases de datos están normalizadas: la información se divide en múltiples tablas relacionadas entre sí para evitar duplicados y mantener la consistencia.
Los JOINs son la forma de reunir esa información dispersa en una sola consulta. Es como unir piezas de un puzzle para ver la imagen completa.
Escenario del día
Tu jefe te pide varios informes:
- ¿Qué productos ha comprado cada usuario?
- ¿Cuáles son los pedidos con el nombre completo del cliente?
- ¿Qué usuarios nunca han hecho un pedido?
- ¿Cuánto ha gastado cada usuario en total?
Sin JOINs, tendrías que hacer múltiples consultas y cruzar los datos manualmente en Excel. Con JOINs lo haces en una sola consulta SQL.
1. ¿Por qué existen varias tablas?
Imagina que guardamos todo en una sola tabla:
| pedido_id | usuario_nombre | usuario_email | producto_nombre | precio | cantidad |
|---|---|---|---|---|---|
| 1 | Ana Garcia | ana.garcia@email.com | Laptop Dell XPS 13 | 1299.99 | 1 |
| 1 | Ana Garcia | ana.garcia@email.com | Teclado Mecanico | 129.99 | 1 |
| 2 | Carlos Lopez | carlos.lopez@email.com | iPhone 15 Pro | 1199.00 | 1 |
Problemas:
- Duplicación: El nombre y email de Ana aparece dos veces
- Inconsistencia: Si Ana cambia su email, hay que actualizarlo en 100 sitios
- Desperdicio de espacio: Repetimos información innecesariamente
Solución: Normalización
Dividimos la información en tablas especializadas:
- usuarios → Información de usuarios (nombre, email)
- productos → Información de productos (nombre, precio)
- pedidos → Cabecera del pedido (fecha, total)
- detalle_pedidos → Qué productos tiene cada pedido
Y las relacionamos mediante IDs:
pedidos.usuario_id → usuarios.id
detalle_pedidos.pedido_id → pedidos.id
detalle_pedidos.producto_id → productos.id
Esto es mucho más eficiente, pero necesitas JOINs para reunir la información.
2. Concepto de JOIN
Un JOIN combina filas de dos o más tablas basándose en una condición de relación.
Analogía simple:
Imagina dos listas en papel:
- Lista A: Usuarios con su ID
- Lista B: Pedidos con el ID del usuario que lo hizo
Un JOIN es como juntar ambas listas poniendo lado a lado las filas que coinciden en el ID del usuario.
Sintaxis básica:
SELECT columnas
FROM tabla1
JOIN tabla2 ON tabla1.columna = tabla2.columna;
3. INNER JOIN – Solo coincidencias
INNER JOIN devuelve solo las filas donde existe coincidencia en ambas tablas.
Si un usuario no tiene pedidos, no aparece. Si un pedido no tiene usuario (dato huérfano), no aparece.
Ejemplo básico
-- Pedidos con el nombre del usuario
SELECT
pedidos.id AS pedido_id,
usuarios.nombre,
pedidos.fecha,
pedidos.total
FROM pedidos
INNER JOIN usuarios ON pedidos.usuario_id = usuarios.id;
¿Qué hace esta consulta?
- Toma la tabla
pedidos - Para cada pedido, busca el usuario correspondiente en la tabla
usuarios - Une ambas filas cuando
pedidos.usuario_id = usuarios.id - Devuelve solo los pedidos que tienen un usuario asociado
Resultado:
| pedido_id | nombre | fecha | total |
|---|---|---|---|
| 1 | Ana Garcia | 2024-04-10 | 1429.98 |
| 2 | Carlos Lopez | 2024-04-11 | 1199.00 |
| 3 | Maria Rodriguez | 2024-04-12 | 479.98 |
| … | … | … | … |
Usar alias para simplificar
Cuando trabajas con varias tablas, los nombres se vuelven largos. Usa alias para abreviar:
SELECT
p.id AS pedido_id,
u.nombre,
p.fecha,
p.total
FROM pedidos p
INNER JOIN usuarios u ON p.usuario_id = u.id
ORDER BY p.fecha DESC;
p y u son alias de pedidos y usuarios.
Ejemplo con 3 tablas
-- Detalles completos de cada pedido
SELECT
u.nombre AS cliente,
p.fecha,
pr.nombre AS producto,
dp.cantidad,
dp.precio_unitario,
(dp.cantidad * dp.precio_unitario) AS subtotal
FROM pedidos p
INNER JOIN usuarios u ON p.usuario_id = u.id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
ORDER BY p.fecha DESC, u.nombre;
¿Qué hace?
Combina 4 tablas:
pedidosconusuarios→ Para saber quién hizo el pedidopedidoscondetalle_pedidos→ Para ver qué productos tienedetalle_pedidosconproductos→ Para obtener el nombre del producto
Resultado:
| cliente | fecha | producto | cantidad | precio_unitario | subtotal |
|---|---|---|---|---|---|
| Ana Garcia | 2024-04-10 | Laptop Dell XPS 13 | 1 | 1299.99 | 1299.99 |
| Ana Garcia | 2024-04-10 | Teclado Mecanico | 1 | 129.99 | 129.99 |
| Carlos Lopez | 2024-04-11 | iPhone 15 Pro | 1 | 1199.00 | 1199.00 |
| … | … | … | … | … | … |
4. LEFT JOIN – Incluir filas sin coincidencia
LEFT JOIN devuelve todas las filas de la tabla izquierda (la primera tabla), incluso si no tienen coincidencia en la tabla derecha.
Si no hay coincidencia, las columnas de la tabla derecha aparecen como NULL.
Caso de uso: Usuarios sin pedidos
-- Todos los usuarios, incluyendo los que nunca compraron
SELECT
u.nombre,
u.email,
p.id AS pedido_id,
p.fecha,
p.total
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
ORDER BY u.nombre;
Diferencia con INNER JOIN:
- INNER JOIN: Solo muestra usuarios que tienen al menos 1 pedido
- LEFT JOIN: Muestra todos los usuarios, los que tienen pedidos y los que no
Resultado:
| nombre | pedido_id | fecha | total | |
|---|---|---|---|---|
| Ana Garcia | ana.garcia@email.com | 1 | 2024-04-10 | 1429.98 |
| Ana Garcia | ana.garcia@email.com | 4 | 2024-04-15 | 299.00 |
| Carmen Ruiz | carmen.ruiz@email.com | NULL | NULL | NULL |
| Carlos Lopez | carlos.lopez@email.com | 2 | 2024-04-11 | 1199.00 |
| … | … | … | … | … |
Carmen Ruiz aparece aunque no tiene pedidos. Sus columnas de pedido son NULL.
Encontrar usuarios sin pedidos
-- ¿Qué usuarios nunca han comprado?
SELECT
u.nombre,
u.email,
u.fecha_registro
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
WHERE p.id IS NULL
ORDER BY u.nombre;
Truco clave: WHERE p.id IS NULL filtra solo los usuarios sin pedidos.
Resultado:
| nombre | fecha_registro | |
|---|---|---|
| Carmen Ruiz | carmen.ruiz@email.com | 2024-04-05 |
| Diego Ramirez | diego.ramirez@email.com | 2024-01-20 |
| Laura Fernandez | laura.fernandez@email.com | 2024-04-12 |
| … | … | … |
Esto es muy útil para campañas de marketing: enviar cupones a usuarios inactivos.
Contar pedidos por usuario (incluidos los que no tienen)
-- Usuarios y cuántos pedidos han hecho
SELECT
u.nombre,
u.email,
COUNT(p.id) AS total_pedidos
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre, u.email
ORDER BY total_pedidos DESC;
Importante: Usa COUNT(p.id) y no COUNT(*):
COUNT(*)cuenta todas las filas (incluso las que tienen NULL)COUNT(p.id)solo cuenta filas dondep.idno es NULL
Resultado:
| nombre | total_pedidos | |
|---|---|---|
| Ana Garcia | ana.garcia@email.com | 2 |
| Carlos Lopez | carlos.lopez@email.com | 1 |
| Carmen Ruiz | carmen.ruiz@email.com | 0 |
| … | … | … |
5. RIGHT JOIN – La tabla derecha es la protagonista
RIGHT JOIN es lo contrario de LEFT JOIN: devuelve todas las filas de la tabla derecha, incluso si no tienen coincidencia en la izquierda.
En la práctica, RIGHT JOIN se usa poco porque siempre puedes reescribirlo como LEFT JOIN cambiando el orden de las tablas.
Ejemplo
-- Todos los pedidos, incluso los que no tienen usuario (datos huérfanos)
SELECT
p.id AS pedido_id,
p.fecha,
p.total,
u.nombre,
u.email
FROM usuarios u
RIGHT JOIN pedidos p ON u.id = p.usuario_id;
Equivalente con LEFT JOIN:
SELECT
p.id AS pedido_id,
p.fecha,
p.total,
u.nombre,
u.email
FROM pedidos p
LEFT JOIN usuarios u ON p.usuario_id = u.id;
Ambas consultas dan el mismo resultado.
Consejo práctico: Usa LEFT JOIN y olvida RIGHT JOIN. Es más intuitivo pensar siempre en «la tabla principal + información adicional».
6. FULL OUTER JOIN – Todo, con o sin coincidencias
FULL OUTER JOIN devuelve todas las filas de ambas tablas, con o sin coincidencia.
- Si hay coincidencia, une las filas
- Si no hay coincidencia en la izquierda, rellena con NULL
- Si no hay coincidencia en la derecha, rellena con NULL
PostgreSQL sí soporta FULL OUTER JOIN.
Ejemplo
-- Todos los usuarios y todos los pedidos
SELECT
u.nombre,
u.email,
p.id AS pedido_id,
p.fecha,
p.total
FROM usuarios u
FULL OUTER JOIN pedidos p ON u.id = p.usuario_id
ORDER BY u.nombre, p.fecha;
Resultado:
- Usuarios sin pedidos → NULL en columnas de pedidos
- Pedidos sin usuario → NULL en columnas de usuarios
En nuestros datos de ejemplo, todos los pedidos tienen usuario, así que solo verías usuarios sin pedidos con NULL.
Uso real: Auditorías de integridad de datos para detectar registros huérfanos.
7. Casos prácticos del día a día
Ahora que dominas los JOINs, veamos consultas útiles para informes reales.
Informe de ventas por usuario
-- ¿Cuánto ha gastado cada usuario?
SELECT
u.nombre,
u.email,
COUNT(p.id) AS total_pedidos,
COALESCE(SUM(p.total), 0) AS gasto_total
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre, u.email
ORDER BY gasto_total DESC;
Nota: COALESCE(SUM(p.total), 0) convierte NULL en 0 para usuarios sin pedidos.
Productos más vendidos con detalles
-- Top 5 productos más vendidos
SELECT
pr.nombre AS producto,
pr.categoria,
pr.precio,
SUM(dp.cantidad) AS unidades_vendidas,
COUNT(DISTINCT dp.pedido_id) AS pedidos_distintos,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos_totales
FROM productos pr
INNER JOIN detalle_pedidos dp ON pr.id = dp.producto_id
GROUP BY pr.id, pr.nombre, pr.categoria, pr.precio
ORDER BY unidades_vendidas DESC
LIMIT 5;
Pedidos con detalles completos
-- Reporte completo de un pedido específico
SELECT
p.id AS pedido_id,
p.fecha,
u.nombre AS cliente,
u.email,
pr.nombre AS producto,
dp.cantidad,
dp.precio_unitario,
(dp.cantidad * dp.precio_unitario) AS subtotal,
p.total AS total_pedido
FROM pedidos p
INNER JOIN usuarios u ON p.usuario_id = u.id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
WHERE p.id = 1
ORDER BY pr.nombre;
Análisis de categorías por país
-- ¿Qué categorías prefiere cada país?
SELECT
u.pais,
pr.categoria,
COUNT(dp.id) AS compras,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos
FROM usuarios u
INNER JOIN pedidos p ON u.id = p.usuario_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
GROUP BY u.pais, pr.categoria
ORDER BY u.pais, ingresos DESC;
Usuarios inactivos (nunca han comprado)
-- Usuarios que se registraron pero nunca compraron
SELECT
u.nombre,
u.email,
u.pais,
u.fecha_registro,
CURRENT_DATE - u.fecha_registro AS dias_desde_registro
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
WHERE p.id IS NULL
ORDER BY u.fecha_registro;
Uso: Crear campañas de reactivación, enviar cupones de descuento.
8. Orden de ejecución de una consulta con JOINs
Es importante entender en qué orden SQL procesa tu consulta:
- FROM → Selecciona la tabla base
- JOIN → Une las tablas según las condiciones
- WHERE → Filtra filas individuales
- GROUP BY → Agrupa filas
- HAVING → Filtra grupos
- SELECT → Calcula las columnas a mostrar
- ORDER BY → Ordena el resultado
- LIMIT → Limita la cantidad de filas
Ejemplo:
SELECT
u.nombre,
COUNT(p.id) AS pedidos
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
WHERE u.pais = 'España'
GROUP BY u.id, u.nombre
HAVING COUNT(p.id) > 0
ORDER BY pedidos DESC
LIMIT 10;
Ejecución:
- Toma
usuarios(FROM) - Une con
pedidos(LEFT JOIN) - Filtra solo usuarios de España (WHERE)
- Agrupa por usuario (GROUP BY)
- Filtra solo usuarios con pedidos (HAVING)
- Calcula el nombre y cuenta de pedidos (SELECT)
- Ordena por cantidad de pedidos (ORDER BY)
- Muestra solo los primeros 10 (LIMIT)
9. Errores comunes (y cómo evitarlos)
Error 1: Ambigüedad en nombres de columnas
-- ❌ ESTO DA ERROR (id existe en ambas tablas)
SELECT id, nombre, fecha
FROM pedidos
INNER JOIN usuarios ON pedidos.usuario_id = usuarios.id;
Solución: Especifica de qué tabla viene cada columna:
-- ✅ CORRECTO
SELECT
pedidos.id AS pedido_id,
usuarios.nombre,
pedidos.fecha
FROM pedidos
INNER JOIN usuarios ON pedidos.usuario_id = usuarios.id;
O usa alias:
-- ✅ AÚN MEJOR
SELECT
p.id AS pedido_id,
u.nombre,
p.fecha
FROM pedidos p
INNER JOIN usuarios u ON p.usuario_id = u.id;
Error 2: Olvidar la condición ON
-- ❌ ESTO DA ERROR (falta la condición ON)
SELECT *
FROM pedidos
INNER JOIN usuarios;
Solución: Siempre especifica la condición ON:
-- ✅ CORRECTO
SELECT *
FROM pedidos
INNER JOIN usuarios ON pedidos.usuario_id = usuarios.id;
Error 3: Usar WHERE en lugar de ON
-- ⚠️ FUNCIONA PERO NO ES LO MISMO
SELECT *
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
WHERE p.total > 500;
Problema: El WHERE convierte el LEFT JOIN en INNER JOIN porque elimina las filas con NULL.
Solución: Si quieres filtrar pedidos pero mantener usuarios sin pedidos, usa la condición en el JOIN:
-- ✅ CORRECTO
SELECT *
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id AND p.total > 500;
Error 4: Producto cartesiano (olvidar el JOIN)
-- ❌ CUIDADO: Esto crea TODAS las combinaciones posibles
SELECT *
FROM usuarios, pedidos;
Si tienes 10 usuarios y 5 pedidos, esto devuelve 10 × 5 = 50 filas, combinando cada usuario con cada pedido.
Solución: Siempre usa JOIN con condición:
-- ✅ CORRECTO
SELECT *
FROM usuarios u
INNER JOIN pedidos p ON u.id = p.usuario_id;
Error 5: COUNT(*) vs COUNT(columna) en LEFT JOIN
-- ⚠️ INCORRECTO para contar pedidos
SELECT
u.nombre,
COUNT(*) AS pedidos
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre;
Problema: COUNT(*) cuenta todas las filas, incluso usuarios sin pedidos (muestra 1 en lugar de 0).
Solución: Usa COUNT(p.id):
-- ✅ CORRECTO
SELECT
u.nombre,
COUNT(p.id) AS pedidos
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre;
10. Ejercicios prácticos
Intenta resolver estos ejercicios antes de ver las soluciones.
- Lista todos los pedidos con el nombre del usuario y su email
- Muestra todos los productos con la cantidad total vendida de cada uno (incluye productos no vendidos con 0)
- ¿Qué usuarios de España han hecho pedidos?
- Calcula el ticket promedio (valor medio) por usuario (solo usuarios con pedidos)
- Encuentra los productos que nunca se han vendido
- Muestra el detalle completo del pedido #3 (cliente, productos, cantidades, precios)
- ¿Cuál es la categoría de productos más vendida por país?
- Lista usuarios con su primer pedido y último pedido (fecha y total)
- Productos que solo se han vendido una vez
- Análisis completo: Para cada usuario muestra nombre, país, cantidad de pedidos y gasto total (incluye usuarios sin pedidos)
💡 Consejo: Empieza siempre identificando qué tablas necesitas y cómo se relacionan.
11. Soluciones
Solución Ejercicio 1
SELECT
p.id AS pedido_id,
p.fecha,
p.total,
u.nombre,
u.email
FROM pedidos p
INNER JOIN usuarios u ON p.usuario_id = u.id
ORDER BY p.fecha DESC;
Solución Ejercicio 2
SELECT
pr.nombre AS producto,
pr.precio,
COALESCE(SUM(dp.cantidad), 0) AS unidades_vendidas
FROM productos pr
LEFT JOIN detalle_pedidos dp ON pr.id = dp.producto_id
GROUP BY pr.id, pr.nombre, pr.precio
ORDER BY unidades_vendidas DESC;
Solución Ejercicio 3
SELECT DISTINCT
u.nombre,
u.email,
u.fecha_registro
FROM usuarios u
INNER JOIN pedidos p ON u.id = p.usuario_id
WHERE u.pais = 'España'
ORDER BY u.nombre;
Solución Ejercicio 4
SELECT
u.nombre,
COUNT(p.id) AS total_pedidos,
ROUND(AVG(p.total), 2) AS ticket_promedio
FROM usuarios u
INNER JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre
ORDER BY ticket_promedio DESC;
Solución Ejercicio 5
SELECT
pr.nombre,
pr.precio,
pr.categoria,
pr.stock
FROM productos pr
LEFT JOIN detalle_pedidos dp ON pr.id = dp.producto_id
WHERE dp.id IS NULL
ORDER BY pr.categoria, pr.nombre;
Solución Ejercicio 6
SELECT
p.id AS pedido_id,
p.fecha,
p.total AS total_pedido,
u.nombre AS cliente,
u.email,
pr.nombre AS producto,
pr.categoria,
dp.cantidad,
dp.precio_unitario,
(dp.cantidad * dp.precio_unitario) AS subtotal
FROM pedidos p
INNER JOIN usuarios u ON p.usuario_id = u.id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
WHERE p.id = 3
ORDER BY pr.nombre;
Solución Ejercicio 7
SELECT
u.pais,
pr.categoria,
SUM(dp.cantidad) AS unidades_vendidas,
ROUND(SUM(dp.cantidad * dp.precio_unitario), 2) AS ingresos
FROM usuarios u
INNER JOIN pedidos p ON u.id = p.usuario_id
INNER JOIN detalle_pedidos dp ON p.id = dp.pedido_id
INNER JOIN productos pr ON dp.producto_id = pr.id
GROUP BY u.pais, pr.categoria
ORDER BY u.pais, unidades_vendidas DESC;
Solución Ejercicio 8
SELECT
u.nombre,
u.email,
MIN(p.fecha) AS primer_pedido,
MAX(p.fecha) AS ultimo_pedido,
ROUND(SUM(p.total), 2) AS gasto_total
FROM usuarios u
INNER JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre, u.email
ORDER BY gasto_total DESC;
Solución Ejercicio 9
SELECT
pr.nombre AS producto,
pr.precio,
COUNT(dp.pedido_id) AS veces_vendido
FROM productos pr
INNER JOIN detalle_pedidos dp ON pr.id = dp.producto_id
GROUP BY pr.id, pr.nombre, pr.precio
HAVING COUNT(dp.pedido_id) = 1
ORDER BY pr.nombre;
Solución Ejercicio 10
SELECT
u.nombre,
u.email,
u.pais,
u.fecha_registro,
COUNT(p.id) AS total_pedidos,
COALESCE(ROUND(SUM(p.total), 2), 0) AS gasto_total
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre, u.email, u.pais, u.fecha_registro
ORDER BY gasto_total DESC;
Resumen del Día 4
¡Excelente trabajo! Ahora dominas una de las herramientas más poderosas de SQL.
Has aprendido:
✅ Por qué existen varias tablas (normalización)
✅ INNER JOIN → Solo coincidencias
✅ LEFT JOIN → Todas las filas de la izquierda + coincidencias
✅ RIGHT JOIN → Todas las filas de la derecha + coincidencias
✅ FULL OUTER JOIN → Todo, con o sin coincidencias
✅ Combinar múltiples tablas en una sola consulta
✅ Detectar registros sin relación (usuarios sin pedidos, productos no vendidos)
✅ Informes complejos combinando JOINs con agregaciones y filtros
Clave del día:
- INNER JOIN: Solo lo que coincide
- LEFT JOIN: Todo de la izquierda + lo que coincida de la derecha
- Usa alias para simplificar consultas complejas
- COUNT(columna) vs COUNT(*) en LEFT JOINs
- WHERE filtra filas, ON define cómo unir tablas
Siguiente paso
En el Día 5 aprenderás subconsultas y CTEs: cómo escribir consultas dentro de otras consultas para resolver problemas más complejos.
Por ejemplo:
- Encontrar usuarios que gastaron más que el promedio
- Productos más caros de cada categoría
- Ranking de mejores clientes
Los JOINs te permiten unir tablas horizontalmente. Las subconsultas te permitirán crear consultas más sofisticadas, como «filtros dinámicos» basados en cálculos.
¡Nos vemos en el Día 5!
