El INNER JOIN es el JOIN por defecto, el que ya usaste sin nombrarlo en la lección anterior y el que escribirás en ocho de cada diez consultas de tu vida profesional. Su regla es de una simplicidad absoluta: solo sobreviven las filas que encuentran pareja a ambos lados. Todo lo demás desaparece, en silencio y sin aviso.

Y ahí está la trampa. Un INNER JOIN nunca da error por perder filas; simplemente te devuelve menos de las que esperabas. En esta lección aprenderás no solo a escribirlo, sino a predecir cuántas filas devuelve y por qué, que es la habilidad que distingue a quien entiende los JOIN de quien los copia. Verás desaparecer a tres clientes de TiendaVerde y a la mitad de sus pedidos, y entenderás que esas desapariciones son la definición misma del operador.

Contenido

  1. INNER JOIN frente a JOIN a secas
  2. El diagrama de conjuntos
  3. Ejemplos progresivos sobre TiendaVerde
  4. La consulta canónica de cuatro tablas
  5. Qué filas se pierden y por qué
  6. Condiciones adicionales: ON frente a WHERE
  7. Claves no únicas y la multiplicación de filas
  8. El orden de las tablas no cambia el resultado
  9. Errores Comunes y Consejos
  10. Ejercicios
  11. Conclusión

  1. INNER JOIN frente a JOIN a secas

Las dos formas siguientes son exactamente la misma consulta:

FROM productos AS p
INNER JOIN categorias AS cat ON p.categoria_id = cat.id
FROM productos AS p
JOIN categorias AS cat ON p.categoria_id = cat.id

INNER es opcional porque el estándar SQL define el JOIN sin calificar como interno. Es lo contrario de lo que ocurre con LEFT OUTER JOIN, donde lo opcional es OUTER.

Escritura Significado ¿La palabra sobra?
JOIN INNER JOIN
INNER JOIN Interno Sí, INNER es opcional
LEFT JOIN LEFT OUTER JOIN
LEFT OUTER JOIN Externo por la izquierda Sí, OUTER es opcional

¿Cuál escribir? Hay dos escuelas. Una prefiere JOIN por brevedad; la otra prefiere INNER JOIN porque, en una consulta que mezcla varios tipos, ver la palabra INNER junto a un LEFT deja explícita la intención y evita que alguien piense que se olvidó un modificador.

Convención del curso: escribiremos JOIN cuando toda la consulta sea interna, y INNER JOIN explícito cuando conviva con un LEFT, un RIGHT o un FULL en la misma consulta. Es la práctica más extendida en equipos profesionales.

  1. El diagrama de conjuntos

La forma clásica de visualizar los JOIN es con dos conjuntos que se solapan. El INNER JOIN devuelve solo la intersección:

flowchart LR
    subgraph R[" "]
        direction LR
        A(("clientes<br/>sin pedidos<br/>❌ fuera"))
        I(("casan<br/>✅ resultado"))
        B(("pedidos<br/>sin cliente<br/>❌ fuera"))
    end
    style A fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
    style I fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
    style B fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4

Y como flujo de filas, retomando el modelo mental de 03-01:

flowchart LR
    A["tabla izquierda"] --> C["cartesiano"]
    B["tabla derecha"] --> C
    C --> D["filtro ON"]
    D --> E["✅ filas que casan"]
    D --> F["❌ filas sin pareja<br/>se descartan a ambos lados"]

Ese cuadro rojo —"se descartan a ambos lados"— es todo lo que hay que recordar del INNER JOIN. Las lecciones 03-03, 03-04 y 03-05 consisten precisamente en recuperar lo que aquí se tira.

  1. Ejemplos progresivos sobre TiendaVerde

3.1. Un producto y el nombre de su categoría

Ya lo viste en 03-01; lo recuperamos como punto de partida, ahora ampliado con el proveedor. Tres tablas, dos ON:

SELECT p.id,
       p.nombre   AS producto,
       cat.nombre AS categoria,
       pr.nombre  AS proveedor,
       p.precio
FROM productos AS p
JOIN categorias  AS cat ON p.categoria_id = cat.id
JOIN proveedores AS pr  ON p.proveedor_id = pr.id
WHERE cat.nombre = 'Cosmética natural'
ORDER BY p.id;
id producto categoria proveedor precio
6 Crema facial de aloe vera 50 ml Cosmética natural Maison Nature 18.90
7 Champú sólido de romero 80 g Cosmética natural Maison Nature 8.40
8 Aceite corporal de almendras 200 ml Cosmética natural Verde Atlántico 14.25
9 Bálsamo labial de caléndula 15 ml Cosmética natural Maison Nature 4.60

Fíjate en un detalle que solo es posible con JOIN: hemos filtrado por el nombre de la categoría, no por su id. Antes de este módulo habrías tenido que buscar a mano que "Cosmética natural" es la categoría 2 y escribir WHERE categoria_id = 2. Ahora la consulta se lee como se piensa.

3.2. Pedidos con el nombre completo del cliente

La pregunta con la que cerramos el módulo 2: quién hizo cada pedido.

SELECT pe.id AS pedido_id,
       pe.fecha_pedido,
       c.nombre || ' ' || c.apellidos AS cliente,
       c.ciudad,
       pe.estado,
       pe.gastos_envio
FROM pedidos  AS pe
JOIN clientes AS c ON pe.cliente_id = c.id
ORDER BY pe.id;
pedido_id fecha_pedido cliente ciudad estado gastos_envio
1 2025-03-04 Lucía Martínez Soler Valencia entregado 4.95
2 2025-03-12 Carlos Ferrer Ibáñez Valencia entregado 0.00
3 2025-04-02 Marta Sanchis Gil Castellón entregado 4.95
4 2025-04-19 Javier Ortega Ruiz Madrid entregado 4.95
5 2025-05-07 Lucía Martínez Soler Valencia entregado 0.00
6 2025-05-23 Ana Belmonte Roca Barcelona cancelado 4.95
7 2025-06-11 Pau Llorens Vidal Valencia entregado 6.50
8 2025-06-28 Sofia Moreira Costa Lisboa entregado 9.90
9 2025-07-15 Tiago Almeida Nunes Oporto entregado 9.90
10 2025-08-03 Camille Dubois Lyon entregado 12.50
11 2025-09-09 Carlos Ferrer Ibáñez Valencia entregado 0.00
12 2025-10-01 Julien Moreau París entregado 12.50
13 2025-10-22 Elena Navarro Puig Alicante entregado 4.95
14 2025-11-14 Diego Ramos Herrera Sevilla entregado 4.95
15 2025-12-02 Lucía Martínez Soler Valencia entregado 0.00
16 2025-12-19 Javier Ortega Ruiz Madrid enviado 4.95
17 2026-01-13 Sofia Moreira Costa Lisboa enviado 9.90
18 2026-01-27 Ana Belmonte Roca Barcelona pagado 4.95
19 2026-02-09 Pau Llorens Vidal Valencia pagado 4.95
20 2026-02-21 Camille Dubois Lyon pendiente 12.50

20 filas, las mismas que tiene pedidos. Ahí está por fin "Camille Dubois" donde antes había un 9.

Dos observaciones:

  • Los nombres se repiten: Lucía Martínez Soler aparece tres veces porque hizo tres pedidos (1, 5 y 15). Es normal: unimos por el lado "muchos" de la relación 1:N.
  • La concatenación c.nombre || ' ' || c.apellidos es la de 02-02, con su advertencia sobre NULL incluida. Aquí es segura porque ambas columnas son NOT NULL.

3.3. Líneas de pedido con nombre de producto e importe

SELECT lp.id AS linea_id,
       lp.pedido_id,
       p.nombre AS producto,
       lp.cantidad,
       lp.precio_unitario,
       lp.descuento,
       ROUND(lp.cantidad * lp.precio_unitario * (1 - lp.descuento), 2) AS importe
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
ORDER BY lp.id
LIMIT 10;
linea_id pedido_id producto cantidad precio_unitario descuento importe
1 1 Aceite de oliva virgen extra 500 ml 2 11.95 0.00 23.90
2 1 Arroz integral ecológico 1 kg 3 3.90 0.00 11.70
3 1 Infusión de manzanilla ecológica 20 uds 2 3.25 0.00 6.50
4 2 Crema facial de aloe vera 50 ml 1 17.50 0.00 17.50
5 2 Bálsamo labial de caléndula 15 ml 2 4.60 0.00 9.20
6 3 Tomate triturado ecológico 400 g 6 1.95 0.10 10.53
7 3 Pasta de espelta 500 g 4 2.80 0.00 11.20
8 3 Arroz integral ecológico 1 kg 2 3.90 0.00 7.80
9 4 Té verde matcha ceremonial 30 g 1 22.00 0.00 22.00
10 4 Miel de azahar cruda 500 g 1 9.75 0.00 9.75

(10 primeras de 47 filas.)

Aquí se ve algo que en el módulo 2 solo podíamos intuir: la línea 1 tiene precio_unitario 11.95, mientras que el aceite cuesta hoy 12.50 €. Es el precio histórico del que hablaba 01-06. Un error clásico sería calcular el importe con p.precio en lugar de con lp.precio_unitario: obtendrías 25.00 € en vez de 23.90 € y estarías reescribiendo la historia comercial de la empresa.

Regla del curso: el importe de una línea se calcula siempre con lp.precio_unitario, nunca con p.precio. La tabla productos dice cuánto cuesta hoy; lineas_pedido dice cuánto se cobró entonces.

  1. La consulta canónica de cuatro tablas

Esta es la consulta más importante del curso. Responde a "¿qué compró cada cliente y por cuánto?" y necesita cuatro tablas: el detalle está en lineas_pedido, el cliente cuelga de pedidos y el nombre del producto de productos.

flowchart LR
    LP["lineas_pedido<br/>(detalle: 47 filas)"] -->|"lp.pedido_id = pe.id"| PE["pedidos"]
    PE -->|"pe.cliente_id = c.id"| C["clientes"]
    LP -->|"lp.producto_id = p.id"| P["productos"]

Observa la forma del camino: no es una cadena lineal, es una estrella con lineas_pedido en el centro. Desde ella salen dos ramas: una hacia el pedido y su cliente, otra hacia el producto. Eso es normal cuando la tabla de partida tiene varias claves foráneas.

SELECT pe.id AS pedido_id,
       pe.fecha_pedido,
       c.nombre || ' ' || c.apellidos AS cliente,
       p.nombre AS producto,
       lp.cantidad,
       ROUND(lp.cantidad * lp.precio_unitario * (1 - lp.descuento), 2) AS importe
FROM lineas_pedido AS lp
JOIN pedidos   AS pe ON lp.pedido_id = pe.id
JOIN clientes  AS c  ON pe.cliente_id = c.id
JOIN productos AS p  ON lp.producto_id = p.id
ORDER BY pe.id, lp.id
LIMIT 12;
pedido_id fecha_pedido cliente producto cantidad importe
1 2025-03-04 Lucía Martínez Soler Aceite de oliva virgen extra 500 ml 2 23.90
1 2025-03-04 Lucía Martínez Soler Arroz integral ecológico 1 kg 3 11.70
1 2025-03-04 Lucía Martínez Soler Infusión de manzanilla ecológica 20 uds 2 6.50
2 2025-03-12 Carlos Ferrer Ibáñez Crema facial de aloe vera 50 ml 1 17.50
2 2025-03-12 Carlos Ferrer Ibáñez Bálsamo labial de caléndula 15 ml 2 9.20
3 2025-04-02 Marta Sanchis Gil Tomate triturado ecológico 400 g 6 10.53
3 2025-04-02 Marta Sanchis Gil Pasta de espelta 500 g 4 11.20
3 2025-04-02 Marta Sanchis Gil Arroz integral ecológico 1 kg 2 7.80
4 2025-04-19 Javier Ortega Ruiz Té verde matcha ceremonial 30 g 1 22.00
4 2025-04-19 Javier Ortega Ruiz Miel de azahar cruda 500 g 1 9.75
5 2025-05-07 Lucía Martínez Soler Detergente ecológico concentrado 1 L 1 11.20
5 2025-05-07 Lucía Martínez Soler Estropajo vegetal de luffa (pack 3) 2 11.00

(12 primeras de 47 filas.)

47 filas: una por línea de pedido. Guarda esta consulta, porque es la base de casi todo lo que viene después. En el módulo 4 le añadirás GROUP BY c.id y SUM(importe) para responder "¿cuánto ha gastado cada cliente?"; en el módulo 7 la usarás como subconsulta; en el módulo 10 la convertirás en una vista. El esqueleto no cambia.

Cuatro tablas, tres condiciones ON. Vuelve a comprobarse la regla de 03-01: N tablas, N-1 emparejamientos.

  1. Qué filas se pierden y por qué

Llegamos al corazón de la lección. Un INNER JOIN descarta silenciosamente las filas sin pareja, y en TiendaVerde eso tiene dos consecuencias muy visibles.

5.1. Los tres clientes que desaparecen

clientes tiene 15 filas. Veamos quiénes sobreviven a un INNER JOIN con pedidos:

SELECT DISTINCT c.id,
       c.nombre,
       c.apellidos
FROM clientes AS c
JOIN pedidos  AS pe ON pe.cliente_id = c.id
ORDER BY c.id;
id nombre apellidos
1 Lucía Martínez Soler
2 Carlos Ferrer Ibáñez
3 Marta Sanchis Gil
4 Javier Ortega Ruiz
5 Ana Belmonte Roca
6 Pau Llorens Vidal
7 Sofia Moreira Costa
8 Tiago Almeida Nunes
9 Camille Dubois
10 Julien Moreau
11 Elena Navarro Puig
12 Diego Ramos Herrera

12 filas, no 15. Faltan los clientes 13 (Núria Bosch Ferrer), 14 (Hugo Iglesias Pardo) y 15 (Inés Carrasco Vega). ¿Por qué? Porque ninguno ha hecho un pedido, así que en el producto cartesiano no existe ninguna combinación en la que pe.cliente_id valga 13, 14 o 15. La condición ON no las encuentra y desaparecen.

El balance de recuentos:

Consulta Filas Qué significa
SELECT ... FROM clientes 15 Todos los clientes
SELECT ... FROM clientes JOIN pedidos ON ... 20 Una fila por pedido, no por cliente
SELECT DISTINCT c.id ... FROM clientes JOIN pedidos ON ... 12 Clientes con al menos un pedido

Las tres cifras son distintas y las tres son correctas: responden a preguntas distintas. Si te piden "el listado de clientes con su actividad" y entregas 12 de 15, has borrado del informe a tres personas.

Aviso: ese DISTINCT es exactamente el síntoma del que hablaba 02-04. Aparece aquí porque estamos usando una herramienta —el INNER JOIN— que no es la adecuada para la pregunta "qué clientes han comprado". La herramienta correcta llega en 03-03.

5.2. Los diez pedidos sin comercial

El mismo fenómeno, desde el otro lado. pedidos.empleado_id admite NULL porque los pedidos web no llevan comercial asignado:

SELECT pe.id AS pedido_id,
       pe.fecha_pedido,
       e.nombre || ' ' || e.apellidos AS comercial,
       e.puesto
FROM pedidos   AS pe
JOIN empleados AS e ON pe.empleado_id = e.id
ORDER BY pe.id;
pedido_id fecha_pedido comercial puesto
2 2025-03-12 Óscar Peris Blasco Comercial
4 2025-04-19 Laia Puig Sanchis Comercial
6 2025-05-23 Óscar Peris Blasco Comercial
8 2025-06-28 Laia Puig Sanchis Comercial
10 2025-08-03 Óscar Peris Blasco Comercial
12 2025-10-01 Laia Puig Sanchis Comercial
14 2025-11-14 Marc Estévez Roig Atención al cliente
16 2025-12-19 Óscar Peris Blasco Comercial
18 2026-01-27 Laia Puig Sanchis Comercial
20 2026-02-21 Marc Estévez Roig Atención al cliente

10 filas de 20. Han desaparecido los pedidos 1, 3, 5, 7, 9, 11, 13, 15, 17 y 19: todos los que tienen empleado_id IS NULL.

El mecanismo es el que viste en 02-03 con empleado_id = NULL: NULL no es igual a nada, ni siquiera a otro NULL. La condición ON pe.empleado_id = e.id se evalúa a NULL (que no es TRUE) para esas diez filas, así que ninguna pareja las salva.

Y aquí está el daño real: la mitad de la facturación de TiendaVerde ha desaparecido del informe. Si la dirección pregunta "¿cuántos pedidos hemos tenido este año?" y respondes con esta consulta, tu respuesta será la mitad de la verdad.

flowchart TD
    A["20 pedidos"] --> B{"¿empleado_id<br/>tiene valor?"}
    B -->|"sí (10)"| C["encuentran pareja<br/>✅ salen en el resultado"]
    B -->|"NULL (10)"| D["ninguna comparación es TRUE<br/>❌ se pierden"]

5.3. Las dos causas de pérdida

Resumiendo, un INNER JOIN pierde filas por dos motivos:

Causa Ejemplo en TiendaVerde Solución
La FK es NULL Los 10 pedidos sin empleado_id LEFT JOIN desde pedidos (03-03)
No existe ninguna fila hija que apunte a esta Los clientes 13, 14 y 15; los productos 13, 19 y 20 LEFT JOIN desde la tabla padre (03-03)

En ambos casos la respuesta es la misma familia de operadores, y por eso las lecciones 03-03, 03-04 y 03-05 existen. El INNER JOIN no está mal: simplemente responde a la pregunta "dame lo que casa", y a veces la pregunta del negocio es otra.

  1. Condiciones adicionales: ON frente a WHERE

Nada impide poner condiciones extra dentro del ON, más allá del emparejamiento. Estas dos consultas piden lo mismo: los pedidos de clientes portugueses.

-- Condición en el ON
SELECT pe.id, pe.fecha_pedido, c.nombre, c.pais
FROM pedidos  AS pe
JOIN clientes AS c
  ON pe.cliente_id = c.id
 AND c.pais = 'Portugal'
ORDER BY pe.id;
id fecha_pedido nombre pais
8 2025-06-28 Sofia Portugal
9 2025-07-15 Tiago Portugal
17 2026-01-13 Sofia Portugal
-- Condición en el WHERE
SELECT pe.id, pe.fecha_pedido, c.nombre, c.pais
FROM pedidos  AS pe
JOIN clientes AS c ON pe.cliente_id = c.id
WHERE c.pais = 'Portugal'
ORDER BY pe.id;
id fecha_pedido nombre pais
8 2025-06-28 Sofia Portugal
9 2025-07-15 Tiago Portugal
17 2026-01-13 Sofia Portugal

Resultados idénticos. Y no es casualidad: en un INNER JOIN son siempre equivalentes. Vuelve al diagrama del orden lógico de 03-01 y entenderás por qué. En un JOIN interno no existe el paso 1d —no se reintroduce ninguna fila sin pareja—, así que filtrar durante el emparejamiento o justo después produce el mismo conjunto.

Si son equivalentes, ¿dónde ponerlas? Por legibilidad:

Tipo de condición Dónde ponerla Ejemplo
Relaciona dos tablas (emparejamiento) ON pe.cliente_id = c.id
Restringe qué filas nos interesan WHERE c.pais = 'Portugal'

Y ahora el aviso más importante de esta lección: esta equivalencia es EXCLUSIVA del INNER JOIN. En un LEFT JOIN, mover una condición del ON al WHERE cambia el resultado, y lo cambia de una forma que parece un fallo de datos y no un fallo de consulta. La lección 03-03 lo demuestra con la misma pregunta escrita de las dos maneras y sus dos resultados distintos. Coge desde ya la costumbre de separar emparejamiento y filtrado: cuando llegues al LEFT JOIN, esa costumbre te salvará.

  1. Claves no únicas y la multiplicación de filas

Hasta ahora todos los JOIN de la lección han conservado el número de filas de la tabla de partida. Eso ocurre cuando unes desde el lado "muchos" hacia el lado "uno" (de productos a categorias, de pedidos a clientes): cada fila encuentra exactamente una pareja.

Al revés, la cosa cambia.

SELECT pe.id AS pedido_id,
       pe.fecha_pedido,
       pe.estado,
       lp.id AS linea_id,
       lp.producto_id,
       lp.cantidad
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
WHERE pe.id <= 3
ORDER BY pe.id, lp.id;
pedido_id fecha_pedido estado linea_id producto_id cantidad
1 2025-03-04 entregado 1 1 2
1 2025-03-04 entregado 2 2 3
1 2025-03-04 entregado 3 14 2
2 2025-03-12 entregado 4 6 1
2 2025-03-12 entregado 5 9 2
3 2025-04-02 entregado 6 5 6
3 2025-04-02 entregado 7 4 4
3 2025-04-02 entregado 8 2 2

Tres pedidos han producido ocho filas. El pedido 1 aparece tres veces (tiene tres líneas), el 2 dos veces y el 3 tres veces. Los datos de cabecera —fecha, estado, y también gastos_envio si lo hubiéramos pedido— se repiten en cada línea.

La regla general:

Al unir por una columna no única en el lado derecho, cada fila de la izquierda se duplica tantas veces como parejas encuentre.

Dirección del JOIN Efecto sobre el recuento Ejemplo
Muchos → uno (FK → PK) Se conserva lineas_pedido JOIN productos: 47 → 47
Uno → muchos (PK → FK) Se multiplica pedidos JOIN lineas_pedido: 20 → 47
Muchos → muchos (ninguna columna única) Se dispara Cuidado

Por qué esto es la causa nº 1 de sumas infladas

Esto parece inofensivo mientras solo miras filas de detalle. Se vuelve peligroso en el módulo 4, cuando empieces a sumar. Imagina esta consulta:

-- ⚠️ INCORRECTA (avance del módulo 4): los gastos de envío quedan inflados
SELECT SUM(pe.gastos_envio)
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id;

Los gastos de envío del pedido 1 son 4,95 € una vez, pero después del JOIN esa cifra aparece en tres filas. La suma daría 14,85 € solo para ese pedido: el triple de lo real. Y no habrá ningún error, ningún aviso, solo un número equivocado en un informe de dirección.

Lo mismo ocurriría al sumar salario tras unir empleados con pedidos, o al contar clientes tras unir clientes con lineas_pedido.

Aprende a detectarlo ahora: después de cada JOIN que añadas, pregúntate "¿cuántas filas de la derecha puede haber por cada fila de la izquierda?". Si la respuesta es "más de una", cualquier valor de la izquierda que sumes después estará multiplicado. En el módulo 4 verás las técnicas para evitarlo; de momento basta con reconocer el síntoma.

Un truco de verificación que puedes aplicar hoy mismo: si el recuento de filas de tu consulta coincide con el de la tabla de detalle (47 para lineas_pedido), estás en el nivel de granularidad correcto para trabajar con importes de línea.

  1. El orden de las tablas no cambia el resultado

En un INNER JOIN, A JOIN B y B JOIN A devuelven el mismo conjunto de filas. La operación es conmutativa.

-- Estas dos son equivalentes
FROM pedidos  AS pe JOIN clientes AS c  ON pe.cliente_id = c.id
FROM clientes AS c  JOIN pedidos  AS pe ON pe.cliente_id = c.id

Ambas devuelven las mismas 20 filas. Lo único que puede cambiar es el orden de las columnas si usas SELECT *, y el orden de las filas si no pones ORDER BY (lo de siempre desde 02-01).

Con tres o más tablas también es asociativa: (A JOIN B) JOIN C equivale a A JOIN (B JOIN C), siempre que las condiciones ON estén bien puestas.

Propiedad INNER JOIN LEFT JOIN
Conmutativa (A ⋈ B = B ⋈ A) No
Asociativa Solo con cuidado

Que sea conmutativo tiene dos consecuencias prácticas:

  1. Escribe las tablas en el orden en que se entiende la pregunta. Si el informe es "pedidos con su cliente", empieza por pedidos. Si es "clientes y sus pedidos", empieza por clientes. El resultado es el mismo y la consulta se lee mejor.
  2. El optimizador no te hace caso. PostgreSQL reordena libremente los INNER JOIN para elegir el plan más barato: puede empezar por la tabla más pequeña, o por la que tenga el filtro más selectivo, con independencia de cómo lo escribas. Tu orden es documentación para humanos, no una instrucción para el motor. (Lo verás en el módulo 8.)

Y una advertencia para lo que viene. El LEFT JOIN no es conmutativo: A LEFT JOIN B y B LEFT JOIN A devuelven cosas distintas. Ahí el orden en que escribes las tablas es parte del significado, no del estilo.

Errores Comunes y Consejos

  • Dar por hecho que el INNER JOIN conserva todas las filas. Pierde las que no casan a ninguno de los dos lados, sin decir nada. Comprueba el recuento contra la tabla de partida.
  • Olvidar que una FK con NULL nunca casa. Los 10 pedidos web desaparecen al unir con empleados. NULL = 4 no es falso: es NULL, y NULL no es TRUE.
  • Calcular el importe con p.precio en vez de lp.precio_unitario. Devuelve el precio de hoy aplicado a una venta de hace un año. Con la línea 1 la diferencia es de 1,10 €; con un catálogo real, de miles de euros.
  • Sumar valores de cabecera después de unir con el detalle. SUM(pe.gastos_envio) tras JOIN lineas_pedido cuenta cada pedido tantas veces como líneas tenga. Es la causa nº 1 de informes inflados.
  • Poner el DISTINCT como parche. Si necesitas DISTINCT para arreglar un JOIN, casi siempre el JOIN es el que está mal planteado (02-04). Piensa qué granularidad quiere de verdad la pregunta.
  • Creer que la equivalencia ON/WHERE es general. Solo vale para INNER JOIN. En LEFT JOIN cambia el resultado (03-03).
  • Emparejar por columnas del tipo correcto pero del concepto equivocado. ON lp.producto_id = pe.id no da error, compara enteros con enteros, y devuelve datos sin sentido.
  • Consejo: valida cada JOIN por separado. Escribe primero FROM lineas_pedido lp JOIN pedidos pe ON ... con LIMIT 5, comprueba, y solo entonces añade la tercera tabla. Depurar una consulta de cinco tablas escrita de una tacada es un suplicio.
  • Consejo: memoriza los tres recuentos de TiendaVerde. 20 productos, 20 pedidos, 47 líneas. Si una consulta de detalle no devuelve 47 filas, sabes de inmediato que algo pasa.
  • Consejo: nombra los alias de columna cuando unas tablas con columnas homónimas. c.nombre AS cliente y p.nombre AS producto evitan que el resultado tenga dos columnas llamadas nombre.

Ejercicios

Ejercicio 1

El equipo de producto quiere revisar las opiniones recibidas. Escribe una consulta que devuelva, para cada reseña: su id, el nombre del producto reseñado, el nombre completo de quien la escribió, la puntuación y la fecha. Ordena por id de reseña.

Después responde: ¿cuántas filas devuelve, y por qué ese número no coincide con el número de productos del catálogo?

Ejercicio 2

Administración necesita el detalle de las devoluciones. Escribe una consulta que muestre, para cada devolución: su id, la fecha, el importe, el motivo, el id y el estado del pedido devuelto, y el nombre completo del cliente afectado.

Indica cuántas tablas has necesitado, cuántas condiciones ON y por qué.

Ejercicio 3

Camille Dubois (cliente 9) ha llamado reclamando el detalle de todo lo que ha comprado. Usando la consulta canónica de cuatro tablas de la sección 4, obtén su historial completo: pedido, fecha, estado del pedido, producto, cantidad e importe de la línea.

Después responde a estas dos preguntas:

  1. ¿Cuántas filas devuelve y cuántos pedidos representan?
  2. Si en lugar de INNER JOIN entre pedidos y clientes hubieras unido pedidos con empleados, ¿aparecerían los mismos pedidos de Camille? Razona la respuesta mirando los datos.

Soluciones

Solución 1

SELECT r.id AS resena_id,
       p.nombre AS producto,
       c.nombre || ' ' || c.apellidos AS cliente,
       r.puntuacion,
       r.fecha
FROM resenas   AS r
JOIN productos AS p ON r.producto_id = p.id
JOIN clientes  AS c ON r.cliente_id  = c.id
ORDER BY r.id;
resena_id producto cliente puntuacion fecha
1 Aceite de oliva virgen extra 500 ml Lucía Martínez Soler 5 2025-03-15
2 Arroz integral ecológico 1 kg Lucía Martínez Soler 4 2025-03-16
3 Crema facial de aloe vera 50 ml Carlos Ferrer Ibáñez 5 2025-03-25
4 Tomate triturado ecológico 400 g Marta Sanchis Gil 3 2025-04-12
5 Té verde matcha ceremonial 30 g Javier Ortega Ruiz 5 2025-05-02
6 Kombucha de jengibre 750 ml Pau Llorens Vidal 2 2025-06-20
7 Aceite de oliva virgen extra 500 ml Sofia Moreira Costa 5 2025-07-08
8 Cepillo de dientes de bambú Tiago Almeida Nunes 4 2025-07-26
9 Crema facial de aloe vera 50 ml Camille Dubois 4 2025-08-14
10 Arroz integral ecológico 1 kg Carlos Ferrer Ibáñez 5 2025-09-19
11 Bolsas reutilizables de algodón (pack 5) Elena Navarro Puig 3 2025-11-03
12 Detergente ecológico concentrado 1 L Javier Ortega Ruiz 4 2026-01-10

12 filas, exactamente las de resenas. Partimos de esa tabla y sus dos claves foráneas (producto_id, cliente_id) son obligatorias y válidas: cada reseña encuentra un producto y un cliente, uno y solo uno.

Por qué no coincide con los 20 productos: porque la consulta va de reseñas a productos, no al revés. Solo aparecen los 9 productos distintos que tienen alguna reseña (el aceite, el arroz y la crema aparecen dos veces cada uno). Los 11 productos sin ninguna reseña —entre ellos el desodorante y las cápsulas de espirulina— no existen para esta consulta. Para verlos habría que partir de productos y usar un LEFT JOIN: es justo lo que hace la lección siguiente.

Solución 2

SELECT d.id AS devolucion_id,
       d.fecha,
       d.importe,
       d.motivo,
       pe.id AS pedido_id,
       pe.estado,
       c.nombre || ' ' || c.apellidos AS cliente
FROM devoluciones AS d
JOIN pedidos  AS pe ON d.pedido_id  = pe.id
JOIN clientes AS c  ON pe.cliente_id = c.id
ORDER BY d.id;
devolucion_id fecha importe motivo pedido_id estado cliente
1 2025-05-25 26.75 Pedido cancelado por el cliente antes del envío 6 cancelado Ana Belmonte Roca
2 2025-08-11 34.02 Producto dañado durante el transporte 10 entregado Camille Dubois
3 2025-10-30 19.80 El formato no corresponde a lo esperado 13 entregado Elena Navarro Puig

Tres tablas y dos condiciones ON, siguiendo la regla N-1 de 03-01. Hacen falta tres porque el cliente no es alcanzable desde devoluciones en un solo salto: devoluciones solo conoce pedido_id, y es pedidos quien conoce cliente_id. El camino obligatorio es devoluciones → pedidos → clientes.

Un detalle que valida los datos: la devolución 2 vale 34,02 €, exactamente el importe de la línea 24 (2 unidades de crema facial a 18,90 € con 10 % de descuento). No es casualidad: se devolvió ese producto concreto del pedido de Camille.

Solución 3

SELECT pe.id AS pedido_id,
       pe.fecha_pedido,
       pe.estado,
       p.nombre AS producto,
       lp.cantidad,
       ROUND(lp.cantidad * lp.precio_unitario * (1 - lp.descuento), 2) AS importe
FROM lineas_pedido AS lp
JOIN pedidos   AS pe ON lp.pedido_id  = pe.id
JOIN clientes  AS c  ON pe.cliente_id = c.id
JOIN productos AS p  ON lp.producto_id = p.id
WHERE c.id = 9
ORDER BY pe.id, lp.id;
pedido_id fecha_pedido estado producto cantidad importe
10 2025-08-03 entregado Crema facial de aloe vera 50 ml 2 34.02
10 2025-08-03 entregado Aceite corporal de almendras 200 ml 1 14.25
20 2026-02-21 pendiente Arroz integral ecológico 1 kg 4 15.60
20 2026-02-21 pendiente Cepillo de dientes de bambú 2 7.00

1. 4 filas que representan 2 pedidos: el 10 (entregado, dos líneas) y el 20 (pendiente, dos líneas). Es la multiplicación de filas de la sección 7 en acción: el número de filas es el de líneas, no el de pedidos. Si quisieras el número de pedidos de Camille, esta consulta no es la herramienta: te haría falta COUNT(DISTINCT pe.id) del módulo 4.

2. No. El pedido 10 tiene empleado_id = 4 (Óscar Peris Blasco) y aparecería; el pedido 20 tiene empleado_id = 6 (Marc Estévez Roig) y también. En este caso concreto sí saldrían los dos... pero es pura suerte: ambos pedidos de Camille tienen comercial asignado. Prueba con Lucía Martínez Soler (cliente 1), cuyos tres pedidos —1, 5 y 15— son todos web con empleado_id IS NULL: un INNER JOIN con empleados haría desaparecer el 100 % de su historial. Ese es el peligro que motiva la lección siguiente.

Conclusión

El INNER JOIN ya no tiene secretos:

  • JOIN e INNER JOIN son lo mismo. El curso usa JOIN en consultas puramente internas e INNER JOIN explícito cuando conviven varios tipos.
  • Devuelve solo la intersección: las filas que encuentran pareja a ambos lados. Todo lo que no casa se descarta en silencio.
  • Sabes escribir cadenas de tres y cuatro tablas y tienes la consulta canónica de detalle de ventas (lineas_pedido + pedidos + clientes + productos, 47 filas), que reutilizarás en los módulos 4, 7 y 10.
  • Sabes qué se pierde: los clientes 13, 14 y 15 al unir clientes con pedidos (12 clientes de 15), y los 10 pedidos web al unir pedidos con empleados (10 filas de 20). Las dos causas son la FK con NULL y la ausencia de fila hija.
  • En un INNER JOIN, poner una condición extra en ON o en WHERE es equivalente; en un LEFT JOIN no lo será, y esa es la trampa de la próxima lección.
  • Unir hacia el lado "muchos" multiplica filas: 20 pedidos se convierten en 47 al añadir lineas_pedido. Es la causa número uno de las sumas infladas que verás en el módulo 4.
  • El INNER JOIN es conmutativo y asociativo: el orden de las tablas es cuestión de legibilidad, no de significado. El LEFT JOIN no lo será.

En la lección siguiente, LEFT JOIN, recuperaremos todo lo que aquí hemos tirado. Volverán Núria, Hugo e Inés con sus pedidos vacíos; volverán las velas de soja, el desodorante y la espirulina que nadie ha comprado nunca; volverán los diez pedidos web con su comercial a NULL. Aprenderás el patrón anti-join para responder directamente a "¿qué clientes no han comprado nunca?" y verás, con la misma consulta escrita de dos formas, por qué colocar una condición en ON o en WHERE deja de ser indiferente.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados