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 EXCEPT— añ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
JOINfrente a operadores de conjuntos- Las reglas de compatibilidad
UNIONyUNION ALL- La lista unificada de contactos
INTERSECT: lo que está en ambosEXCEPT: lo que está en el primero y no en el segundoEXCEPTfrente al anti-join de 03-03INTERSECT ALLyEXCEPT ALL- Precedencia y paréntesis
ORDER BYyLIMITsobre el resultado combinado- Soporte por motor
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
JOIN frente a operadores de conjuntos
JOIN frente a operadores de conjuntosflowchart 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.
- Las reglas de compatibilidad
Para que dos consultas puedan combinarse hay tres reglas, y las tres son estrictas:
1. Mismo número de columnas.
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.
3. Los nombres de columna del resultado los pone la primera consulta.
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:
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.
UNION y UNION ALL
UNION y UNION ALLUNION 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:
| 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
idalSELECThace queUNIONdeje de eliminar nada, y entonces solo estás pagando el coste.
Regla del curso: usa
UNION ALLpor defecto. Recurre aUNIONú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 deDISTINCTen 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 sí producen las mismas filas coincidentes, y hay que eliminarlas.
- 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 | 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".
INTERSECT: lo que está en ambos
INTERSECT: lo que está en ambosINTERSECT 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?
| 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?
| 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 enclientesy tres enempleados, el resultado la muestra una vez. - Es conmutativo:
A INTERSECT ByB INTERSECT Adan lo mismo. Es la propiedad queEXCEPTno tiene.
EXCEPT: lo que está en el primero y no en el segundo
EXCEPT: lo que está en el primero y no en el segundoEXCEPT 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:
| 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?
| pais |
|---|
| Alemania |
Una fila. Y ahora la clave de EXCEPT: no es conmutativo. Dale la vuelta:
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, noEXCEPT. Desde Oracle 21c también se admiteEXCEPTcomo sinónimo, pero encontrarásMINUSen todo el código anterior. El comportamiento es idéntico.
EXCEPT frente al anti-join de 03-03
EXCEPT frente al anti-join de 03-03Tienes 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—,
EXCEPTes 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.
INTERSECT ALL y EXCEPT ALL
INTERSECT ALL y EXCEPT ALLIgual 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.
Devuelve Valencia 4 veces —min(4, 7)— y Castellón 1 vez —min(1, 1)—: 5 filas en total. Con INTERSECT a secas serían 2.
Devuelve Valencia 3 veces —max(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 ALLyEXCEPT ALLson estándar y están en PostgreSQL, pero no en SQL Server ni en Oracle (dondeMINUSno tiene varianteALL).
- Precedencia y paréntesis
Cuando se encadenan tres o más consultas, el orden de evaluación importa. El estándar SQL establece que:
INTERSECTtiene mayor precedencia queUNIONyEXCEPT, 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):
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).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;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.
ORDER BY y LIMIT sobre el resultado combinado
ORDER BY y LIMIT sobre el resultado combinadoORDER BY y LIMIT no pertenecen a ninguna de las consultas: se aplican al resultado combinado y van al final de todo.
-- ✅ 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"]
- 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
DATEen la posición 2 de una rama y unVARCHARen 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
UNIONcuando queríasUNION ALL. Elimina filas legítimamente repetidas y cuesta más. Por defecto,UNION ALL. - Usar
UNION ALLcuando sí había solapamiento. En la emulación delFULL OUTER JOIN(03-05) duplicaría todas las filas coincidentes. - Invertir el orden de un
EXCEPT. No es conmutativo:proveedores EXCEPT clientesda Alemania; al revés, cero filas. - Poner
ORDER BYen 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.
INTERSECTse evalúa antes queUNIONyEXCEPTen 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
UNIONde 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
UNIONde 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
UNIONbien formateado se revisa de un vistazo. - Consejo: usa
ORDER BY 1, 2en 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:
- ¿En qué ciudades hay clientes pero ningún empleado?
- ¿En qué ciudades hay empleados pero ningún cliente?
- 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;- ¿Cómo agrupa PostgreSQL esta consulta y qué devuelve realmente?
- Escribe la consulta correcta para lo que él quería.
- Escribe la misma respuesta usando un
JOINy 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:
empleadosno llevaWHEREporque la tabla no tiene columnapais: 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
WHEREde cada rama, no al final. UnWHEREdespués del últimoUNION ALLpertenecería solo a la tercera consulta, no al conjunto. NULL::varcharmantiene el número de columnas en la rama de proveedores.
Solución 2
1. Ciudades con clientes pero sin empleados:
| ciudad |
|---|
| Alicante |
| Barcelona |
| Lisboa |
| Lyon |
| Madrid |
| Oporto |
| París |
| Sevilla |
| Zaragoza |
9 ciudades.
2. Ciudades con empleados pero sin clientes:
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:
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
JOINcombinan 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 ALLes la opción por defecto: no deduplica, es más rápido y no borra filas legítimamente repetidas.UNIONsolo cuando eliminar duplicados sea el objetivo, como en la lista de 11 ciudades frente a las 23 deUNION 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
UNIONde fuentes heterogéneas. INTERSECTdevuelve lo que está en ambos lados y es conmutativo: dos ciudades con clientes y empleados, tres países con clientes y proveedores.EXCEPTdevuelve 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 llamaMINUS.- Sabes cuándo elegir
EXCEPTy cuándo el anti-join de 03-03:EXCEPTpara listas de identificadores, anti-join cuando necesitas los datos de las filas. INTERSECTtiene precedencia sobreUNIONyEXCEPT—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 BYyLIMITvan 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
JOINes un producto cartesiano filtrado, que su condición va en elONy que losJOINse resuelven dentro delFROM, antes delWHERE. - Usar el
INNER JOINy 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 JOINpara conservar lo que no casa, el patrón anti-join para encontrarlo, y por qué una condición en elWHEREsobre la tabla derecha degrada silenciosamente elLEFTaINNER. - Que el
RIGHT JOINes su espejo y siempre se puede reescribir comoLEFT, y que mezclarlos en una cadena hace la consulta ilegible. - Que el
FULL OUTER JOINsirve 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 conCROSS JOIN. - Y apilar resultados enteros con
UNION,INTERSECTyEXCEPT.
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
- ¿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
