Todo lo que has hecho en este módulo consiste en emparejar filas en horizontal: coger una fila de pedidos, buscarle su pareja en clientes y pegarlas de lado para obtener una fila más ancha. Los JOIN añaden columnas.

Existe una segunda forma de combinar, completamente distinta: apilar resultados en vertical. Se ejecutan dos consultas independientes, cada una con sus propias columnas, y sus filas se juntan una debajo de otra. Los operadores de conjuntos —UNION, INTERSECT y EXCEPTañaden filas.

Esta lección cierra el módulo 3. Con ella tendrás las dos maneras de combinar información en SQL y sabrás cuál pedir en cada caso.

Contenido

  1. JOIN frente a operadores de conjuntos
  2. Las reglas de compatibilidad
  3. UNION y UNION ALL
  4. La lista unificada de contactos
  5. INTERSECT: lo que está en ambos
  6. EXCEPT: lo que está en el primero y no en el segundo
  7. EXCEPT frente al anti-join de 03-03
  8. INTERSECT ALL y EXCEPT ALL
  9. Precedencia y paréntesis
  10. ORDER BY y LIMIT sobre el resultado combinado
  11. Soporte por motor
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión

  1. JOIN frente a operadores de conjuntos

flowchart TB
    subgraph J["JOIN — combina en HORIZONTAL"]
        direction LR
        J1["fila de A<br/>(3 columnas)"] --- J2["fila de B<br/>(4 columnas)"]
        J2 --- J3["→ una fila<br/>de 7 columnas"]
    end
    subgraph U["UNION — combina en VERTICAL"]
        direction TB
        U1["filas de A<br/>(3 columnas)"]
        U2["filas de B<br/>(3 columnas)"]
        U1 --- U2
        U2 --- U3["→ más filas,<br/>siempre 3 columnas"]
    end
JOIN Operadores de conjuntos
Qué hace Empareja filas de dos tablas Apila los resultados de dos consultas
Efecto sobre el resultado Más columnas Más filas
Relación entre las tablas Necesita una condición de emparejamiento (ON) Ninguna: las consultas son independientes
Requisito Que exista un camino entre las tablas Mismo número de columnas y tipos compatibles
Ejemplo "Cada pedido con el nombre de su cliente" "Todos los contactos: clientes, empleados y proveedores"

La diferencia clave es la segunda mitad de la tercera fila: los operadores de conjuntos no necesitan que las tablas estén relacionadas. Puedes unir los resultados de dos consultas sobre tablas que no comparten ni una clave foránea, siempre que sus columnas encajen.

  1. Las reglas de compatibilidad

Para que dos consultas puedan combinarse hay tres reglas, y las tres son estrictas:

1. Mismo número de columnas.

-- ⚠️ INCORRECTA
SELECT nombre, email FROM clientes
UNION
SELECT nombre FROM empleados;
ERROR:  each UNION query must have the same number of columns

2. Tipos compatibles, columna a columna, en el mismo orden.

La columna 1 de la primera consulta se combina con la columna 1 de la segunda, la 2 con la 2, etc. Los nombres no importan; importa la posición. PostgreSQL aplica sus reglas de conversión implícita: INTEGER y NUMERIC se combinan sin problema, VARCHAR y TEXT también, pero DATE y VARCHAR no.

-- ⚠️ INCORRECTA
SELECT nombre, fecha_registro FROM clientes
UNION
SELECT nombre, email FROM ??? ;
ERROR:  UNION types date and character varying cannot be matched

3. Los nombres de columna del resultado los pone la primera consulta.

SELECT nombre AS contacto FROM clientes
UNION ALL
SELECT nombre AS lo_que_sea FROM empleados;

La columna del resultado se llama contacto. El alias de la segunda consulta se ignora por completo, y esa es una fuente clásica de confusión al leer código ajeno.

Consecuencia práctica: escribe los alias en la primera consulta y no te molestes en repetirlos en las demás (aunque hacerlo ayuda a documentar qué es cada columna).

El truco de las columnas que faltan

¿Qué pasa si una tabla no tiene una columna que la otra sí tiene? En TiendaVerde, clientes y proveedores tienen email, pero empleados no. La solución es rellenar el hueco con un literal:

SELECT nombre, email FROM clientes
UNION ALL
SELECT nombre, NULL::varchar FROM empleados;

El ::varchar es un cast explícito. Sin él, PostgreSQL a veces puede inferir el tipo del NULL a partir de la otra rama, pero no siempre; escribirlo evita el error failed to determine data type of column. Los casts se estudian a fondo en la lección 06-04.

  1. UNION y UNION ALL

UNION apila las filas de dos consultas y elimina los duplicados. UNION ALL las apila y no elimina nada.

Veámoslo con las ciudades donde TiendaVerde tiene presencia:

SELECT ciudad FROM clientes
UNION
SELECT ciudad FROM empleados
ORDER BY ciudad;
ciudad
Alicante
Barcelona
Castellón
Lisboa
Lyon
Madrid
Oporto
París
Sevilla
Valencia
Zaragoza

11 filas. Y la misma consulta con UNION ALL devuelve 23 filas: las 15 ciudades de clientes (con Valencia repetida cuatro veces y Barcelona dos) más las 8 de empleados (con Valencia siete veces).

Operador Filas aquí Qué hace Coste
UNION ALL 23 Concatena, sin más Barato: solo lee y emite
UNION 11 Concatena y deduplica Caro: necesita ordenar o construir una tabla hash

Por qué UNION ALL suele ser lo que se quiere

UNION sin ALL hace un trabajo extra que muchas veces no solo es innecesario, sino incorrecto:

  • Es más lento. Deduplicar exige ordenar todo el resultado o mantener una estructura hash en memoria. Con millones de filas, la diferencia es enorme.
  • Puede borrar filas legítimas. Si dos clientes distintos se llamaran igual y vivieran en la misma ciudad, SELECT nombre, ciudad FROM clientes UNION ... los colapsaría en uno. Habrías perdido un cliente sin enterarte.
  • Compara la fila entera. Dos filas son duplicadas solo si todas sus columnas coinciden. Añadir una columna id al SELECT hace que UNION deje de eliminar nada, y entonces solo estás pagando el coste.

Regla del curso: usa UNION ALL por defecto. Recurre a UNION únicamente cuando la eliminación de duplicados sea el objetivo explícito de la consulta, como en el listado de ciudades de arriba. Es la misma filosofía que la de DISTINCT en 02-04: si lo necesitas para "arreglar" un resultado, revisa antes si la consulta está bien planteada.

Esta es también la razón por la que la emulación del FULL OUTER JOIN en MySQL (03-05) usa UNION y no UNION ALL: allí las dos mitades producen las mismas filas coincidentes, y hay que eliminarlas.

  1. La lista unificada de contactos

Un caso real: la empresa quiere una agenda única con todas las personas y entidades con las que trata, señalando de dónde sale cada una. Los tres orígenes viven en tablas distintas y sin ninguna relación entre sí: es el escenario perfecto para UNION ALL.

SELECT 'cliente' AS origen,
       c.nombre || ' ' || c.apellidos AS nombre,
       c.email,
       c.ciudad
FROM clientes AS c

UNION ALL

SELECT 'empleado',
       e.nombre || ' ' || e.apellidos,
       NULL::varchar,
       e.ciudad
FROM empleados AS e

UNION ALL

SELECT 'proveedor',
       pr.nombre,
       pr.email,
       NULL::varchar
FROM proveedores AS pr

ORDER BY origen, nombre;
origen nombre email ciudad
cliente Ana Belmonte Roca [email protected] Barcelona
cliente Camille Dubois [email protected] Lyon
cliente Carlos Ferrer Ibáñez [email protected] Valencia
cliente Diego Ramos Herrera [email protected] Sevilla
cliente Elena Navarro Puig [email protected] Alicante
cliente Hugo Iglesias Pardo [email protected] Zaragoza
cliente Inés Carrasco Vega [email protected] Valencia
cliente Javier Ortega Ruiz [email protected] Madrid
cliente Julien Moreau [email protected] París
cliente Lucía Martínez Soler [email protected] Valencia
cliente Marta Sanchis Gil [email protected] Castellón
cliente Núria Bosch Ferrer [email protected] Barcelona
cliente Pau Llorens Vidal [email protected] Valencia
cliente Sofia Moreira Costa [email protected] Lisboa
cliente Tiago Almeida Nunes [email protected] Oporto
empleado Andrés Company Talens (null) Valencia
empleado Beatriz Nadal Ripoll (null) Valencia
empleado Daniel Vercher Lluch (null) Valencia
empleado Irene Salvador Mira (null) Valencia
empleado Laia Puig Sanchis (null) Castellón
empleado Marc Estévez Roig (null) Valencia
empleado Óscar Peris Blasco (null) Valencia
empleado Rosa Alcázar Vives (null) Valencia
proveedor BioSierra Ibérica [email protected] (null)
proveedor EcoNordic Supplies [email protected] (null)
proveedor Huerta del Turia [email protected] (null)
proveedor Maison Nature [email protected] (null)
proveedor Verde Atlántico [email protected] (null)

28 filas = 15 clientes + 8 empleados + 5 proveedores.

Cuatro decisiones de diseño de esta consulta merecen comentario:

Decisión Por qué
Columna literal 'cliente' Sin ella, el resultado sería una lista de nombres sin saber de dónde sale cada uno. Una columna de origen es imprescindible en cualquier UNION de fuentes heterogéneas
UNION ALL y no UNION Un cliente y un empleado podrían llamarse igual; con UNION uno de los dos desaparecería. Además, la columna origen los hace distintos de todos modos, así que UNION solo costaría tiempo
NULL::varchar para las columnas ausentes empleados no tiene email y proveedores no tiene ciudad. El hueco se rellena con un nulo del tipo correcto
ORDER BY al final, una sola vez Ordena el resultado combinado, no cada consulta por separado. Ver sección 10

Fíjate también en el orden alfabético: "Óscar" aparece entre "Marc" y "Rosa" porque la base de datos usa la colación es-ES-x-icu que configuraste en 02-05. Con la colación por defecto C, "Óscar" iría al final del bloque, después de "Rosa".

  1. INTERSECT: lo que está en ambos

INTERSECT devuelve las filas que aparecen en las dos consultas.

flowchart LR
    subgraph R[" "]
        direction LR
        A(("solo en A<br/>❌"))
        I(("en A y en B<br/>✅"))
        B(("solo en B<br/>❌"))
    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

¿En qué ciudades tenemos a la vez clientes y empleados?

SELECT ciudad FROM clientes
INTERSECT
SELECT ciudad FROM empleados
ORDER BY ciudad;
ciudad
Castellón
Valencia

Dos ciudades. Valencia, donde está la sede y viven cuatro clientes, y Castellón, donde trabaja Laia Puig Sanchis y vive Marta Sanchis Gil. Es una consulta con lectura de negocio inmediata: son las ciudades donde se podría organizar una entrega en mano o un evento con clientes.

¿En qué países tenemos a la vez clientes y proveedores?

SELECT pais FROM clientes
INTERSECT
SELECT pais FROM proveedores
ORDER BY pais;
pais
España
Francia
Portugal

Tres países. Alemania queda fuera porque allí hay proveedor (EcoNordic Supplies) pero ningún cliente.

Dos propiedades de INTERSECT que conviene saber:

  • Elimina duplicados por defecto, igual que UNION. Si una ciudad apareciera cinco veces en clientes y tres en empleados, el resultado la muestra una vez.
  • Es conmutativo: A INTERSECT B y B INTERSECT A dan lo mismo. Es la propiedad que EXCEPT no tiene.

  1. EXCEPT: lo que está en el primero y no en el segundo

EXCEPT devuelve las filas de la primera consulta que no aparecen en la segunda. Es la diferencia de conjuntos.

flowchart LR
    subgraph R[" "]
        direction LR
        A(("solo en A<br/>✅"))
        I(("en A y en B<br/>❌"))
        B(("solo en B<br/>❌"))
    end
    style A fill:#d9f0d9,stroke:#2b7a2b,stroke-width:3px
    style I fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4
    style B fill:#f8f8f8,stroke:#bbb,stroke-dasharray: 4 4

¿Qué productos no se han vendido nunca? Los identificadores del catálogo, menos los identificadores que aparecen en las ventas:

SELECT id FROM productos
EXCEPT
SELECT producto_id FROM lineas_pedido
ORDER BY id;
id
13
19
20

Los tres de siempre. La consulta se lee casi como la pregunta: "todos los productos, quitando los que se han vendido".

¿En qué países tenemos proveedor pero ningún cliente?

SELECT pais FROM proveedores
EXCEPT
SELECT pais FROM clientes
ORDER BY pais;
pais
Alemania

Una fila. Y ahora la clave de EXCEPT: no es conmutativo. Dale la vuelta:

SELECT pais FROM clientes
EXCEPT
SELECT pais FROM proveedores
ORDER BY pais;
(0 filas)

Cero filas: no hay ningún país con clientes en el que no tengamos también proveedor. Las dos consultas responden a preguntas distintas, y confundirlas es el error más frecuente con este operador.

Consulta Significado Resultado
proveedores EXCEPT clientes Países donde compramos pero no vendemos Alemania
clientes EXCEPT proveedores Países donde vendemos pero no compramos (ninguno)

Nota de dialecto: en Oracle este operador se llama MINUS, no EXCEPT. Desde Oracle 21c también se admite EXCEPT como sinónimo, pero encontrarás MINUS en todo el código anterior. El comportamiento es idéntico.

  1. EXCEPT frente al anti-join de 03-03

Tienes ahora dos formas de responder a "¿qué productos no se han vendido nunca?". Compáralas:

-- Opción A: anti-join (03-03)
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;
-- Opción B: EXCEPT
SELECT id FROM productos
EXCEPT
SELECT producto_id FROM lineas_pedido
ORDER BY id;

Ambas identifican los productos 13, 19 y 20. Pero no son intercambiables:

Aspecto Anti-join EXCEPT
Columnas que puede devolver Todas las de productos: nombre, precio, stock, activo... Solo las que se comparan. Si añades nombre al primer SELECT, hay que añadir algo comparable al segundo, y lineas_pedido no tiene nombre de producto
Legibilidad de la intención Requiere entender por qué el IS NULL funciona Se lee como la frase: "los productos, menos los vendidos"
Duplicados Los conserva Los elimina siempre
Uso típico Informes: necesitas los datos de las filas encontradas Comprobaciones y conciliaciones: te basta con la lista de claves
Rendimiento Excelente con índice sobre la FK Requiere deduplicar ambos lados; suele ser algo más caro

Cuándo elegir cada uno: si necesitas datos de las filas resultantes, usa el anti-join. Si solo necesitas la lista de identificadores —para un recuento, una comprobación de integridad, un informe de conciliación—, EXCEPT es más corto y se lee mejor.

En el módulo 7 aparecerá una tercera forma, NOT EXISTS con una subconsulta correlacionada, que combina lo mejor de las dos: devuelve la fila entera y se lee como la pregunta. Y una cuarta, NOT IN, que parece la más natural y tiene un comportamiento traicionero con los nulos. Las cuatro se comparan en la lección 07-05.

  1. INTERSECT ALL y EXCEPT ALL

Igual que UNION tiene su variante ALL, INTERSECT y EXCEPT también la tienen. Su semántica es multiconjunto: en lugar de trabajar con presencia o ausencia, cuentan cuántas veces aparece cada fila.

Operador Si una fila aparece m veces en A y n veces en B, sale...
INTERSECT 1 vez (si m ≥ 1 y n ≥ 1)
INTERSECT ALL min(m, n) veces
EXCEPT 1 vez (si m ≥ 1 y n = 0)
EXCEPT ALL max(m − n, 0) veces

Ejemplo sobre las ciudades: Valencia aparece 4 veces en clientes y 7 veces en empleados.

SELECT ciudad FROM clientes
INTERSECT ALL
SELECT ciudad FROM empleados;

Devuelve Valencia 4 vecesmin(4, 7)— y Castellón 1 vezmin(1, 1)—: 5 filas en total. Con INTERSECT a secas serían 2.

SELECT ciudad FROM empleados
EXCEPT ALL
SELECT ciudad FROM clientes;

Devuelve Valencia 3 vecesmax(7 − 4, 0)— y nada más: max(1 − 1, 0) = 0 para Castellón.

En la práctica se usan muy poco. Su terreno son las comprobaciones de calidad de datos del tipo "¿esta migración ha duplicado filas?", donde importa el número de repeticiones y no solo su presencia. Es útil saber que existen; no es habitual escribirlas.

Nota de dialecto: INTERSECT ALL y EXCEPT ALL son estándar y están en PostgreSQL, pero no en SQL Server ni en Oracle (donde MINUS no tiene variante ALL).

  1. Precedencia y paréntesis

Cuando se encadenan tres o más consultas, el orden de evaluación importa. El estándar SQL establece que:

INTERSECT tiene mayor precedencia que UNION y EXCEPT, que se evalúan de izquierda a derecha entre sí.

Es decir, A UNION B INTERSECT C significa A UNION (B INTERSECT C), igual que 2 + 3 * 4 significa 2 + (3 * 4).

Veámoslo con un caso que cambia radicalmente según cómo se agrupe. La consulta:

SELECT id FROM productos
EXCEPT
SELECT producto_id FROM lineas_pedido
INTERSECT
SELECT producto_id FROM resenas
ORDER BY id;

PostgreSQL la evalúa como productos EXCEPT (lineas_pedido INTERSECT resenas):

  1. lineas_pedido INTERSECT resenas = los productos que se han vendido y tienen reseña = 9 productos (1, 2, 5, 6, 10, 12, 15, 16, 18).
  2. productos EXCEPT esos 9 = 11 productos: 3, 4, 7, 8, 9, 11, 13, 14, 17, 19 y 20.
id
3
4
7
8
9
11
13
14
17
19
20

Son exactamente los 11 productos sin ninguna reseña. Ahora forcemos la otra agrupación con paréntesis:

(SELECT id FROM productos
 EXCEPT
 SELECT producto_id FROM lineas_pedido)
INTERSECT
SELECT producto_id FROM resenas;
(0 filas)

Cero filas, porque (productos EXCEPT lineas_pedido) son los tres productos nunca vendidos (13, 19, 20) y ninguno de ellos tiene reseña. La misma consulta, dos agrupaciones, 11 filas contra 0.

Regla del curso: en cuanto haya tres o más consultas encadenadas con operadores distintos, usa paréntesis siempre, aunque coincidan con la precedencia por defecto. Cuestan dos caracteres y eliminan toda ambigüedad para quien lea el código.

Nota de dialecto importante: SQLite no implementa esta precedencia. Evalúa los operadores compuestos estrictamente de izquierda a derecha, así que la primera consulta de esta sección devolvería 0 filas en SQLite y 11 en PostgreSQL, MySQL, SQL Server y Oracle. Es un argumento definitivo a favor de los paréntesis explícitos: con ellos, la consulta significa lo mismo en todos los motores.

  1. ORDER BY y LIMIT sobre el resultado combinado

ORDER BY y LIMIT no pertenecen a ninguna de las consultas: se aplican al resultado combinado y van al final de todo.

-- ⚠️ INCORRECTA
SELECT ciudad FROM clientes ORDER BY ciudad
UNION
SELECT ciudad FROM empleados;
ERROR:  syntax error at or near "UNION"
-- ✅ CORRECTA
SELECT ciudad FROM clientes
UNION
SELECT ciudad FROM empleados
ORDER BY ciudad
LIMIT 5;
ciudad
Alicante
Barcelona
Castellón
Lisboa
Lyon

Las cinco primeras ciudades por orden alfabético del conjunto ya unificado. Detalles importantes:

Detalle Explicación
Los nombres válidos son los de la primera consulta Si la primera columna se llama contacto, escribe ORDER BY contacto, aunque en la segunda consulta la columna se llame de otro modo
Se puede ordenar por posición ORDER BY 1 ordena por la primera columna. Es especialmente cómodo aquí, donde los nombres pueden ser confusos (02-05)
LIMIT recorta el total, no cada mitad LIMIT 5 sobre un UNION de dos consultas de 15 y 8 filas devuelve 5 filas en total
Sigue valiendo la regla de 02-06 Sin ORDER BY, el orden del resultado combinado no está garantizado, ni siquiera el de "primero A y luego B"

Si necesitas ordenar o limitar una de las consultas por separado, hay que encerrarla entre paréntesis:

(SELECT ciudad FROM clientes ORDER BY ciudad LIMIT 3)
UNION ALL
(SELECT ciudad FROM empleados ORDER BY ciudad LIMIT 3);

Es sintaxis válida en PostgreSQL, aunque poco frecuente: casi siempre lo que se quiere es ordenar el total.

En el orden lógico de ejecución, el paso queda así: cada consulta se resuelve por completo (con su FROM, sus JOIN, su WHERE y su SELECT), después se aplica el operador de conjunto, y solo entonces se ordena y se recorta.

flowchart TD
    A["consulta 1<br/>FROM → WHERE → SELECT"] --> C["operador de conjunto<br/>UNION / INTERSECT / EXCEPT"]
    B["consulta 2<br/>FROM → WHERE → SELECT"] --> C
    C --> D["ORDER BY<br/>sobre el resultado combinado"]
    D --> E["LIMIT / OFFSET"]

  1. Soporte por motor

Motor UNION / UNION ALL INTERSECT EXCEPT Variantes ALL
PostgreSQL INTERSECT ALL, EXCEPT ALL
MySQL / MariaDB desde MySQL 8.0.31 (2022) ✅ desde 8.0.31 ✅ desde 8.0.31
SQLite ❌ Sin variantes ALL; además, precedencia de izquierda a derecha
SQL Server
Oracle ✅ como MINUS (y EXCEPT desde 21c)

UNION es el único de los tres que puedes dar por seguro en cualquier motor y cualquier versión. Si escribes SQL portable y necesitas INTERSECT o EXCEPT sobre MySQL antiguo, la alternativa es un JOIN (para la intersección) o un anti-join (para la diferencia), que es justamente lo que hacía todo el mundo antes de 2022.

Errores Comunes y Consejos

  • Distinto número de columnas. ERROR: each UNION query must have the same number of columns. Cuenta las columnas de cada rama antes de ejecutar.
  • Tipos incompatibles en la misma posición. Las columnas se emparejan por posición, no por nombre. Un DATE en la posición 2 de una rama y un VARCHAR en la 2 de la otra da error.
  • Esperar que el alias de la segunda consulta se use. Los nombres del resultado los pone siempre la primera.
  • Usar UNION cuando querías UNION ALL. Elimina filas legítimamente repetidas y cuesta más. Por defecto, UNION ALL.
  • Usar UNION ALL cuando sí había solapamiento. En la emulación del FULL OUTER JOIN (03-05) duplicaría todas las filas coincidentes.
  • Invertir el orden de un EXCEPT. No es conmutativo: proveedores EXCEPT clientes da Alemania; al revés, cero filas.
  • Poner ORDER BY en una consulta intermedia. ERROR: syntax error at or near "UNION". Va al final, una sola vez, y afecta al total.
  • Encadenar tres operadores sin paréntesis. INTERSECT se evalúa antes que UNION y EXCEPT en el estándar, pero no en SQLite. La misma consulta devolvía 11 filas y 0 filas según la agrupación.
  • Olvidar la columna de origen en un UNION de fuentes distintas. Sin ella no sabes si "Laia Puig Sanchis" es una empleada o una clienta.
  • Consejo: escribe la primera rama, ejecútala, y solo entonces añade las demás. Depurar un UNION de cuatro consultas escrito de una tacada es incómodo; los errores de tipo señalan la unión, no la columna culpable.
  • Consejo: alinea visualmente las ramas. Mismo orden de columnas, misma indentación y el operador en una línea propia. Un UNION bien formateado se revisa de un vistazo.
  • Consejo: usa ORDER BY 1, 2 en las consultas de conjuntos. Los nombres pueden ser engañosos cuando las ramas vienen de tablas distintas; los ordinales, no.

Ejercicios

Ejercicio 1

Construye un directorio de contactos españoles: todas las personas y entidades de TiendaVerde cuyo país sea España, con una columna que indique su tipo (cliente, empleado o proveedor), su nombre y su ciudad si la hay.

Ten en cuenta que empleados no tiene columna pais —todos trabajan en España— y que proveedores no tiene ciudad. Ordena por tipo y nombre.

Ejercicio 2

Responde con operadores de conjuntos:

  1. ¿En qué ciudades hay clientes pero ningún empleado?
  2. ¿En qué ciudades hay empleados pero ningún cliente?
  3. Explica por qué los dos resultados son tan distintos en tamaño.

Ejercicio 3

Un compañero quiere la lista de productos que se han vendido pero no tienen ninguna reseña, y escribe esto:

SELECT producto_id FROM lineas_pedido
EXCEPT
SELECT producto_id FROM resenas
UNION
SELECT id FROM productos
ORDER BY 1;
  1. ¿Cómo agrupa PostgreSQL esta consulta y qué devuelve realmente?
  2. Escribe la consulta correcta para lo que él quería.
  3. Escribe la misma respuesta usando un JOIN y un anti-join en lugar de operadores de conjuntos, devolviendo también el nombre del producto. ¿Cuál de las dos prefieres y por qué?

Soluciones

Solución 1

SELECT 'cliente' AS tipo,
       c.nombre || ' ' || c.apellidos AS nombre,
       c.ciudad
FROM clientes AS c
WHERE c.pais = 'España'

UNION ALL

SELECT 'empleado',
       e.nombre || ' ' || e.apellidos,
       e.ciudad
FROM empleados AS e

UNION ALL

SELECT 'proveedor',
       pr.nombre,
       NULL::varchar
FROM proveedores AS pr
WHERE pr.pais = 'España'

ORDER BY tipo, nombre;
tipo nombre ciudad
cliente Ana Belmonte Roca Barcelona
cliente Carlos Ferrer Ibáñez Valencia
cliente Diego Ramos Herrera Sevilla
cliente Elena Navarro Puig Alicante
cliente Hugo Iglesias Pardo Zaragoza
cliente Inés Carrasco Vega Valencia
cliente Javier Ortega Ruiz Madrid
cliente Lucía Martínez Soler Valencia
cliente Marta Sanchis Gil Castellón
cliente Núria Bosch Ferrer Barcelona
cliente Pau Llorens Vidal Valencia
empleado Andrés Company Talens Valencia
empleado Beatriz Nadal Ripoll Valencia
empleado Daniel Vercher Lluch Valencia
empleado Irene Salvador Mira Valencia
empleado Laia Puig Sanchis Castellón
empleado Marc Estévez Roig Valencia
empleado Óscar Peris Blasco Valencia
empleado Rosa Alcázar Vives Valencia
proveedor BioSierra Ibérica (null)
proveedor Huerta del Turia (null)

21 filas = 11 clientes españoles + 8 empleados + 2 proveedores españoles.

Tres detalles del razonamiento:

  • empleados no lleva WHERE porque la tabla no tiene columna pais: por diseño, todo el equipo trabaja en España. Es una suposición del modelo, y conviene dejarla escrita en un comentario para que nadie la dé por olvidada.
  • Los filtros van en el WHERE de cada rama, no al final. Un WHERE después del último UNION ALL pertenecería solo a la tercera consulta, no al conjunto.
  • NULL::varchar mantiene el número de columnas en la rama de proveedores.

Solución 2

1. Ciudades con clientes pero sin empleados:

SELECT ciudad FROM clientes
EXCEPT
SELECT ciudad FROM empleados
ORDER BY ciudad;
ciudad
Alicante
Barcelona
Lisboa
Lyon
Madrid
Oporto
París
Sevilla
Zaragoza

9 ciudades.

2. Ciudades con empleados pero sin clientes:

SELECT ciudad FROM empleados
EXCEPT
SELECT ciudad FROM clientes
ORDER BY ciudad;
(0 filas)

Ninguna.

3. Por qué son tan distintos. Porque los dos conjuntos tienen tamaños y naturalezas muy diferentes:

Conjunto Ciudades distintas Cuáles
Ciudades de clientes 11 Valencia, Castellón, Madrid, Barcelona, Alicante, Sevilla, Zaragoza, Lisboa, Oporto, Lyon, París
Ciudades de empleados 2 Valencia, Castellón

Las ciudades de los empleados son un subconjunto de las de los clientes: TiendaVerde solo tiene oficinas en Valencia (sede) y Castellón (donde trabaja Laia), y en ambas hay clientes. Por eso empleados EXCEPT clientes es vacío, mientras que la operación inversa devuelve las otras nueve ciudades donde hay clientes y ninguna presencia física.

Comprobación cruzada con la sección 5: 11 ciudades en total (UNION), 2 en común (INTERSECT), 9 solo de clientes (EXCEPT) y 0 solo de empleados. Los números cuadran: 2 + 9 + 0 = 11.

Solución 3

1. Cómo agrupa PostgreSQL. No hay INTERSECT en la consulta, así que los dos operadores restantes se evalúan de izquierda a derecha:

((lineas_pedido EXCEPT resenas) UNION productos)

La primera parte da los 8 productos vendidos sin reseña; la segunda añade los 20 productos del catálogo. La unión de ambos conjuntos son... los 20 productos. La consulta devuelve el catálogo entero, que no tiene absolutamente nada que ver con la pregunta. El UNION con productos anula todo el trabajo del EXCEPT.

2. La consulta correcta. La tercera rama sobra por completo:

-- ✅ CORRECTA
SELECT producto_id FROM lineas_pedido
EXCEPT
SELECT producto_id FROM resenas
ORDER BY 1;
producto_id
3
4
7
8
9
11
14
17

8 productos vendidos alguna vez y nunca reseñados. Compáralo con los 11 productos sin reseña de la sección 9: la diferencia son los tres que nunca se han vendido (13, 19 y 20), que aquí no aparecen porque no están en lineas_pedido.

3. Con JOIN y anti-join:

SELECT DISTINCT p.id,
       p.nombre AS producto,
       p.precio
FROM productos AS p
INNER JOIN lineas_pedido AS lp ON lp.producto_id = p.id
LEFT  JOIN resenas       AS r  ON r.producto_id  = p.id
WHERE r.id IS NULL
ORDER BY p.id;
id producto precio
3 Miel de azahar cruda 500 g 9.75
4 Pasta de espelta 500 g 2.80
7 Champú sólido de romero 80 g 8.40
8 Aceite corporal de almendras 200 ml 14.25
9 Bálsamo labial de caléndula 15 ml 4.60
11 Estropajo vegetal de luffa (pack 3) 5.50
14 Infusión de manzanilla ecológica 20 uds 3.25
17 Zumo de naranja prensado en frío 1 L 5.40

Los mismos 8 productos, ahora con su nombre y su precio. El INNER JOIN con lineas_pedido exige que se hayan vendido; el LEFT JOIN con resenas más el WHERE r.id IS NULL exige que no tengan reseña.

Cuál preferir:

EXCEPT JOIN + anti-join
A favor Corto, se lee como la pregunta, imposible equivocarse con los duplicados Devuelve la fila entera: nombre, precio, stock, lo que haga falta
En contra Solo devuelve identificadores; para el informe hay que volver a productos Necesita un DISTINCT —porque el INNER JOIN multiplica por cada línea vendida— y hay que razonar el IS NULL

Para este caso concreto, el JOIN es la mejor opción, porque un informe con ocho números sin nombre no le sirve a nadie. El EXCEPT sería preferible si la consulta fuera un paso intermedio de una comprobación automática, donde solo importan los identificadores.

Y fíjate en el DISTINCT: es exactamente el síntoma del que hablábamos en 02-04 y 03-02. Aparece porque el INNER JOIN con lineas_pedido genera una fila por venta, y la pregunta es sobre productos, no sobre ventas. En el módulo 7 verás que WHERE EXISTS (...) resuelve esto sin DISTINCT y sin multiplicar filas.

Conclusión

Cierras la última pieza del módulo:

  • Los operadores de conjuntos combinan en vertical: apilan filas de consultas independientes, mientras que los JOIN combinan en horizontal añadiendo columnas. No necesitan ninguna relación entre las tablas.
  • Las reglas de compatibilidad son tres: mismo número de columnas, tipos compatibles por posición y nombres del resultado tomados de la primera consulta. Los huecos se rellenan con literales como NULL::varchar.
  • UNION ALL es la opción por defecto: no deduplica, es más rápido y no borra filas legítimamente repetidas. UNION solo cuando eliminar duplicados sea el objetivo, como en la lista de 11 ciudades frente a las 23 de UNION ALL.
  • Has construido la lista unificada de contactos (28 filas de tres tablas sin relación entre sí) con una columna de origen literal, que es lo que hace legible cualquier UNION de fuentes heterogéneas.
  • INTERSECT devuelve lo que está en ambos lados y es conmutativo: dos ciudades con clientes y empleados, tres países con clientes y proveedores.
  • EXCEPT devuelve lo del primero que no está en el segundo y no es conmutativo: Alemania en un sentido, cero filas en el otro. En Oracle se llama MINUS.
  • Sabes cuándo elegir EXCEPT y cuándo el anti-join de 03-03: EXCEPT para listas de identificadores, anti-join cuando necesitas los datos de las filas.
  • INTERSECT tiene precedencia sobre UNION y EXCEPT —salvo en SQLite, que evalúa de izquierda a derecha—, y la misma consulta puede devolver 11 filas o 0 según cómo se agrupe. Usa paréntesis siempre que encadenes tres o más.
  • ORDER BY y LIMIT van al final y se aplican al resultado combinado, usando los nombres de columna de la primera consulta o los ordinales.

Y con esto cierras el módulo 3

Las nueve tablas de TiendaVerde han dejado de ser nueve islas. Sabes:

  • Que un JOIN es un producto cartesiano filtrado, que su condición va en el ON y que los JOIN se resuelven dentro del FROM, antes del WHERE.
  • Usar el INNER JOIN y predecir qué filas pierde, con la consulta canónica de cuatro tablas —lineas_pedido + pedidos + clientes + productos— que te acompañará hasta el proyecto final.
  • Usar el LEFT JOIN para conservar lo que no casa, el patrón anti-join para encontrarlo, y por qué una condición en el WHERE sobre la tabla derecha degrada silenciosamente el LEFT a INNER.
  • Que el RIGHT JOIN es su espejo y siempre se puede reescribir como LEFT, y que mezclarlos en una cadena hace la consulta ilegible.
  • Que el FULL OUTER JOIN sirve para conciliar dos fuentes independientes, y que la integridad referencial lo hace innecesario dentro de un mismo esquema.
  • Recorrer las relaciones reflexivas con SELF JOIN —el organigrama y la red de referidos— y generar combinaciones completas con CROSS JOIN.
  • Y apilar resultados enteros con UNION, INTERSECT y EXCEPT.

Pero fíjate en qué devuelven todas las consultas que has escrito en este módulo: filas de detalle. Una fila por línea de pedido, una por cliente, una por pareja de productos. Y las preguntas que la dirección de TiendaVerde hace de verdad no piden detalle, piden totales: cuánto factura cada categoría, cuántos pedidos ha hecho cada cliente, cuál es la puntuación media de cada producto, qué comercial cierra más ventas, cuántos clientes hay por país. Para responderlas hay que resumir muchas filas en una sola, y eso es una operación que todavía no sabes hacer.

En el módulo 4, Filtrado avanzado y agregación, llegarán las dos mitades que faltan. Primero afinarás el filtrado: LIKE para buscar por patrones de texto, IN y BETWEEN para rangos y listas, y el tratamiento serio de los NULL con IS NULL, IS NOT NULL y su lógica de tres valores —esa que ya has visto asomar en el ON, en el WHERE y en cada LEFT JOIN de este módulo—. Y después llegará la agregación: las funciones COUNT, SUM, AVG, MIN y MAX, la cláusula GROUP BY que parte el resultado en grupos y HAVING para filtrar esos grupos. Ahí volverá, esta vez con consecuencias reales, la advertencia que has leído tres veces en este módulo: cuidado con sumar valores de cabecera después de unir con el detalle.

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