En la lección anterior perdiste tres clientes, tres productos y diez pedidos sin que nadie te avisara. El LEFT JOIN es la herramienta que los recupera. Su regla es una sola frase: conserva todas las filas de la tabla izquierda, casen o no casen con la derecha; cuando no casan, las columnas de la derecha se rellenan con NULL.
Suena sencillo, y lo es. Pero el LEFT JOIN esconde la trampa más famosa de todo SQL intermedio: poner una condición en el ON o en el WHERE deja de ser indiferente. La diferencia no da error, no da aviso, y produce dos resultados distintos que parecen igualmente plausibles. Media lección está dedicada a que no caigas nunca en ella.
Contenido
- La regla del
LEFT JOINy el diagrama de conjuntos LEFT JOIN=LEFT OUTER JOIN- De dónde salen exactamente los
NULL - Los tres casos reales de TiendaVerde
- El patrón anti-join: encontrar lo que no casa
- La trampa: condición en
ONfrente a condición enWHERE LEFT JOINencadenados- Cuando la tabla derecha tiene varias filas por cada izquierda
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- La regla del
LEFT JOIN y el diagrama de conjuntos
LEFT JOIN y el diagrama de conjuntosflowchart LR
subgraph R[" "]
direction LR
A(("solo en la izquierda<br/>✅ se conserva<br/>con NULL a la derecha"))
I(("casan<br/>✅ resultado"))
B(("solo en la derecha<br/>❌ fuera"))
end
style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style I fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
style B fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
Como flujo de filas, ampliando el diagrama de 03-02:
flowchart LR
A["tabla izquierda"] --> C["cartesiano"]
B["tabla derecha"] --> C
C --> D["filtro ON"]
D --> E["✅ filas que casan"]
D --> F["filas izquierdas<br/>sin pareja"]
F --> G["✅ se añaden igualmente<br/>con NULL a la derecha"]
D --> H["❌ filas derechas<br/>sin pareja: se descartan"]
Ese paso extra —"se añaden igualmente con NULL a la derecha"— es el paso 1d del orden lógico que viste en 03-01. Ocurre dentro del FROM, después de aplicar el ON y antes del WHERE. Todo lo que explica esta lección se deduce de ese hecho.
La palabra "izquierda" es literal: es la tabla que escribes antes de la palabra LEFT JOIN.
FROM clientes AS c -- ← la izquierda: se conserva entera
LEFT JOIN pedidos AS pe -- ← la derecha: solo aporta lo que case
ON pe.cliente_id = c.idDe aquí sale la consecuencia más importante para escribir bien: el LEFT JOIN no es conmutativo. clientes LEFT JOIN pedidos conserva los 15 clientes; pedidos LEFT JOIN clientes conserva los 20 pedidos. Son preguntas distintas.
Cómo elegir el lado izquierdo: ponte la pregunta de negocio en voz alta y busca el sustantivo que debe aparecer completo en el informe. "Quiero todos los clientes, con sus pedidos si los tienen" →
clienteses la izquierda. "Quiero todos los pedidos, con su comercial si lo tienen" →pedidoses la izquierda.
LEFT JOIN = LEFT OUTER JOIN
LEFT JOIN = LEFT OUTER JOINLas dos escrituras son idénticas:
FROM clientes AS c LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
FROM clientes AS c LEFT OUTER JOIN pedidos AS pe ON pe.cliente_id = c.idOUTER es opcional y prácticamente nadie lo escribe. El adjetivo externo (outer) describe la familia entera: LEFT, RIGHT y FULL son joins externos porque conservan filas que quedan fuera del emparejamiento; INNER es el join interno porque solo devuelve lo que queda dentro.
| Escritura | Equivale a | Frecuencia real |
|---|---|---|
LEFT JOIN |
LEFT OUTER JOIN |
La habitual |
LEFT OUTER JOIN |
LEFT JOIN |
Poco frecuente, algo más en documentación formal |
En este curso escribimos LEFT JOIN.
- De dónde salen exactamente los
NULL
NULLEste punto se malinterpreta a menudo, así que conviene ser preciso: los NULL que aparecen en un LEFT JOIN no estaban en la base de datos. Los fabrica el motor en el momento de construir el resultado.
Cuando una fila de la izquierda no encuentra pareja, PostgreSQL la incorpora igualmente al resultado y rellena todas las columnas de la tabla derecha con NULL. No solo la de la clave: todas.
flowchart LR
A["cliente 13<br/>Núria Bosch Ferrer"] --> B{"¿algún pedido<br/>con cliente_id = 13?"}
B -->|"no"| C["fila conservada<br/>pe.id = NULL<br/>pe.fecha_pedido = NULL<br/>pe.estado = NULL<br/>pe.gastos_envio = NULL"]
De ahí salen dos consecuencias que usarás constantemente:
- Puedes detectar la ausencia mirando cualquier columna de la derecha. Si
pe.id IS NULLen unLEFT JOINdesdeclientes, es que no hubo pareja. Es la base del anti-join de la sección 5. - Conviene mirar una columna
NOT NULLde la derecha. Si eligieras una columna que puede ser nula de verdad en los datos, no sabrías distinguir "no hubo pareja" de "hubo pareja y ese dato estaba vacío". Por eso el anti-join se escribe siempre contra la clave primaria de la tabla derecha:pe.id,lp.id,r.id. Una PK nunca esNULLde forma legítima.
Un tercer efecto, más sutil, es la aritmética con nulos que viste en 02-02: cualquier operación con NULL da NULL. Si calculas pe.gastos_envio * 2 sobre una fila sin pareja, el resultado es NULL, no cero. La función COALESCE que resuelve esto se estudia en 06-04; el tratamiento completo de los nulos, en 04-03.
- Los tres casos reales de TiendaVerde
Los tres huecos deliberados del conjunto de datos existen precisamente para esta lección.
4.1. Todos los clientes con sus pedidos
SELECT c.id AS cliente_id,
c.nombre || ' ' || c.apellidos AS cliente,
c.ciudad,
pe.id AS pedido_id,
pe.fecha_pedido,
pe.estado
FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
ORDER BY c.id, pe.id;| cliente_id | cliente | ciudad | pedido_id | fecha_pedido | estado |
|---|---|---|---|---|---|
| 1 | Lucía Martínez Soler | Valencia | 1 | 2025-03-04 | entregado |
| 1 | Lucía Martínez Soler | Valencia | 5 | 2025-05-07 | entregado |
| 1 | Lucía Martínez Soler | Valencia | 15 | 2025-12-02 | entregado |
| 2 | Carlos Ferrer Ibáñez | Valencia | 2 | 2025-03-12 | entregado |
| 2 | Carlos Ferrer Ibáñez | Valencia | 11 | 2025-09-09 | entregado |
| 3 | Marta Sanchis Gil | Castellón | 3 | 2025-04-02 | entregado |
| 4 | Javier Ortega Ruiz | Madrid | 4 | 2025-04-19 | entregado |
| 4 | Javier Ortega Ruiz | Madrid | 16 | 2025-12-19 | enviado |
| 5 | Ana Belmonte Roca | Barcelona | 6 | 2025-05-23 | cancelado |
| 5 | Ana Belmonte Roca | Barcelona | 18 | 2026-01-27 | pagado |
| 6 | Pau Llorens Vidal | Valencia | 7 | 2025-06-11 | entregado |
| 6 | Pau Llorens Vidal | Valencia | 19 | 2026-02-09 | pagado |
| 7 | Sofia Moreira Costa | Lisboa | 8 | 2025-06-28 | entregado |
| 7 | Sofia Moreira Costa | Lisboa | 17 | 2026-01-13 | enviado |
| 8 | Tiago Almeida Nunes | Oporto | 9 | 2025-07-15 | entregado |
| 9 | Camille Dubois | Lyon | 10 | 2025-08-03 | entregado |
| 9 | Camille Dubois | Lyon | 20 | 2026-02-21 | pendiente |
| 10 | Julien Moreau | París | 12 | 2025-10-01 | entregado |
| 11 | Elena Navarro Puig | Alicante | 13 | 2025-10-22 | entregado |
| 12 | Diego Ramos Herrera | Sevilla | 14 | 2025-11-14 | entregado |
| 13 | Núria Bosch Ferrer | Barcelona | (null) | (null) | (null) |
| 14 | Hugo Iglesias Pardo | Zaragoza | (null) | (null) | (null) |
| 15 | Inés Carrasco Vega | Valencia | (null) | (null) | (null) |
23 filas. Compara con 03-02:
| Consulta | Filas | Composición |
|---|---|---|
clientes INNER JOIN pedidos |
20 | Solo pedidos reales |
clientes LEFT JOIN pedidos |
23 | 20 pedidos + 3 clientes sin pedidos |
Ahí están Núria, Hugo e Inés, con toda la parte de pedidos en NULL. La aritmética es exacta y merece la pena interiorizarla: el resultado de un LEFT JOIN tiene tantas filas como el INNER JOIN más una fila por cada fila izquierda huérfana.
4.2. Todos los productos con sus líneas de venta
Mismo patrón, ahora en el catálogo. Recortamos a cinco productos para ver el contraste con claridad:
SELECT p.id AS producto_id,
p.nombre AS producto,
lp.id AS linea_id,
lp.pedido_id,
lp.cantidad
FROM productos AS p
LEFT JOIN lineas_pedido AS lp ON lp.producto_id = p.id
WHERE p.id IN (12, 13, 18, 19, 20)
ORDER BY p.id, lp.id;| producto_id | producto | linea_id | pedido_id | cantidad |
|---|---|---|---|---|
| 12 | Bolsas reutilizables de algodón (pack 5) | 13 | 5 | 1 |
| 12 | Bolsas reutilizables de algodón (pack 5) | 31 | 13 | 2 |
| 12 | Bolsas reutilizables de algodón (pack 5) | 40 | 16 | 1 |
| 13 | Velas de cera de soja (pack 2) | (null) | (null) | (null) |
| 18 | Cepillo de dientes de bambú | 23 | 9 | 4 |
| 18 | Cepillo de dientes de bambú | 32 | 13 | 3 |
| 18 | Cepillo de dientes de bambú | 47 | 20 | 2 |
| 19 | Desodorante natural en barra 50 g | (null) | (null) | (null) |
| 20 | Cápsulas de espirulina 120 uds | (null) | (null) | (null) |
Los productos 12 y 18 se han vendido tres veces cada uno y ocupan tres filas. Los productos 13, 19 y 20 nunca se han vendido y ocupan una fila con todo a NULL.
Sin el WHERE de recorte, la consulta completa devuelve 50 filas: las 47 de lineas_pedido más las 3 de los productos nunca vendidos.
4.3. Todos los pedidos con su comercial
El tercer caso es distinto de los dos anteriores, y conviene fijarse en el matiz. Aquí no falta la fila hija: la clave foránea es NULL.
SELECT pe.id AS pedido_id,
pe.fecha_pedido,
pe.estado,
e.nombre || ' ' || e.apellidos AS comercial
FROM pedidos AS pe
LEFT JOIN empleados AS e ON pe.empleado_id = e.id
ORDER BY pe.id;| pedido_id | fecha_pedido | estado | comercial |
|---|---|---|---|
| 1 | 2025-03-04 | entregado | (null) |
| 2 | 2025-03-12 | entregado | Óscar Peris Blasco |
| 3 | 2025-04-02 | entregado | (null) |
| 4 | 2025-04-19 | entregado | Laia Puig Sanchis |
| 5 | 2025-05-07 | entregado | (null) |
| 6 | 2025-05-23 | cancelado | Óscar Peris Blasco |
| 7 | 2025-06-11 | entregado | (null) |
| 8 | 2025-06-28 | entregado | Laia Puig Sanchis |
| 9 | 2025-07-15 | entregado | (null) |
| 10 | 2025-08-03 | entregado | Óscar Peris Blasco |
| 11 | 2025-09-09 | entregado | (null) |
| 12 | 2025-10-01 | entregado | Laia Puig Sanchis |
| 13 | 2025-10-22 | entregado | (null) |
| 14 | 2025-11-14 | entregado | Marc Estévez Roig |
| 15 | 2025-12-02 | entregado | (null) |
| 16 | 2025-12-19 | enviado | Óscar Peris Blasco |
| 17 | 2026-01-13 | enviado | (null) |
| 18 | 2026-01-27 | pagado | Laia Puig Sanchis |
| 19 | 2026-02-09 | pagado | (null) |
| 20 | 2026-02-21 | pendiente | Marc Estévez Roig |
20 filas: los 20 pedidos. Frente a las 10 que devolvía el INNER JOIN de 03-02. Los NULL de la columna comercial significan aquí algo perfectamente legible para el negocio: pedido entrado por la web, sin comercial asignado. Diez de veinte, exactamente la proporción que 01-06 describía.
Fíjate en un detalle: comercial vale NULL por partida doble. Primero porque pe.empleado_id es NULL y no casa con nada; y segundo porque, aunque casara, la concatenación e.nombre || ' ' || e.apellidos sobre columnas nulas da NULL (la trampa de 02-02).
Resumen de los tres casos
| Caso | Izquierda | Derecha | Filas | Qué recupera |
|---|---|---|---|---|
| Clientes y pedidos | clientes (15) |
pedidos |
23 | Clientes 13, 14, 15 |
| Productos y ventas | productos (20) |
lineas_pedido |
50 | Productos 13, 19, 20 |
| Pedidos y comercial | pedidos (20) |
empleados |
20 | Los 10 pedidos web |
- El patrón anti-join: encontrar lo que no casa
Hasta ahora hemos usado el LEFT JOIN para conservar lo que no casa. Ahora vamos a usarlo para quedarnos solo con eso, que es una de las consultas más pedidas en cualquier empresa: clientes inactivos, productos sin rotación, facturas sin cobrar, usuarios sin verificar.
La técnica se llama anti-join y se escribe en dos movimientos:
- Un
LEFT JOINque conserva todo lo de la izquierda. - Un
WHERE <clave primaria de la derecha> IS NULLque se queda solo con las filas que no encontraron pareja.
flowchart LR
A["LEFT JOIN<br/>todas las izquierdas"] --> B["filas con pareja<br/>pe.id tiene valor"]
A --> C["filas sin pareja<br/>pe.id IS NULL"]
B --> D["❌ descartadas por el WHERE"]
C --> E["✅ el resultado que buscamos"]
5.1. Qué clientes no han comprado nunca
SELECT c.id,
c.nombre,
c.apellidos,
c.email,
c.ciudad,
c.fecha_registro
FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
WHERE pe.id IS NULL
ORDER BY c.id;| id | nombre | apellidos | ciudad | fecha_registro | |
|---|---|---|---|---|---|
| 13 | Núria | Bosch Ferrer | [email protected] | Barcelona | 2025-06-20 |
| 14 | Hugo | Iglesias Pardo | [email protected] | Zaragoza | 2025-09-12 |
| 15 | Inés | Carrasco Vega | [email protected] | Valencia | 2026-01-08 |
Tres filas. Esta es exactamente la lista que pediría el departamento de marketing para lanzar una campaña de primera compra.
5.2. Qué productos no se han vendido nunca
SELECT p.id,
p.nombre,
p.precio,
p.stock,
p.activo
FROM productos AS p
LEFT JOIN lineas_pedido AS lp ON lp.producto_id = p.id
WHERE lp.id IS NULL
ORDER BY p.id;| id | nombre | precio | stock | activo |
|---|---|---|---|---|
| 13 | Velas de cera de soja (pack 2) | 13.75 | 0 | true |
| 19 | Desodorante natural en barra 50 g | 7.80 | 75 | true |
| 20 | Cápsulas de espirulina 120 uds | 16.40 | 55 | false |
Tres filas, y con un diagnóstico distinto para cada una: las velas no se venden porque no hay stock; la espirulina porque está descatalogada (activo = false); el desodorante tiene stock y está activo, así que su problema es comercial. Un JOIN bien planteado no solo devuelve datos: apunta a la causa.
5.3. Las tres reglas del anti-join
| Regla | Por qué |
|---|---|
El JOIN debe ser LEFT (o RIGHT), nunca INNER |
Un INNER JOIN ya ha descartado las filas sin pareja: WHERE ... IS NULL devolvería siempre 0 filas |
La condición IS NULL va en el WHERE, nunca en el ON |
En el ON formaría parte del emparejamiento y no filtraría nada útil |
La columna comprobada debe ser NOT NULL en la tabla derecha |
Si eligieras una columna que admite nulos, confundirías "sin pareja" con "pareja con el dato vacío". Usa siempre su clave primaria |
Sobre IS NULL: es el operador correcto para preguntar por nulos, porque = NULL nunca es verdadero (lo viste en 02-03 con empleado_id = NULL devolviendo 0 filas). Su tratamiento completo, junto con IS NOT NULL y COALESCE, es la lección 04-03.
Nota: en el módulo 7 verás otras dos formas de escribir lo mismo:
NOT EXISTScon una subconsulta correlacionada, yNOT IN. Las tres tienen el mismo objetivo y distinto comportamiento ante los nulos. El anti-join conLEFT JOINes el que puedes escribir hoy, y es perfectamente idiomático.
- La trampa: condición en
ON frente a condición en WHERE
ON frente a condición en WHEREAquí está el contenido más importante de la lección. Presta atención a la pregunta, porque la clave está en ella:
"Dame todos los clientes, con sus pedidos entregados."
Fíjate en el "todos": el informe debe listar a los 15 clientes, tengan o no pedidos entregados. Vamos a escribirlo de las dos maneras posibles.
Versión A: la condición en el ON
-- ✅ CORRECTA para la pregunta planteada
SELECT c.id AS cliente_id,
c.nombre || ' ' || c.apellidos AS cliente,
pe.id AS pedido_id,
pe.fecha_pedido,
pe.estado
FROM clientes AS c
LEFT JOIN pedidos AS pe
ON pe.cliente_id = c.id
AND pe.estado = 'entregado'
ORDER BY c.id, pe.id;| cliente_id | cliente | pedido_id | fecha_pedido | estado |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | 1 | 2025-03-04 | entregado |
| 1 | Lucía Martínez Soler | 5 | 2025-05-07 | entregado |
| 1 | Lucía Martínez Soler | 15 | 2025-12-02 | entregado |
| 2 | Carlos Ferrer Ibáñez | 2 | 2025-03-12 | entregado |
| 2 | Carlos Ferrer Ibáñez | 11 | 2025-09-09 | entregado |
| 3 | Marta Sanchis Gil | 3 | 2025-04-02 | entregado |
| 4 | Javier Ortega Ruiz | 4 | 2025-04-19 | entregado |
| 5 | Ana Belmonte Roca | (null) | (null) | (null) |
| 6 | Pau Llorens Vidal | 7 | 2025-06-11 | entregado |
| 7 | Sofia Moreira Costa | 8 | 2025-06-28 | entregado |
| 8 | Tiago Almeida Nunes | 9 | 2025-07-15 | entregado |
| 9 | Camille Dubois | 10 | 2025-08-03 | entregado |
| 10 | Julien Moreau | 12 | 2025-10-01 | entregado |
| 11 | Elena Navarro Puig | 13 | 2025-10-22 | entregado |
| 12 | Diego Ramos Herrera | 14 | 2025-11-14 | entregado |
| 13 | Núria Bosch Ferrer | (null) | (null) | (null) |
| 14 | Hugo Iglesias Pardo | (null) | (null) | (null) |
| 15 | Inés Carrasco Vega | (null) | (null) | (null) |
18 filas y los 15 clientes presentes. Los que no tienen ningún pedido entregado aparecen con NULL: Núria, Hugo e Inés porque no han comprado nunca, y Ana Belmonte Roca porque sus dos pedidos están cancelado y pagado, ninguno entregado. Ana es el caso más interesante: existe en pedidos, pero ninguno de sus pedidos supera la condición del ON.
Versión B: la condición en el WHERE
Cambiamos exactamente dos palabras: AND pasa a ser WHERE.
-- ⚠️ INCORRECTA para la pregunta planteada
SELECT c.id AS cliente_id,
c.nombre || ' ' || c.apellidos AS cliente,
pe.id AS pedido_id,
pe.fecha_pedido,
pe.estado
FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
WHERE pe.estado = 'entregado'
ORDER BY c.id, pe.id;| cliente_id | cliente | pedido_id | fecha_pedido | estado |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | 1 | 2025-03-04 | entregado |
| 1 | Lucía Martínez Soler | 5 | 2025-05-07 | entregado |
| 1 | Lucía Martínez Soler | 15 | 2025-12-02 | entregado |
| 2 | Carlos Ferrer Ibáñez | 2 | 2025-03-12 | entregado |
| 2 | Carlos Ferrer Ibáñez | 11 | 2025-09-09 | entregado |
| 3 | Marta Sanchis Gil | 3 | 2025-04-02 | entregado |
| 4 | Javier Ortega Ruiz | 4 | 2025-04-19 | entregado |
| 6 | Pau Llorens Vidal | 7 | 2025-06-11 | entregado |
| 7 | Sofia Moreira Costa | 8 | 2025-06-28 | entregado |
| 8 | Tiago Almeida Nunes | 9 | 2025-07-15 | entregado |
| 9 | Camille Dubois | 10 | 2025-08-03 | entregado |
| 10 | Julien Moreau | 12 | 2025-10-01 | entregado |
| 11 | Elena Navarro Puig | 13 | 2025-10-22 | entregado |
| 12 | Diego Ramos Herrera | 14 | 2025-11-14 | entregado |
14 filas y solo 12 clientes. Han desaparecido Ana, Núria, Hugo e Inés. El LEFT JOIN se ha comportado como un INNER JOIN.
Por qué ocurre
Vuelve al orden lógico de 03-01 y sigue el recorrido de la fila de Núria en cada versión:
flowchart TD
subgraph A["Versión A · condición en ON"]
A1["FROM: cartesiano"] --> A2["ON: cliente_id = 13<br/>Y estado = 'entregado'<br/>→ ninguna pareja"]
A2 --> A3["1d: se añade Núria<br/>con pedidos en NULL"]
A3 --> A4["WHERE: no hay<br/>→ ✅ Núria sobrevive"]
end
subgraph B["Versión B · condición en WHERE"]
B1["FROM: cartesiano"] --> B2["ON: cliente_id = 13<br/>→ ninguna pareja"]
B2 --> B3["1d: se añade Núria<br/>con pedidos en NULL"]
B3 --> B4["WHERE: NULL = 'entregado'<br/>no es TRUE<br/>→ ❌ Núria se elimina"]
end
El paso 1d sí añade a Núria en las dos versiones. La diferencia está después: en la versión B, el WHERE evalúa pe.estado = 'entregado' sobre una fila cuyo pe.estado es NULL. Y NULL = 'entregado' no es FALSE, es NULL, que tampoco es TRUE, así que la fila se descarta. Es el mismo mecanismo de 02-03 actuando en un sitio nuevo.
La regla que hay que grabar: en un
LEFT JOIN, cualquier condición en elWHEREsobre una columna de la tabla derecha convierte elLEFTen unINNER, porque las filas rellenadas conNULLno pueden satisfacerla.
Tabla de decisión
| Dónde poner la condición sobre la tabla derecha | Efecto | Cuándo la quieres |
|---|---|---|
En el ON |
Restringe qué se empareja. Las filas izquierdas sin pareja se conservan con NULL |
"Todos los X, con sus Y que cumplan Z" |
En el WHERE |
Filtra el resultado final. El LEFT JOIN se degrada a INNER JOIN |
"Solo los X que tengan algún Y que cumpla Z" |
En el WHERE, con IS NULL |
Se queda solo con las filas sin pareja | Anti-join: "los X que no tienen ningún Y" |
Las tres son válidas: cada una responde a una pregunta distinta. El error no es usar el WHERE, es usarlo creyendo que hace lo del ON.
Un caso que sí es seguro: una condición en el WHERE sobre una columna de la tabla izquierda no degrada nada. WHERE c.pais = 'España' filtra clientes, y los que pasen el filtro conservan su NULL a la derecha sin problema. El peligro es exclusivamente el de las columnas de la derecha.
LEFT JOIN encadenados
LEFT JOIN encadenadosCon tres o más tablas el LEFT JOIN mantiene su lógica, pero aparece una regla que sorprende: un INNER JOIN colocado después de un LEFT JOIN anula el efecto del LEFT.
Queremos "todos los clientes, con sus pedidos y el comercial de cada pedido". Escrito con LEFT en los dos saltos:
-- ✅ CORRECTA: 23 filas, los 15 clientes
FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
LEFT JOIN empleados AS e ON pe.empleado_id = e.idY ahora el mismo camino con un INNER JOIN en el segundo salto:
-- ⚠️ INCORRECTA: 10 filas
FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
INNER JOIN empleados AS e ON pe.empleado_id = e.id| Consulta | Filas | Qué queda |
|---|---|---|
LEFT + LEFT |
23 | Los 15 clientes, los 20 pedidos, con NULL donde falte |
LEFT + INNER |
10 | Solo los 10 pedidos que tienen comercial |
El razonamiento: los JOIN se resuelven de izquierda a derecha, así que (clientes LEFT JOIN pedidos) produce 23 filas; sobre esas 23, el INNER JOIN con empleados exige que pe.empleado_id case con un empleado. Las 3 filas de clientes sin pedidos tienen pe.empleado_id = NULL y caen; las 10 filas de pedidos web tienen empleado_id = NULL y también caen. Quedan 10.
Regla práctica: una vez que has abierto una cadena con
LEFT JOIN, todos los saltos posteriores sobre esa rama deben serLEFT JOIN. Un soloINNERen medio deshace todo el trabajo, y lo hace sin ningún aviso.
Es un error muy fácil de cometer al ampliar una consulta existente: alguien añade JOIN productos ON ... al final de una consulta que empezaba con LEFT JOIN, y el informe pierde filas de un día para otro sin que nadie toque el LEFT.
- Cuando la tabla derecha tiene varias filas por cada izquierda
El LEFT JOIN no protege de la multiplicación de filas de 03-02. Sigue habiendo una fila por pareja:
SELECT p.id AS producto_id,
p.nombre AS producto,
r.id AS resena_id,
r.puntuacion
FROM productos AS p
LEFT JOIN resenas AS r ON r.producto_id = p.id
WHERE p.id IN (1, 2, 4, 6)
ORDER BY p.id, r.id;| producto_id | producto | resena_id | puntuacion |
|---|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | 1 | 5 |
| 1 | Aceite de oliva virgen extra 500 ml | 7 | 5 |
| 2 | Arroz integral ecológico 1 kg | 2 | 4 |
| 2 | Arroz integral ecológico 1 kg | 10 | 5 |
| 4 | Pasta de espelta 500 g | (null) | (null) |
| 6 | Crema facial de aloe vera 50 ml | 3 | 5 |
| 6 | Crema facial de aloe vera 50 ml | 9 | 4 |
Cuatro productos producen siete filas: dos reseñas para los productos 1, 2 y 6, y una fila vacía para el 4. Sobre el catálogo entero, productos LEFT JOIN resenas devuelve 23 filas: las 12 reseñas más los 11 productos sin ninguna.
La cuenta general de un LEFT JOIN es siempre esta:
| Ejemplo | Casan | Huérfanas | Total |
|---|---|---|---|
clientes LEFT JOIN pedidos |
20 | 3 | 23 |
productos LEFT JOIN lineas_pedido |
47 | 3 | 50 |
productos LEFT JOIN resenas |
12 | 11 | 23 |
pedidos LEFT JOIN empleados |
10 | 10 | 20 |
Y de nuevo el aviso del módulo 4: si sobre el primer caso sumaras c.fecha_registro, o contaras clientes, obtendrías cifras infladas, porque Lucía aparece tres veces. Un LEFT JOIN conserva filas; no las deduplica.
Errores Comunes y Consejos
- Poner una condición sobre la tabla derecha en el
WHERE. Degrada elLEFT JOINaINNER JOINsin decir nada. Es el error de esta lección: 18 filas contra 14, y cuatro clientes desaparecidos. - Mezclar un
INNER JOINdespués de unLEFT JOIN. Mismo efecto, en cadena: 23 filas se convierten en 10. - Escribir las tablas al revés.
pedidos LEFT JOIN clientesno esclientes LEFT JOIN pedidos. ElLEFT JOINno es conmutativo; la izquierda es la que se conserva entera. - Hacer el anti-join contra una columna que admite nulos.
WHERE pe.empleado_id IS NULLno distingue "no hubo pareja" de "hubo pareja con el dato vacío". Compara siempre contra la clave primaria de la derecha. - Intentar un anti-join con
INNER JOIN.INNER JOIN ... WHERE derecha.id IS NULLdevuelve siempre cero filas: elINNERya ha tirado esas filas. - Usar
= NULLen lugar deIS NULL.WHERE pe.id = NULLdevuelve cero filas siempre (02-03). - Suponer que
NULLen el resultado significa cero.pe.gastos_envioesNULL, no0.00, para un cliente sin pedidos. Operar con él propaga el nulo (COALESCE, en 06-04). - Olvidar que el
LEFT JOINsigue multiplicando filas. Conserva a los huérfanos, pero no impide que un cliente con tres pedidos ocupe tres filas. - Consejo: escribe primero el
INNER JOIN, comprueba el recuento, y luego cámbialo aLEFT. La diferencia entre ambos recuentos te dice exactamente cuántos huérfanos hay. - Consejo: para el anti-join, di la pregunta en voz alta. "Clientes sin pedidos", "productos nunca vendidos", "facturas sin cobrar. El "sin" y el "nunca" son la señal de que toca
LEFT JOIN ... WHERE ... IS NULL. - Consejo: sospecha de cualquier
WHEREque nombre la tabla derecha de unLEFT JOIN. Salvo que sea unIS NULLdeliberado, casi siempre debería estar en elON.
Ejercicios
Ejercicio 1
El equipo de producto quiere saber qué referencias del catálogo no han recibido ninguna reseña, para lanzar una campaña de solicitud de opiniones. Escribe la consulta que devuelva el id, el nombre, la categoría y el precio de esos productos, ordenados por id.
Después responde: ¿por qué el JOIN con categorias puede ser un INNER JOIN sin que eso rompa el anti-join?
Ejercicio 2
Logística necesita el listado completo de los 20 pedidos con la información de su devolución, si la hubo: id del pedido, fecha, estado, gastos de envío y —cuando exista devolución— su motivo e importe.
- Escribe la consulta.
- Escribe después la variante que devuelve solo los pedidos que no tuvieron devolución, e indica cuántas filas da.
Ejercicio 3
Un compañero te pasa esta consulta y te dice: "quiero todos los clientes con sus pedidos pagados por tarjeta, pero me faltan clientes".
SELECT c.id, c.nombre, pe.id AS pedido_id, pe.metodo_pago
FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
WHERE pe.metodo_pago = 'tarjeta';- Explica qué está haciendo realmente esa consulta.
- Corrígela para que devuelva lo que él quiere.
- Predice cuántas filas devuelve cada versión y cuántos clientes distintos aparecen en cada una.
Soluciones
Solución 1
SELECT p.id,
p.nombre AS producto,
cat.nombre AS categoria,
p.precio
FROM productos AS p
INNER JOIN categorias AS cat ON p.categoria_id = cat.id
LEFT JOIN resenas AS r ON r.producto_id = p.id
WHERE r.id IS NULL
ORDER BY p.id;| id | producto | categoria | precio |
|---|---|---|---|
| 3 | Miel de azahar cruda 500 g | Alimentación | 9.75 |
| 4 | Pasta de espelta 500 g | Alimentación | 2.80 |
| 7 | Champú sólido de romero 80 g | Cosmética natural | 8.40 |
| 8 | Aceite corporal de almendras 200 ml | Cosmética natural | 14.25 |
| 9 | Bálsamo labial de caléndula 15 ml | Cosmética natural | 4.60 |
| 11 | Estropajo vegetal de luffa (pack 3) | Hogar sostenible | 5.50 |
| 13 | Velas de cera de soja (pack 2) | Hogar sostenible | 13.75 |
| 14 | Infusión de manzanilla ecológica 20 uds | Bebidas | 3.25 |
| 17 | Zumo de naranja prensado en frío 1 L | Bebidas | 5.40 |
| 19 | Desodorante natural en barra 50 g | Higiene personal | 7.80 |
| 20 | Cápsulas de espirulina 120 uds | Complementos | 16.40 |
11 filas, exactamente los 11 productos sin reseña que anunciaba 01-06. Solo 9 de los 20 productos han sido reseñados alguna vez.
Por qué el JOIN con categorias puede ser INNER: porque la sección 7 advierte de los INNER JOIN que aparecen después de un LEFT JOIN sobre la misma rama. Aquí no es el caso: categorias cuelga de productos por una FK que apunta a una categoría que siempre existe, así que ese INNER JOIN no descarta ningún producto. Las 20 filas de partida siguen siendo 20 antes de aplicar el LEFT JOIN con resenas. La regla precisa es: un INNER JOIN es seguro mientras no pueda eliminar filas de la tabla protagonista.
(Si categoria_id admitiera NULL con datos reales nulos, ese INNER JOIN sí perdería productos y habría que escribirlo también como LEFT JOIN.)
Solución 2
1. Todos los pedidos con su devolución, si la hubo:
SELECT pe.id AS pedido_id,
pe.fecha_pedido,
pe.estado,
pe.gastos_envio,
d.motivo,
d.importe AS importe_devuelto
FROM pedidos AS pe
LEFT JOIN devoluciones AS d ON d.pedido_id = pe.id
ORDER BY pe.id;Devuelve 20 filas: las 3 con devolución (pedidos 6, 10 y 13) muestran motivo e importe; las otras 17 muestran *(null)* en esas dos columnas.
2. Solo los pedidos sin devolución (anti-join):
SELECT pe.id AS pedido_id,
pe.fecha_pedido,
pe.estado,
pe.gastos_envio
FROM pedidos AS pe
LEFT JOIN devoluciones AS d ON d.pedido_id = pe.id
WHERE d.id IS NULL
ORDER BY pe.id;| pedido_id | fecha_pedido | estado | gastos_envio |
|---|---|---|---|
| 1 | 2025-03-04 | entregado | 4.95 |
| 2 | 2025-03-12 | entregado | 0.00 |
| 3 | 2025-04-02 | entregado | 4.95 |
| 4 | 2025-04-19 | entregado | 4.95 |
| 5 | 2025-05-07 | entregado | 0.00 |
| 7 | 2025-06-11 | entregado | 6.50 |
| 8 | 2025-06-28 | entregado | 9.90 |
| 9 | 2025-07-15 | entregado | 9.90 |
| 11 | 2025-09-09 | entregado | 0.00 |
| 12 | 2025-10-01 | entregado | 12.50 |
| 14 | 2025-11-14 | entregado | 4.95 |
| 15 | 2025-12-02 | entregado | 0.00 |
| 16 | 2025-12-19 | enviado | 4.95 |
| 17 | 2026-01-13 | enviado | 9.90 |
| 18 | 2026-01-27 | pagado | 4.95 |
| 19 | 2026-02-09 | pagado | 4.95 |
| 20 | 2026-02-21 | pendiente | 12.50 |
17 filas = 20 pedidos − 3 devoluciones. Faltan justo los pedidos 6, 10 y 13.
Solución 3
1. Qué hace realmente. La condición pe.metodo_pago = 'tarjeta' está en el WHERE y se aplica a una columna de la tabla derecha. Toda fila de cliente sin pedido llega al WHERE con pe.metodo_pago a NULL, y NULL = 'tarjeta' no es TRUE. Resultado: el LEFT JOIN se degrada a INNER JOIN y la consulta devuelve solo los clientes que tienen al menos un pedido pagado con tarjeta. Es una pregunta legítima, pero no la que él quería.
2. Corrección: mover la condición al ON.
-- ✅ CORRECTA
SELECT c.id,
c.nombre,
pe.id AS pedido_id,
pe.metodo_pago
FROM clientes AS c
LEFT JOIN pedidos AS pe
ON pe.cliente_id = c.id
AND pe.metodo_pago = 'tarjeta'
ORDER BY c.id, pe.id;3. Predicción de filas. Los pedidos con metodo_pago = 'tarjeta' son los ids 1, 3, 5, 6, 8, 10, 11, 13, 15, 16 y 19: 11 pedidos, de 9 clientes distintos (Lucía aparece tres veces).
| Versión | Filas | Clientes distintos |
|---|---|---|
Original (WHERE) |
11 | 9 — solo los que pagaron alguna vez con tarjeta |
Corregida (ON) |
17 | 15 — los 11 emparejamientos + los 6 clientes sin ninguna compra con tarjeta, con NULL |
Los 6 clientes que aparecen con NULL en la versión corregida son: Tiago (8), Julien (10), Diego (12), Núria (13), Hugo (14) e Inés (15). Los tres últimos porque no han comprado nunca; los tres primeros porque compraron, pero pagando con PayPal o transferencia.
Conclusión
El LEFT JOIN es, en la práctica, el JOIN que más problemas de negocio resuelve:
- Conserva todas las filas de la tabla izquierda, casen o no, rellenando con
NULLlas columnas de la derecha.LEFT JOINyLEFT OUTER JOINson lo mismo. - Los
NULLlos fabrica el motor al construir el resultado; no estaban en los datos. Por eso puedes detectar la ausencia mirando la clave primaria de la tabla derecha. - Has recuperado los tres huecos de TiendaVerde: los clientes 13, 14 y 15 (23 filas en vez de 20), los productos 13, 19 y 20 (50 filas en vez de 47) y los 10 pedidos web (20 filas en vez de 10).
- El patrón anti-join
LEFT JOIN ... WHERE derecha.id IS NULLresponde a las preguntas con "sin" y "nunca": tres clientes que no han comprado, tres productos que nadie ha vendido, once productos sin reseñas. - Sabes que una condición sobre la tabla derecha en el
WHEREdegrada elLEFT JOINaINNER JOIN: 18 filas y 15 clientes contra 14 filas y 12 clientes, con la misma pregunta escrita de dos formas. Si quieres "todos los X con sus Y que cumplan Z", la condición va en elON. - Un
INNER JOINencadenado después de unLEFT JOINanula su efecto: 23 filas se quedan en 10. Abierta una rama conLEFT, sigue conLEFT. - El
LEFT JOINno deduplica: sigue multiplicando filas cuando la derecha tiene varias por cada izquierda.
En la lección siguiente, RIGHT JOIN, veremos el simétrico exacto de lo que acabas de aprender. Comprobarás que A RIGHT JOIN B y B LEFT JOIN A devuelven exactamente lo mismo, entenderás por qué la mayoría de guías de estilo prefiere el LEFT pese a ello, y verás por fin a Irene Salvador Mira y a Daniel Vercher Lluch, los dos empleados que nunca han gestionado un pedido.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
