Módulo 34: Consultas multitabla
Objetivos del módulo
- Entender por qué hay que combinar tablas y qué es el producto cartesiano
- Escribir combinaciones con la sintaxis tradicional de Oracle (condiciones en el
WHERE) y con la sintaxis ANSI (JOIN ... ON,USING,NATURAL JOIN) - Combinar tres, cuatro y hasta seis tablas del esquema EMPRESA
- Dominar las combinaciones externas
LEFT,RIGHTyFULL OUTER JOINy el operador(+)de Oracle con sus limitaciones - Comprender la diferencia entre poner una condición en el
ONo en elWHEREde unOUTER JOIN - Combinar una tabla consigo misma (self join), hacer combinaciones por rangos (non-equijoin) y resolver antijoins y semijoins con
OUTER JOIN - Relacionar cada tipo de combinación con su operador del álgebra relacional
- Resolver 36 ejercicios de consultas multitabla
1. ¿Por qué combinar tablas?
En el diseño lógico (módulos 17 a 20) repartiste la información en varias tablas para evitar redundancias: el nombre del cliente está en CLIENTES, no repetido en cada pedido; el nombre del producto está en PRODUCTOS, no en cada línea. Es un buen diseño, pero tiene una consecuencia: para responder a muchas preguntas hay que volver a juntar la información.
Mira el pedido 5001 tal como está en la base de datos:
SELECT * FROM pedidos WHERE pedido_id = 5001;
SELECT * FROM lineas_pedido WHERE pedido_id = 5001;
PEDIDO_ID CLIENTE_ID EMPLEADO_ID FECHA_PEDI FECHA_ENVI ESTADO
---------- ---------- ----------- ---------- ---------- ---------
5001 1001 110 15/01/2025 17/01/2025 ENTREGADO
1 fila seleccionada.
PEDIDO_ID NUM_LINEA PRODUCTO_ID CANTIDAD PRECIO_UNITARIO DESCUENTO
---------- ---------- ----------- ---------- --------------- ----------
5001 1 2 1 1499 0
5001 2 6 2 19,9 0
5001 3 9 2 139 5
3 filas seleccionadas.
Para imprimir la factura necesitas el nombre del cliente 1001, el del empleado 110 y los de los productos 2, 6 y 9, que están en otras tablas. Las claves ajenas (cliente_id, empleado_id, producto_id) son el “hilo” que permite seguir la relación.
📘 Concepto: Una combinación o join es una operación que produce filas formadas por columnas de dos o más tablas, emparejando las filas que cumplen una condición de combinación (normalmente, la igualdad entre una clave ajena y la clave primaria que referencia).
erDiagram
DEPARTAMENTOS ||--o{ EMPLEADOS : "trabaja en"
EMPLEADOS |o--o{ EMPLEADOS : "es jefe de"
EMPLEADOS |o--o{ CLIENTES : "es comercial de"
CLIENTES ||--o{ PEDIDOS : "hace"
EMPLEADOS |o--o{ PEDIDOS : "gestiona"
PEDIDOS ||--|{ LINEAS_PEDIDO : "contiene"
PRODUCTOS ||--o{ LINEAS_PEDIDO : "aparece en"
CATEGORIAS ||--o{ PRODUCTOS : "clasifica"
CATEGORIAS |o--o{ CATEGORIAS : "es padre de"
DEPARTAMENTOS |o--o{ PROYECTOS : "dirige"
EMPLEADOS ||--o{ ASIGNACIONES : "participa"
PROYECTOS ||--o{ ASIGNACIONES : "tiene"
Cada línea del diagrama es una clave ajena y, por tanto, un posible camino de combinación.
2. El producto cartesiano
2.1. Qué es
Si en el FROM pones dos tablas separadas por comas sin condición, Oracle combina cada fila de la primera con cada fila de la segunda: es el producto cartesiano (× en álgebra relacional). Con 24 empleados y 7 departamentos salen 24 × 7 = 168 filas:
SELECT COUNT(*) AS filas FROM empleados, departamentos;
FILAS
----------
168
1 fila seleccionada.
Con un ejemplo pequeño se ve qué contiene: los dos departamentos de Madrid combinados con los dos primeros proyectos.
SELECT d.dept_id, d.nombre AS departamento, p.proyecto_id, p.nombre AS proyecto
FROM departamentos d, proyectos p
WHERE d.localidad = 'Madrid'
AND p.proyecto_id <= 2;
DEPT_ID DEPARTAMENTO PROYECTO_ID PROYECTO
---------- ------------ ----------- -------------------
10 DIRECCIÓN 1 Migración a la nube
10 DIRECCIÓN 2 Nueva tienda online
20 VENTAS 1 Migración a la nube
20 VENTAS 2 Nueva tienda online
4 filas seleccionadas.
Todas las parejas posibles, tengan o no relación entre sí: el proyecto 1 es del departamento 30, pero aparece emparejado con el 10 y con el 20.
2.2. CROSS JOIN
La sintaxis ANSI para pedir el producto cartesiano explícitamente es CROSS JOIN:
SELECT COUNT(*) AS filas
FROM empleados CROSS JOIN departamentos;
FILAS
----------
168
1 fila seleccionada.
⚠️ Importante: El producto cartesiano casi siempre es un error: olvidar la condición de combinación. Con tablas reales el resultado es enorme (100 000 clientes × 1 000 000 de pedidos = 10¹¹ filas) y puede dejar el servidor sin recursos. Si una consulta multitabla devuelve muchísimas más filas de las esperadas, revisa que no te falte una condición.
💡 Consejo: El
CROSS JOINtiene usos legítimos: generar todas las combinaciones de dos listas (por ejemplo, todos los productos × todos los meses para un informe que muestre también los meses sin ventas) o multiplicar filas a propósito. Escríbelo siempre conCROSS JOINpara que quien lea el código sepa que es intencionado.
3. Combinaciones con la sintaxis tradicional de Oracle
3.1. La condición de combinación en el WHERE
La forma clásica de Oracle (y del estándar SQL-86) consiste en hacer el producto cartesiano en el FROM y quedarse solo con las parejas que están relacionadas mediante una condición en el WHERE:
SELECT empleados.nombre, empleados.apellidos, departamentos.nombre
FROM empleados, departamentos
WHERE empleados.dept_id = departamentos.dept_id
AND departamentos.dept_id IN (40, 50);
NOMBRE APELLIDOS NOMBRE
------ ---------------- ------------
Pedro Gómez Díaz CONTABILIDAD
Laura Fernández Moreno MARKETING
Isabel Domínguez Vega CONTABILIDAD
Jorge Vázquez Ramos CONTABILIDAD
Nuria Gil Soto MARKETING
Álvaro Peña Cano MARKETING
6 filas seleccionadas.
En álgebra relacional, una combinación es una selección sobre un producto cartesiano:
EMPLEADOS ⋈[EMPLEADOS.dept_id = DEPARTAMENTOS.dept_id] DEPARTAMENTOS
≡ σ[EMPLEADOS.dept_id = DEPARTAMENTOS.dept_id](EMPLEADOS × DEPARTAMENTOS)
Cuando la condición es una igualdad se llama equicombinación (equijoin), el tipo más habitual con diferencia.
3.2. Alias de tabla y columnas ambiguas
Escribir el nombre completo de la tabla delante de cada columna es pesado. Se usan alias de tabla cortos (e, d), definidos en el FROM justo detrás del nombre (sin AS, que en Oracle no se admite para alias de tabla):
SELECT e.nombre, e.apellidos, d.nombre AS departamento, d.localidad
FROM empleados e, departamentos d
WHERE e.dept_id = d.dept_id
AND d.localidad = 'Madrid';
NOMBRE APELLIDOS DEPARTAMENTO LOCALIDAD
------ -------------- ------------ ---------
Ana García López DIRECCIÓN Madrid
Luis Martínez Ruiz VENTAS Madrid
Carmen Ruiz Ortega VENTAS Madrid
David Torres Romero VENTAS Madrid
Elena Navarro Castro VENTAS Madrid
Sergio Jiménez Vidal VENTAS Madrid
Lucía Romero Serrano VENTAS Madrid
7 filas seleccionadas.
Si una columna existe en las dos tablas (nombre está en EMPLEADOS y en DEPARTAMENTOS), hay que cualificarla con el alias; si no, Oracle no sabe a cuál te refieres:
SELECT nombre, localidad
FROM empleados e, departamentos d
WHERE e.dept_id = d.dept_id;
SELECT nombre, localidad
*
ERROR en línea 1:
ORA-00918: NOMBRE: column ambiguously specified - appears in DEPARTAMENTOS and EMPLEADOS
Help: https://docs.oracle.com/error-help/db/ora-00918/
localidad no necesita alias porque solo existe en DEPARTAMENTOS, pero cualificar todas las columnas es una buena práctica: el código es más claro y no se rompe si mañana alguien añade una columna localidad a EMPLEADOS.
⚠️ Importante: Cuando defines un alias de tabla, debes usarlo:
empleados.nombreda el errorORA-00904: invalid identifiersi en elFROMescribisteempleados e.
4. Combinaciones con la sintaxis ANSI
Desde Oracle 9i se admite la sintaxis del estándar SQL:1999, que separa las condiciones de combinación (en ON) de los filtros (en WHERE). Es la recomendada hoy.
4.1. INNER JOIN … ON
SELECT e.nombre, e.apellidos, d.nombre AS departamento
FROM empleados e
INNER JOIN departamentos d ON e.dept_id = d.dept_id
WHERE d.dept_id IN (40, 50);
NOMBRE APELLIDOS DEPARTAMENTO
------ ---------------- ------------
Pedro Gómez Díaz CONTABILIDAD
Laura Fernández Moreno MARKETING
Isabel Domínguez Vega CONTABILIDAD
Jorge Vázquez Ramos CONTABILIDAD
Nuria Gil Soto MARKETING
Álvaro Peña Cano MARKETING
6 filas seleccionadas.
La palabra INNER es opcional: JOIN a secas es un INNER JOIN. El resultado es idéntico al de la sintaxis tradicional, pero se lee mejor: en el FROM ves cómo se relacionan las tablas y en el WHERE qué filas te interesan.
📘 Concepto: Un INNER JOIN (combinación interna) devuelve solo las parejas de filas que cumplen la condición. Las filas de una tabla sin pareja en la otra desaparecen del resultado. Por eso el becario Tomás (sin departamento) y el departamento 70 (sin empleados) nunca salen en un
INNER JOINentreEMPLEADOSyDEPARTAMENTOS.
SELECT COUNT(*) AS filas
FROM empleados e
JOIN departamentos d ON e.dept_id = d.dept_id;
FILAS
----------
23
1 fila seleccionada.
23 filas, no 24: Tomás se ha quedado fuera.
4.2. JOIN … USING
Cuando la columna de combinación se llama igual en las dos tablas, USING (columna) es un atajo para ON a.columna = b.columna. La columna aparece una sola vez en el resultado y no se puede cualificar:
SELECT dept_id, e.nombre, d.nombre AS departamento
FROM empleados e
JOIN departamentos d USING (dept_id)
WHERE dept_id = 40;
DEPT_ID NOMBRE DEPARTAMENTO
---------- ------ ------------
40 Pedro CONTABILIDAD
40 Isabel CONTABILIDAD
40 Jorge CONTABILIDAD
3 filas seleccionadas.
SELECT e.dept_id, e.nombre
FROM empleados e
JOIN departamentos d USING (dept_id)
WHERE dept_id = 40;
SELECT e.dept_id, e.nombre
*
ERROR en línea 1:
ORA-25154: column part of USING clause cannot have qualifier
Help: https://docs.oracle.com/error-help/db/ora-25154/
4.3. NATURAL JOIN y sus riesgos
NATURAL JOIN combina automáticamente por todas las columnas que tengan el mismo nombre en ambas tablas, sin que tengas que escribir la condición. Suena cómodo, pero es peligroso. EMPLEADOS y DEPARTAMENTOS comparten dos columnas: dept_id y nombre. El NATURAL JOIN exige que coincidan ambas, es decir, que un empleado se llame igual que su departamento:
SELECT COUNT(*) AS filas
FROM empleados NATURAL JOIN departamentos;
FILAS
----------
0
1 fila seleccionada.
Cero filas, sin ningún error. Y si alguien añade mañana una columna fecha_alta a DEPARTAMENTOS, una consulta con NATURAL JOIN que hoy funciona cambiará de significado sin avisar. ¿Y entre PRODUCTOS y CATEGORIAS? Comparten categoria_id… pero también nombre:
SELECT COUNT(*) AS filas
FROM productos NATURAL JOIN categorias;
FILAS
----------
0
1 fila seleccionada.
Tampoco. En cambio, entre PEDIDOS y LINEAS_PEDIDO la única columna común es pedido_id:
SELECT pedido_id, fecha_pedido, num_linea, producto_id, cantidad
FROM pedidos NATURAL JOIN lineas_pedido
WHERE pedido_id = 5001;
PEDIDO_ID FECHA_PEDI NUM_LINEA PRODUCTO_ID CANTIDAD
---------- ---------- ---------- ----------- ----------
5001 15/01/2025 1 2 1
5001 15/01/2025 2 6 2
5001 15/01/2025 3 9 2
3 filas seleccionadas.
⚠️ Importante: No uses
NATURAL JOINen código real. La condición de combinación queda implícita, depende de los nombres de las columnas y puede cambiar al modificar las tablas. Escribe siempreJOIN ... ON(oUSING, si lo prefieres, que al menos nombra la columna).
4.4. Comparación de sintaxis
| Sintaxis | Ejemplo | Ventajas | Inconvenientes |
|---|---|---|---|
| Tradicional (Oracle, SQL-86) | FROM e, d WHERE e.dept_id = d.dept_id | Muy extendida en código antiguo | Mezcla combinación y filtros; es fácil olvidar una condición |
JOIN ... ON | FROM e JOIN d ON e.dept_id = d.dept_id | Clara, estándar, admite cualquier condición | Algo más larga |
JOIN ... USING | FROM e JOIN d USING (dept_id) | Corta | Exige el mismo nombre; la columna no se puede cualificar |
NATURAL JOIN | FROM e NATURAL JOIN d | Mínima | Condición implícita y frágil |
CROSS JOIN | FROM e CROSS JOIN d | Producto cartesiano explícito | Rara vez se necesita |
5. Combinar tres o más tablas
5.1. La regla de las N − 1 condiciones
Para combinar N tablas necesitas, como mínimo, N − 1 condiciones de combinación (una por cada “enlace” del camino). Si falta alguna, aparece un producto cartesiano parcial. Con la sintaxis ANSI se encadenan los JOIN, cada uno con su ON:
-- Pedidos con el nombre del cliente y del empleado que los gestionó (3 tablas, 2 condiciones)
SELECT p.pedido_id, p.fecha_pedido, c.nombre AS cliente, e.nombre AS empleado
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.cliente_id
JOIN empleados e ON p.empleado_id = e.emp_id
WHERE p.fecha_pedido >= DATE '2026-01-01'
ORDER BY p.pedido_id;
PEDIDO_ID FECHA_PEDI CLIENTE EMPLEADO
---------- ---------- --------------------------- --------
5016 12/01/2026 Instituto Tecnológico Sur Elena
5017 09/02/2026 Panadería La Espiga Sergio
5018 03/03/2026 Construcciones Levante S.A. Carmen
5019 21/04/2026 Clínica Dental Sonrisa Elena
5020 30/05/2026 Hotel Mar Azul S.L. David
5021 15/06/2026 Talleres Martín S.L. Carmen
5022 01/07/2026 Diseños Creativos S.L. Carmen
5024 10/07/2026 Gestoría Delta Sergio
8 filas seleccionadas.
Falta el pedido 5023 (sin empleado): el INNER JOIN con EMPLEADOS lo ha eliminado. Lo arreglaremos en el apartado 6.
La misma consulta con sintaxis tradicional:
SELECT p.pedido_id, p.fecha_pedido, c.nombre AS cliente, e.nombre AS empleado
FROM pedidos p, clientes c, empleados e
WHERE p.cliente_id = c.cliente_id
AND p.empleado_id = e.emp_id
AND p.fecha_pedido >= DATE '2026-01-01'
ORDER BY p.pedido_id;
PEDIDO_ID FECHA_PEDI CLIENTE EMPLEADO
---------- ---------- --------------------------- --------
5016 12/01/2026 Instituto Tecnológico Sur Elena
5017 09/02/2026 Panadería La Espiga Sergio
5018 03/03/2026 Construcciones Levante S.A. Carmen
5019 21/04/2026 Clínica Dental Sonrisa Elena
5020 30/05/2026 Hotel Mar Azul S.L. David
5021 15/06/2026 Talleres Martín S.L. Carmen
5022 01/07/2026 Diseños Creativos S.L. Carmen
5024 10/07/2026 Gestoría Delta Sergio
8 filas seleccionadas.
5.2. Las líneas de un pedido con sus productos y categorías (4 tablas)
SELECT p.pedido_id, l.num_linea, pr.nombre AS producto, ca.nombre AS categoria,
l.cantidad, l.precio_unitario
FROM pedidos p
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
JOIN productos pr ON pr.producto_id = l.producto_id
JOIN categorias ca ON ca.categoria_id = pr.categoria_id
WHERE p.pedido_id IN (5001, 5007)
ORDER BY p.pedido_id, l.num_linea;
PEDIDO_ID NUM_LINEA PRODUCTO CATEGORIA CANTIDAD PRECIO_UNITARIO
---------- ---------- --------------------- ------------------ ---------- ---------------
5001 1 Portátil Gaming 16 Portátiles 1 1499
5001 2 Ratón inalámbrico Ratones y teclados 2 19,9
5001 3 Monitor 24 FHD Monitores 2 139
5007 1 Smartphone Gama Media Telefonía 6 299
5007 2 Auriculares Bluetooth Audio 6 59,9
5007 3 Tablet 10 Electrónica 2 279
6 filas seleccionadas.
5.3. La factura completa (6 tablas)
Juntando todo: pedido, cliente, comercial que lo gestionó, líneas, productos y categorías.
SELECT p.pedido_id,
TO_CHAR(p.fecha_pedido, 'DD/MM/YYYY') AS fecha,
c.nombre AS cliente,
e.nombre || ' ' || e.apellidos AS vendedor,
pr.nombre AS producto,
ca.nombre AS categoria,
l.cantidad,
ROUND(l.cantidad * l.precio_unitario * (1 - l.descuento / 100), 2) AS importe
FROM pedidos p
JOIN clientes c ON c.cliente_id = p.cliente_id
JOIN empleados e ON e.emp_id = p.empleado_id
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
JOIN productos pr ON pr.producto_id = l.producto_id
JOIN categorias ca ON ca.categoria_id = pr.categoria_id
WHERE p.pedido_id = 5004
ORDER BY l.num_linea;
PEDIDO_ID FECHA CLIENTE VENDEDOR PRODUCTO CATEGORIA CANTIDAD IMPORTE
---------- ---------- ------------- ------------------- --------------------- ------------------ ---------- ----------
5004 20/02/2025 Academia Nova David Torres Romero Portátil UltraBook 14 Portátiles 5 4270,25
5004 20/02/2025 Academia Nova David Torres Romero Monitor 27 4K Monitores 5 1562,75
5004 20/02/2025 Academia Nova David Torres Romero Teclado mecánico Ratones y teclados 5 399,5
3 filas seleccionadas.
💡 Consejo: El orden en que escribes los
JOINno cambia el resultado de unINNER JOIN(el optimizador elige el orden de ejecución). Escríbelos siguiendo el camino lógico de las claves ajenas, como si “caminaras” por el diagrama, y pon una condición por cadaJOIN.
5.4. Combinaciones y agrupaciones: informes de ventas
Las combinaciones se llevan muy bien con GROUP BY (módulo 29): ahora sí podemos hacer informes de ventas con nombres.
-- Importe vendido a cada cliente (pedidos no cancelados)
SELECT c.nombre AS cliente,
COUNT(DISTINCT p.pedido_id) AS pedidos,
ROUND(SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)), 2) AS importe
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.cliente_id
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
WHERE p.estado <> 'CANCELADO'
GROUP BY c.cliente_id, c.nombre
ORDER BY importe DESC;
CLIENTE PEDIDOS IMPORTE
--------------------------- ---------- ----------
Instituto Tecnológico Sur 2 20602,55
Construcciones Levante S.A. 3 14260,19
Academia Nova 2 7891,3
Talleres Martín S.L. 3 4704,15
Hotel Mar Azul S.L. 2 4202,77
Diseños Creativos S.L. 2 3054
Clínica Dental Sonrisa 2 2232
Gestoría Delta 1 1497
Antonio Ruiz Pérez 1 999
Panadería La Espiga 1 838
María José Herrera 2 837,9
Laura Benítez Sanz 2 498,6
12 filas seleccionadas.
Se agrupa por c.cliente_id y c.nombre: el identificador garantiza que dos clientes que se llamen igual no se mezclen, y el nombre hace falta en el GROUP BY porque aparece en el SELECT. Fíjate también en COUNT(DISTINCT p.pedido_id): tras combinar con las líneas, cada pedido aparece tantas veces como líneas tiene, así que COUNT(*) contaría líneas, no pedidos.
-- Ventas por categoría de producto
SELECT ca.nombre AS categoria,
SUM(l.cantidad) AS unidades,
ROUND(SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)), 2) AS importe
FROM lineas_pedido l
JOIN productos pr ON pr.producto_id = l.producto_id
JOIN categorias ca ON ca.categoria_id = pr.categoria_id
JOIN pedidos p ON p.pedido_id = l.pedido_id
WHERE p.estado <> 'CANCELADO'
GROUP BY ca.nombre
ORDER BY importe DESC;
CATEGORIA UNIDADES IMPORTE
--------------------- ---------- ----------
Portátiles 42 27337,25
Ordenadores sobremesa 14 11636,29
Monitores 53 9063,45
Telefonía 13 5033,73
Mobiliario 10 2940
Ratones y teclados 52 2202,84
Informática 14 1213,65
Electrónica 3 837
Periféricos 11 783
Audio 10 570,25
10 filas seleccionadas.
-- Ventas de cada comercial por año
SELECT e.nombre || ' ' || e.apellidos AS comercial,
EXTRACT(YEAR FROM p.fecha_pedido) AS anio,
COUNT(DISTINCT p.pedido_id) AS pedidos,
ROUND(SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)), 2) AS importe
FROM empleados e
JOIN pedidos p ON p.empleado_id = e.emp_id
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
WHERE p.estado <> 'CANCELADO'
GROUP BY e.nombre || ' ' || e.apellidos, EXTRACT(YEAR FROM p.fecha_pedido)
ORDER BY comercial, anio;
COMERCIAL ANIO PEDIDOS IMPORTE
-------------------- ---------- ---------- ----------
Carmen Ruiz Ortega 2025 5 15972,04
Carmen Ruiz Ortega 2026 3 6046,3
David Torres Romero 2025 5 11268,33
David Torres Romero 2026 1 1663,64
Elena Navarro Castro 2025 3 13592,5
Elena Navarro Castro 2026 2 10241,05
Sergio Jiménez Vidal 2026 2 2335
7 filas seleccionadas.
6. Combinaciones externas (OUTER JOIN)
6.1. El problema de las filas sin pareja
En EMPRESA hay varias filas “huérfanas” a propósito:
| Caso | Por qué no aparece en un INNER JOIN |
|---|---|
| Departamento 70 (Investigación) | No tiene empleados |
| Empleado 160 (Tomás) | No tiene departamento (dept_id nulo) |
| Clientes 1013 y 1014 | No tienen pedidos |
| Clientes 1009 y 1013 | No tienen comercial (comercial_id nulo) |
| Pedidos 5014 y 5023 | No tienen empleado (empleado_id nulo) |
| Producto 14 | Nunca se ha vendido |
| Proyecto 5 | No tiene empleados asignados |
flowchart LR
subgraph D["DEPARTAMENTOS"]
D70["Solo en DEPARTAMENTOS:<br/>dept. 70 (sin empleados)"]
end
subgraph X["Emparejados (INNER JOIN)"]
DX["6 departamentos ↔ 23 empleados"]
end
subgraph E["EMPLEADOS"]
E160["Solo en EMPLEADOS:<br/>emp. 160 (sin departamento)"]
end
D70 --- DX --- E160
Una combinación externa (outer join) conserva las filas sin pareja de una de las tablas (o de las dos) y rellena con NULL las columnas de la otra.
| Tipo | Solo izquierda | Emparejadas | Solo derecha | Álgebra |
|---|---|---|---|---|
INNER JOIN | No | Sí | No | ⋈ |
LEFT [OUTER] JOIN | Sí | Sí | No | ⟕ |
RIGHT [OUTER] JOIN | No | Sí | Sí | ⟖ |
FULL [OUTER] JOIN | Sí | Sí | Sí | ⟗ |
6.2. LEFT OUTER JOIN
A LEFT JOIN B devuelve todas las filas de A (la tabla de la izquierda), con su pareja de B si la tiene y con NULL en las columnas de B si no la tiene. Todos los departamentos, tengan o no empleados:
SELECT d.dept_id, d.nombre AS departamento, e.nombre AS empleado
FROM departamentos d
LEFT JOIN empleados e ON e.dept_id = d.dept_id
WHERE d.dept_id IN (40, 50, 70)
ORDER BY d.dept_id, e.nombre;
DEPT_ID DEPARTAMENTO EMPLEADO
---------- ------------- --------
40 CONTABILIDAD Isabel
40 CONTABILIDAD Jorge
40 CONTABILIDAD Pedro
50 MARKETING Álvaro
50 MARKETING Laura
50 MARKETING Nuria
70 INVESTIGACIÓN (nulo)
7 filas seleccionadas.
El departamento 70 aparece con empleado nulo. Con INNER JOIN no saldría.
Todos los empleados, tengan o no departamento:
SELECT e.emp_id, e.nombre, d.nombre AS departamento
FROM empleados e
LEFT JOIN departamentos d ON d.dept_id = e.dept_id
WHERE e.emp_id >= 150;
EMP_ID NOMBRE DEPARTAMENTO
---------- -------- ------------
150 Rosa LOGÍSTICA
151 Manuel LOGÍSTICA
152 Cristina LOGÍSTICA
160 Tomás (nulo)
4 filas seleccionadas.
Y el caso del apartado 5.1: todos los pedidos de 2026 con su cliente y, si lo tienen, su empleado:
SELECT p.pedido_id, c.nombre AS cliente, NVL(e.nombre, '(online)') AS empleado
FROM pedidos p
JOIN clientes c ON p.cliente_id = c.cliente_id
LEFT JOIN empleados e ON p.empleado_id = e.emp_id
WHERE p.fecha_pedido >= DATE '2026-01-01'
ORDER BY p.pedido_id;
PEDIDO_ID CLIENTE EMPLEADO
---------- --------------------------- --------
5016 Instituto Tecnológico Sur Elena
5017 Panadería La Espiga Sergio
5018 Construcciones Levante S.A. Carmen
5019 Clínica Dental Sonrisa Elena
5020 Hotel Mar Azul S.L. David
5021 Talleres Martín S.L. Carmen
5022 Diseños Creativos S.L. Carmen
5023 Laura Benítez Sanz (online)
5024 Gestoría Delta Sergio
9 filas seleccionadas.
Ahora sí aparece el pedido 5023. Observa que se mezclan tipos: INNER con CLIENTES (todo pedido tiene cliente) y LEFT con EMPLEADOS (el empleado es opcional). Elegir bien cada JOIN según la cardinalidad mínima de la relación (módulo 06) es la clave.
6.3. RIGHT OUTER JOIN
A RIGHT JOIN B conserva todas las filas de B. Es exactamente lo mismo que B LEFT JOIN A:
SELECT d.nombre AS departamento, e.nombre AS empleado
FROM empleados e
RIGHT JOIN departamentos d ON e.dept_id = d.dept_id
WHERE d.dept_id IN (10, 70);
DEPARTAMENTO EMPLEADO
------------- --------
DIRECCIÓN Ana
INVESTIGACIÓN (nulo)
2 filas seleccionadas.
💡 Consejo: Casi todo el mundo prefiere escribir siempre
LEFT JOINponiendo primero la tabla “principal” (la que quieres conservar entera). MezclarLEFTyRIGHTen la misma consulta la hace muy difícil de leer.
6.4. FULL OUTER JOIN
A FULL JOIN B conserva las filas sin pareja de ambas tablas:
SELECT d.dept_id, d.nombre AS departamento, e.emp_id, e.nombre AS empleado
FROM departamentos d
FULL JOIN empleados e ON e.dept_id = d.dept_id
WHERE d.dept_id IS NULL
OR e.emp_id IS NULL
OR d.dept_id = 10;
DEPT_ID DEPARTAMENTO EMP_ID EMPLEADO
---------- ------------- ---------- --------
10 DIRECCIÓN 100 Ana
(nulo) (nulo) 160 Tomás
70 INVESTIGACIÓN (nulo) (nulo)
3 filas seleccionadas.
Aparecen las tres situaciones: el departamento 10 con su empleada (emparejados), el 70 sin empleados (solo izquierda) y Tomás sin departamento (solo derecha). Contando filas se ve la relación entre los tipos:
SELECT (SELECT COUNT(*) FROM departamentos d JOIN empleados e ON e.dept_id = d.dept_id) AS inner_join,
(SELECT COUNT(*) FROM departamentos d LEFT JOIN empleados e ON e.dept_id = d.dept_id) AS left_join,
(SELECT COUNT(*) FROM departamentos d RIGHT JOIN empleados e ON e.dept_id = d.dept_id) AS right_join,
(SELECT COUNT(*) FROM departamentos d FULL JOIN empleados e ON e.dept_id = d.dept_id) AS full_join
FROM dual;
INNER_JOIN LEFT_JOIN RIGHT_JOIN FULL_JOIN
---------- ---------- ---------- ----------
23 24 24 25
1 fila seleccionada.
LEFT = INNER + 1 (el departamento 70); RIGHT = INNER + 1 (Tomás); FULL = INNER + 1 + 1. (Esta consulta usa subconsultas escalares, que verás en el módulo 31; aquí solo nos interesan los números).
6.5. COUNT con OUTER JOIN: cuidado con COUNT(*)
Al contar después de un OUTER JOIN, COUNT(*) cuenta filas (también la fila “de relleno” de un departamento vacío), mientras que COUNT(columna_de_la_tabla_opcional) cuenta parejas reales:
SELECT d.dept_id, d.nombre,
COUNT(*) AS count_asterisco,
COUNT(e.emp_id) AS empleados
FROM departamentos d
LEFT JOIN empleados e ON e.dept_id = d.dept_id
GROUP BY d.dept_id, d.nombre
ORDER BY d.dept_id;
DEPT_ID NOMBRE COUNT_ASTERISCO EMPLEADOS
---------- ------------- --------------- ----------
10 DIRECCIÓN 1 1
20 VENTAS 6 6
30 INFORMÁTICA 6 6
40 CONTABILIDAD 3 3
50 MARKETING 3 3
60 LOGÍSTICA 4 4
70 INVESTIGACIÓN 1 0
7 filas seleccionadas.
El departamento 70 tiene 0 empleados, pero COUNT(*) dice 1: cuenta la fila con nulos que genera el LEFT JOIN. Este es el informe correcto de “empleados por departamento” que no pudimos hacer en el módulo 29.
7. El operador (+) de Oracle
7.1. Sintaxis
Antes de la sintaxis ANSI, Oracle expresaba las combinaciones externas con el operador (+) en el WHERE. Se coloca junto a las columnas de la tabla opcional, la que puede no tener pareja (la que “se rellena con nulos”):
-- Equivale a: departamentos d LEFT JOIN empleados e ON e.dept_id = d.dept_id
SELECT d.dept_id, d.nombre AS departamento, e.nombre AS empleado
FROM departamentos d, empleados e
WHERE e.dept_id(+) = d.dept_id
AND d.dept_id IN (10, 70);
DEPT_ID DEPARTAMENTO EMPLEADO
---------- ------------- --------
10 DIRECCIÓN Ana
70 INVESTIGACIÓN (nulo)
2 filas seleccionadas.
Una forma de recordarlo: el (+) va en el lado donde “faltan” filas, el que hay que “añadir” (plus) con nulos.
7.2. Limitaciones
-- No existe FULL OUTER JOIN con (+)
SELECT d.nombre, e.nombre
FROM departamentos d, empleados e
WHERE e.dept_id(+) = d.dept_id(+);
WHERE e.dept_id(+) = d.dept_id(+)
*
ERROR en línea 3:
ORA-01468: a predicate may reference only one outer-joined table
Help: https://docs.oracle.com/error-help/db/ora-01468/
Limitación del operador (+) | Con sintaxis ANSI |
|---|---|
No permite FULL OUTER JOIN (ORA-01468) | FULL JOIN |
Hay que poner (+) en todas las condiciones sobre la tabla opcional, o el OUTER se convierte en INNER sin avisar | Las condiciones van en el ON |
La documentación de Oracle prohíbe combinarlo con OR y con IN en la misma condición (ORA-01719 en versiones anteriores) | Sin restricciones |
| Solo se aplica a columnas, no a expresiones arbitrarias | Cualquier condición en el ON |
| Es exclusivo de Oracle | Estándar, portable |
La segunda limitación es la más traicionera. Queremos todos los departamentos con sus empleados que ganen más de 3000 €:
-- Olvidando el (+) en la segunda condición: el 70 desaparece (se ha convertido en INNER)
SELECT d.dept_id, e.nombre, e.salario
FROM departamentos d, empleados e
WHERE e.dept_id(+) = d.dept_id
AND e.salario > 3000
ORDER BY d.dept_id;
DEPT_ID NOMBRE SALARIO
---------- ------ ----------
10 Ana 6500
20 Luis 4800
30 Marta 5000
30 Pablo 3500
40 Pedro 4200
50 Laura 4300
60 Javier 4000
7 filas seleccionadas.
-- Con (+) en todas las condiciones de la tabla opcional
SELECT d.dept_id, e.nombre, e.salario
FROM departamentos d, empleados e
WHERE e.dept_id(+) = d.dept_id
AND e.salario(+) > 3000
ORDER BY d.dept_id;
DEPT_ID NOMBRE SALARIO
---------- ------ ----------
10 Ana 6500
20 Luis 4800
30 Marta 5000
30 Pablo 3500
40 Pedro 4200
50 Laura 4300
60 Javier 4000
70 (nulo) (nulo)
8 filas seleccionadas.
💡 Consejo: Reconoce el operador
(+)porque lo encontrarás en mucho código Oracle antiguo, pero escribe siempre losOUTER JOINcon sintaxis ANSI. La propia Oracle lo recomienda.
8. Condiciones en el ON o en el WHERE de un OUTER JOIN
En un INNER JOIN da igual poner un filtro en el ON o en el WHERE. En un OUTER JOIN no, y es una de las fuentes de errores más frecuentes:
- Una condición en el
ONdecide qué filas de la tabla opcional se emparejan; las filas de la tabla principal se conservan siempre. - Una condición en el
WHEREse aplica después de la combinación y filtra el resultado, eliminando filas de la tabla principal.
Mismo caso que antes, con sintaxis ANSI: todos los departamentos y, de cada uno, solo los empleados que ganan más de 3000 €.
-- Condición en el ON: se conservan TODOS los departamentos
SELECT d.dept_id, d.nombre AS departamento, e.nombre AS empleado, e.salario
FROM departamentos d
LEFT JOIN empleados e ON e.dept_id = d.dept_id
AND e.salario > 3000
ORDER BY d.dept_id;
DEPT_ID DEPARTAMENTO EMPLEADO SALARIO
---------- ------------- -------- ----------
10 DIRECCIÓN Ana 6500
20 VENTAS Luis 4800
30 INFORMÁTICA Marta 5000
30 INFORMÁTICA Pablo 3500
40 CONTABILIDAD Pedro 4200
50 MARKETING Laura 4300
60 LOGÍSTICA Javier 4000
70 INVESTIGACIÓN (nulo) (nulo)
8 filas seleccionadas.
-- Condición en el WHERE: los departamentos sin empleados de más de 3000 € desaparecen
SELECT d.dept_id, d.nombre AS departamento, e.nombre AS empleado, e.salario
FROM departamentos d
LEFT JOIN empleados e ON e.dept_id = d.dept_id
WHERE e.salario > 3000
ORDER BY d.dept_id;
DEPT_ID DEPARTAMENTO EMPLEADO SALARIO
---------- ------------ -------- ----------
10 DIRECCIÓN Ana 6500
20 VENTAS Luis 4800
30 INFORMÁTICA Marta 5000
30 INFORMÁTICA Pablo 3500
40 CONTABILIDAD Pedro 4200
50 MARKETING Laura 4300
60 LOGÍSTICA Javier 4000
7 filas seleccionadas.
En la segunda consulta el departamento 70 tiene e.salario nulo tras el LEFT JOIN; NULL > 3000 es UNKNOWN y el WHERE lo elimina. El LEFT JOIN se ha comportado, en la práctica, como un INNER JOIN.
| Dónde va la condición sobre la tabla opcional | Efecto |
|---|---|
En el ON | Filtra las parejas; la tabla principal se conserva entera |
En el WHERE | Filtra el resultado; anula el efecto del OUTER (salvo si la condición es IS NULL) |
Las condiciones sobre la tabla principal, en cambio, van en el WHERE (si las pones en el ON de un LEFT JOIN, no filtran: solo impiden emparejar):
-- ¡Cuidado! d.localidad = 'Madrid' en el ON no elimina los demás departamentos
SELECT d.dept_id, d.localidad, e.nombre AS empleado
FROM departamentos d
LEFT JOIN empleados e ON e.dept_id = d.dept_id
AND d.localidad = 'Madrid'
WHERE d.dept_id IN (10, 30, 70)
ORDER BY d.dept_id;
DEPT_ID LOCALIDAD EMPLEADO
---------- --------- --------
10 Madrid Ana
30 Valencia (nulo)
70 Bilbao (nulo)
3 filas seleccionadas.
Los departamentos 30 y 70 siguen saliendo (son de la tabla principal de un LEFT JOIN), pero sin empleados: la condición del ON solo ha impedido emparejarlos.
9. Autocombinación (SELF JOIN)
9.1. Empleados y jefes
Una autocombinación combina una tabla consigo misma. Es la forma de recorrer una relación reflexiva (autorrelación), como jefe_id → EMPLEADOS(emp_id). La tabla aparece dos veces en el FROM con alias distintos, que actúan como dos “copias” con papeles diferentes:
SELECT e.emp_id, e.nombre AS empleado, j.emp_id AS jefe_id, j.nombre AS jefe
FROM empleados e
JOIN empleados j ON e.jefe_id = j.emp_id
WHERE e.dept_id IN (30, 40)
ORDER BY e.emp_id;
EMP_ID EMPLEADO JEFE_ID JEFE
---------- -------- ---------- -----
102 Marta 100 Ana
103 Pedro 100 Ana
120 Pablo 102 Marta
121 Raquel 120 Pablo
122 Iván 120 Pablo
123 Sara 120 Pablo
124 Hugo 102 Marta
130 Isabel 103 Pedro
131 Jorge 103 Pedro
9 filas seleccionadas.
En álgebra se usa el operador de renombrado ρ:
ρ[E](EMPLEADOS) ⋈[E.jefe_id = J.emp_id] ρ[J](EMPLEADOS)
Con INNER JOIN desaparece la directora general, que no tiene jefe. Para conservarla, LEFT JOIN:
SELECT e.nombre AS empleado, e.oficio, NVL(j.nombre, '(sin jefe)') AS jefe
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.emp_id
WHERE e.oficio LIKE 'DIRECTOR%'
ORDER BY e.emp_id;
EMPLEADO OFICIO JEFE
-------- ---------------- ----------
Ana DIRECTOR GENERAL (sin jefe)
Luis DIRECTOR Ana
Marta DIRECTOR Ana
Pedro DIRECTOR Ana
Laura DIRECTOR Ana
Javier DIRECTOR Ana
6 filas seleccionadas.
9.2. Tres niveles: el jefe del jefe
Añadiendo otra copia de la tabla subimos un nivel más en la jerarquía:
SELECT e.nombre AS empleado, j.nombre AS jefe, jj.nombre AS jefe_del_jefe
FROM empleados e
JOIN empleados j ON e.jefe_id = j.emp_id
LEFT JOIN empleados jj ON j.jefe_id = jj.emp_id
WHERE e.dept_id = 30
ORDER BY e.emp_id;
EMPLEADO JEFE JEFE_DEL_JEFE
-------- ----- -------------
Marta Ana (nulo)
Pablo Marta Ana
Raquel Pablo Marta
Iván Pablo Marta
Sara Pablo Marta
Hugo Marta Ana
6 filas seleccionadas.
Para recorrer jerarquías de profundidad arbitraria hay herramientas específicas: CONNECT BY (módulo 33) y las consultas recursivas con WITH (módulo 32).
9.3. Comparar filas de la misma tabla
Las autocombinaciones también sirven para comparar filas entre sí. Empleados que ganan más que su jefe… o, como en EMPRESA eso no ocurre, empleados que ganan más de la mitad que su jefe:
SELECT e.nombre AS empleado, e.salario, j.nombre AS jefe, j.salario AS salario_jefe,
ROUND(100 * e.salario / j.salario, 1) AS pct_del_jefe
FROM empleados e
JOIN empleados j ON e.jefe_id = j.emp_id
WHERE e.salario > j.salario / 2
ORDER BY pct_del_jefe DESC;
EMPLEADO SALARIO JEFE SALARIO_JEFE PCT_DEL_JEFE
-------- ---------- ----- ------------ ------------
Raquel 2800 Pablo 3500 80
Marta 5000 Ana 6500 76,9
Iván 2600 Pablo 3500 74,3
Luis 4800 Ana 6500 73,8
Pablo 3500 Marta 5000 70
Sara 2400 Pablo 3500 68,6
Laura 4300 Ana 6500 66,2
Pedro 4200 Ana 6500 64,6
Álvaro 2700 Laura 4300 62,8
Isabel 2600 Pedro 4200 61,9
Javier 4000 Ana 6500 61,5
Nuria 2300 Laura 4300 53,5
12 filas seleccionadas.
Parejas de empleados con el mismo oficio en departamentos distintos (la condición e1.emp_id < e2.emp_id evita repetir cada pareja dos veces y emparejar a alguien consigo mismo):
SELECT e1.oficio, e1.nombre AS empleado_1, e1.dept_id AS dept_1,
e2.nombre AS empleado_2, e2.dept_id AS dept_2
FROM empleados e1
JOIN empleados e2 ON e1.oficio = e2.oficio
AND e1.emp_id < e2.emp_id
AND e1.dept_id <> e2.dept_id
WHERE e1.oficio <> 'DIRECTOR'
ORDER BY e1.oficio, e1.nombre;
OFICIO EMPLEADO_1 DEPT_1 EMPLEADO_2 DEPT_2
-------------- ---------- ---------- ---------- ----------
ADMINISTRATIVO Jorge 40 Cristina 60
ADMINISTRATIVO Lucía 20 Jorge 40
ADMINISTRATIVO Lucía 20 Cristina 60
ANALISTA Pablo 30 Álvaro 50
4 filas seleccionadas.
📘 Concepto: Fíjate en que esta condición de combinación tiene
=,<y<>. Una condición de combinación puede ser cualquier expresión lógica que relacione columnas de ambas tablas; la igualdad es solo el caso más frecuente.
9.4. Categorías y su categoría padre
La tabla CATEGORIAS tiene otra autorrelación (categoria_padre_id):
SELECT h.categoria_id, h.nombre AS categoria,
NVL(p.nombre, '-- raíz --') AS categoria_padre
FROM categorias h
LEFT JOIN categorias p ON h.categoria_padre_id = p.categoria_id
ORDER BY NVL(h.categoria_padre_id, h.categoria_id), h.categoria_id;
CATEGORIA_ID CATEGORIA CATEGORIA_PADRE
------------ --------------------- ---------------
1 Informática -- raíz --
2 Portátiles Informática
3 Ordenadores sobremesa Informática
4 Periféricos Informática
5 Ratones y teclados Periféricos
6 Monitores Periféricos
7 Electrónica -- raíz --
8 Telefonía Electrónica
9 Audio Electrónica
10 Oficina -- raíz --
11 Mobiliario Oficina
11 filas seleccionadas.
10. Combinaciones por rangos (non-equijoin)
Una non-equijoin es una combinación cuya condición no es una igualdad: BETWEEN, <, >=… El caso típico es asignar a cada fila un tramo de una tabla de rangos. Creamos una tabla de demostración con niveles salariales:
CREATE TABLE niveles_salariales_demo (
nivel VARCHAR2(10) CONSTRAINT pk_niveles_demo PRIMARY KEY,
salario_min NUMBER(8,2) NOT NULL,
salario_max NUMBER(8,2) NOT NULL
);
INSERT INTO niveles_salariales_demo VALUES ('N1', 0, 1799.99);
INSERT INTO niveles_salariales_demo VALUES ('N2', 1800, 2499.99);
INSERT INTO niveles_salariales_demo VALUES ('N3', 2500, 3499.99);
INSERT INTO niveles_salariales_demo VALUES ('N4', 3500, 4999.99);
INSERT INTO niveles_salariales_demo VALUES ('N5', 5000, 99999.99);
COMMIT;
Tabla creada.
1 fila creada.
1 fila creada.
1 fila creada.
1 fila creada.
1 fila creada.
Confirmación terminada.
SELECT e.nombre, e.salario, n.nivel
FROM empleados e
JOIN niveles_salariales_demo n ON e.salario BETWEEN n.salario_min AND n.salario_max
WHERE e.dept_id IN (30, 60)
ORDER BY e.salario DESC;
NOMBRE SALARIO NIVEL
-------- ---------- -----
Marta 5000 N5
Javier 4000 N4
Pablo 3500 N4
Raquel 2800 N3
Iván 2600 N3
Hugo 2500 N3
Sara 2400 N2
Cristina 1750 N1
Rosa 1600 N1
Manuel 1600 N1
10 filas seleccionadas.
Combinada con GROUP BY, cuántos empleados hay en cada nivel (incluidos los niveles vacíos, gracias al LEFT JOIN):
SELECT n.nivel, n.salario_min, n.salario_max, COUNT(e.emp_id) AS empleados
FROM niveles_salariales_demo n
LEFT JOIN empleados e ON e.salario BETWEEN n.salario_min AND n.salario_max
GROUP BY n.nivel, n.salario_min, n.salario_max
ORDER BY n.nivel;
NIVEL SALARIO_MIN SALARIO_MAX EMPLEADOS
----- ----------- ----------- ----------
N1 0 1799,99 6
N2 1800 2499,99 6
N3 2500 3499,99 5
N4 3500 4999,99 5
N5 5000 99999,99 2
5 filas seleccionadas.
DROP TABLE niveles_salariales_demo PURGE;
Tabla borrada.
⚠️ Importante: En una combinación por rangos, los tramos no deben solaparse ni dejar huecos; si un salario cae en dos tramos, la fila del empleado se duplica, y si cae en un hueco, desaparece (con
INNER JOIN). Por eso los máximos acaban en,99.
Otro non-equijoin con los datos de EMPRESA: qué pedidos se hicieron mientras duraba la campaña de Navidad (proyecto 3), es decir, cuya fecha cae entre el inicio y el fin del proyecto:
SELECT pr.nombre AS proyecto, p.pedido_id, p.fecha_pedido
FROM proyectos pr
JOIN pedidos p ON p.fecha_pedido BETWEEN pr.fecha_inicio AND pr.fecha_fin
WHERE pr.proyecto_id = 3
ORDER BY p.fecha_pedido;
PROYECTO PEDIDO_ID FECHA_PEDI
-------------------- ---------- ----------
Campaña Navidad 2025 5013 14/10/2025
Campaña Navidad 2025 5014 03/11/2025
Campaña Navidad 2025 5015 27/11/2025
3 filas seleccionadas.
11. Antijoin y semijoin con OUTER JOIN
11.1. Antijoin: filas SIN pareja
Una pregunta muy frecuente es “¿qué filas de A no tienen ninguna pareja en B?”: clientes sin pedidos, productos nunca vendidos, departamentos sin empleados… Es un antijoin (en álgebra, A ▷ B, o A − π[A](A ⋈ B)). Con combinaciones se resuelve con un LEFT JOIN y quedándose con las filas cuya pareja es nula:
-- Clientes sin pedidos
SELECT c.cliente_id, c.nombre, c.fecha_alta
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.cliente_id
WHERE p.pedido_id IS NULL;
CLIENTE_ID NOMBRE FECHA_ALTA
---------- ----------------- ----------
1013 Carlos Ibáñez Rey 08/01/2025
1014 Farmacia Central 12/06/2025
2 filas seleccionadas.
La condición IS NULL debe ir sobre una columna de la tabla opcional que nunca sea nula cuando hay pareja (lo ideal, su clave primaria), porque así NULL solo puede significar “no hay pareja”.
-- Productos que nunca se han vendido
SELECT pr.producto_id, pr.nombre, pr.descatalogado
FROM productos pr
LEFT JOIN lineas_pedido l ON l.producto_id = pr.producto_id
WHERE l.pedido_id IS NULL;
PRODUCTO_ID NOMBRE D
----------- ------------------- -
14 Altavoz Inteligente S
1 fila seleccionada.
-- Empleados que no participan en ningún proyecto
SELECT e.emp_id, e.nombre, e.oficio
FROM empleados e
LEFT JOIN asignaciones a ON a.emp_id = e.emp_id
WHERE a.emp_id IS NULL
ORDER BY e.emp_id;
EMP_ID NOMBRE OFICIO
---------- ------ ----------------
100 Ana DIRECTOR GENERAL
101 Luis DIRECTOR
102 Marta DIRECTOR
103 Pedro DIRECTOR
111 David VENDEDOR
112 Elena VENDEDOR
113 Sergio VENDEDOR
114 Lucía ADMINISTRATIVO
130 Isabel CONTABLE
131 Jorge ADMINISTRATIVO
151 Manuel OPERARIO
160 Tomás BECARIO
12 filas seleccionadas.
11.2. Semijoin: filas CON alguna pareja
El semijoin (A ⋉ B) devuelve las filas de A que tienen al menos una pareja en B, pero sin repetirlas ni añadir columnas de B. Con un JOIN normal, cada cliente sale tantas veces como pedidos tenga; hay que eliminar repetidos con DISTINCT:
-- Clientes que han hecho algún pedido en 2026 (sin DISTINCT saldrían repetidos)
SELECT DISTINCT c.cliente_id, c.nombre
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.cliente_id
WHERE p.fecha_pedido >= DATE '2026-01-01'
ORDER BY c.cliente_id;
CLIENTE_ID NOMBRE
---------- ---------------------------
1001 Talleres Martín S.L.
1002 Construcciones Levante S.A.
1005 Clínica Dental Sonrisa
1007 Hotel Mar Azul S.L.
1008 Gestoría Delta
1009 Laura Benítez Sanz
1010 Instituto Tecnológico Sur
1011 Panadería La Espiga
1012 Diseños Creativos S.L.
9 filas seleccionadas.
💡 Consejo: Los antijoins y semijoins se expresan de forma más natural (y a menudo más eficiente) con subconsultas:
NOT EXISTS/NOT INyEXISTS/IN(módulo 31). ElLEFT JOIN ... IS NULLes una alternativa muy usada que conviene conocer.
12. Resumen de tipos de combinación y álgebra relacional
| Tipo | Sintaxis ANSI | Sintaxis Oracle tradicional | Álgebra relacional |
|---|---|---|---|
| Producto cartesiano | A CROSS JOIN B | FROM A, B | A × B |
| Equijoin / inner join | A JOIN B ON A.x = B.y | WHERE A.x = B.y | A ⋈[A.x = B.y] B |
| Combinación natural | A NATURAL JOIN B | — | A ⋈ B |
| Externa izquierda | A LEFT JOIN B ON ... | WHERE A.x = B.y(+) | A ⟕ B |
| Externa derecha | A RIGHT JOIN B ON ... | WHERE A.x(+) = B.y | A ⟖ B |
| Externa completa | A FULL JOIN B ON ... | No existe | A ⟗ B |
| Autocombinación | A a1 JOIN A a2 ON ... | FROM A a1, A a2 WHERE ... | ρ[A1](A) ⋈ ρ[A2](A) |
| Non-equijoin | JOIN B ON A.x BETWEEN B.min AND B.max | WHERE A.x BETWEEN B.min AND B.max | A ⋈[θ] B (θ-join) |
| Semijoin | SELECT DISTINCT A.* ... JOIN B | Ídem | A ⋉ B |
| Antijoin | A LEFT JOIN B ON ... WHERE B.pk IS NULL | WHERE A.x = B.y(+) AND B.pk IS NULL | A ▷ B |
13. Errores frecuentes
| Error o síntoma | Causa | Solución |
|---|---|---|
| Muchísimas más filas de las esperadas | Falta una condición de combinación (producto cartesiano) | N tablas → al menos N − 1 condiciones |
ORA-00918: column ambiguously specified | Columna que existe en varias tablas sin cualificar | Usa el alias: e.nombre |
ORA-00904: invalid identifier con empleados.nombre | Se usa el nombre de la tabla después de definir un alias | Usa el alias |
ORA-25154 / ORA-25155 | Columna de USING o de NATURAL JOIN cualificada | Escríbela sin alias |
NATURAL JOIN devuelve 0 filas | Las tablas comparten columnas que no son la clave (p. ej. nombre) | Usa JOIN ... ON |
| Faltan filas (departamento 70, Tomás, clientes sin pedidos) | INNER JOIN donde hacía falta OUTER | LEFT JOIN desde la tabla que quieres conservar |
El LEFT JOIN se comporta como INNER | Filtro sobre la tabla opcional en el WHERE | Muévelo al ON |
ORA-01468: a predicate may reference only one outer-joined table | (+) en los dos lados | FULL JOIN |
| Totales inflados al agrupar | La combinación con las líneas multiplica las filas del pedido | COUNT(DISTINCT ...) o agrupar antes |
COUNT(*) da 1 en grupos vacíos | Cuenta la fila de relleno del OUTER JOIN | COUNT(columna_de_la_tabla_opcional) |
14. Ejercicios
A continuación tienes 36 ejercicios resueltos de dificultad creciente y 15 propuestos. Usa la sintaxis ANSI salvo que el enunciado pida otra cosa.
Ejercicio 1: Empleados con su departamento
Muestra el nombre, los apellidos y el nombre del departamento de los empleados de Ventas e Informática, ordenados por departamento y apellidos.
Ver solución
SELECT e.nombre, e.apellidos, d.nombre AS departamento
FROM empleados e
JOIN departamentos d ON d.dept_id = e.dept_id
WHERE d.nombre IN ('VENTAS', 'INFORMÁTICA')
ORDER BY d.nombre, e.apellidos;
NOMBRE APELLIDOS DEPARTAMENTO
------ -------------- ------------
Iván Castro Méndez INFORMÁTICA
Raquel Molina Prieto INFORMÁTICA
Pablo Moreno Herrera INFORMÁTICA
Sara Ortiz Delgado INFORMÁTICA
Hugo Rubio Marín INFORMÁTICA
Marta Sánchez Gil INFORMÁTICA
Sergio Jiménez Vidal VENTAS
Luis Martínez Ruiz VENTAS
Elena Navarro Castro VENTAS
Lucía Romero Serrano VENTAS
Carmen Ruiz Ortega VENTAS
David Torres Romero VENTAS
12 filas seleccionadas.
Ejercicio 2: Lo mismo con sintaxis tradicional
Repite el ejercicio anterior con la sintaxis tradicional de Oracle y comprueba que obtienes el mismo número de filas.
Ver solución
SELECT e.nombre, e.apellidos, d.nombre AS departamento
FROM empleados e, departamentos d
WHERE d.dept_id = e.dept_id
AND d.nombre IN ('VENTAS', 'INFORMÁTICA')
ORDER BY d.nombre, e.apellidos;
NOMBRE APELLIDOS DEPARTAMENTO
------ -------------- ------------
Iván Castro Méndez INFORMÁTICA
Raquel Molina Prieto INFORMÁTICA
Pablo Moreno Herrera INFORMÁTICA
Sara Ortiz Delgado INFORMÁTICA
...
12 filas seleccionadas.
Ejercicio 3: Productos con su categoría
Muestra el nombre de cada producto de más de 300 €, su precio y el nombre de su categoría.
Ver solución
SELECT pr.nombre AS producto, pr.precio, ca.nombre AS categoria
FROM productos pr
JOIN categorias ca ON ca.categoria_id = pr.categoria_id
WHERE pr.precio > 300
ORDER BY pr.precio DESC;
PRODUCTO PRECIO CATEGORIA
--------------------- ---------- ---------------------
Torre Workstation 1899 Ordenadores sobremesa
Portátil Gaming 16 1499 Portátiles
Smartphone Gama Alta 999 Telefonía
Portátil UltraBook 14 899 Portátiles
Torre Oficina i5 649 Ordenadores sobremesa
Portátil Básico 15 499 Portátiles
Mesa Elevable 399 Mobiliario
Monitor 27 4K 329 Monitores
8 filas seleccionadas.
Ejercicio 4: Clientes y su comercial
Muestra el nombre de cada cliente, su ciudad y el nombre completo de su comercial asignado (solo los que tienen comercial).
Ver solución
SELECT c.nombre AS cliente, c.ciudad, e.nombre || ' ' || e.apellidos AS comercial
FROM clientes c
JOIN empleados e ON e.emp_id = c.comercial_id
ORDER BY comercial, cliente;
CLIENTE CIUDAD COMERCIAL
--------------------------- ---------- --------------------
Construcciones Levante S.A. Alicante Carmen Ruiz Ortega
Diseños Creativos S.L. Barcelona Carmen Ruiz Ortega
Talleres Martín S.L. Valencia Carmen Ruiz Ortega
Academia Nova Madrid David Torres Romero
Hotel Mar Azul S.L. Palma David Torres Romero
María José Herrera Madrid David Torres Romero
Antonio Ruiz Pérez Sevilla Elena Navarro Castro
Clínica Dental Sonrisa Sevilla Elena Navarro Castro
...
12 filas seleccionadas.
Ejercicio 5: Proyectos y departamento responsable
Muestra cada proyecto con el nombre y la localidad del departamento que lo dirige.
Ver solución
SELECT p.nombre AS proyecto, d.nombre AS departamento, d.localidad
FROM proyectos p
JOIN departamentos d USING (dept_id)
ORDER BY p.proyecto_id;
PROYECTO DEPARTAMENTO LOCALIDAD
----------------------- ------------ ---------
Migración a la nube INFORMÁTICA Valencia
Nueva tienda online INFORMÁTICA Valencia
Campaña Navidad 2025 MARKETING Barcelona
Optimización de almacén LOGÍSTICA Zaragoza
Plan estratégico 2027 DIRECCIÓN Madrid
5 filas seleccionadas.
Aquí USING (dept_id) es cómodo porque la columna se llama igual en las dos tablas.
Ejercicio 6: Pedidos de Valencia
Muestra los pedidos de clientes de la provincia de Valencia con la fecha, el estado y el nombre del cliente.
Ver solución
SELECT p.pedido_id, p.fecha_pedido, p.estado, c.nombre AS cliente
FROM pedidos p
JOIN clientes c ON c.cliente_id = p.cliente_id
WHERE c.provincia = 'Valencia'
ORDER BY p.fecha_pedido;
PEDIDO_ID FECHA_PEDI ESTADO CLIENTE
---------- ---------- --------- --------------------
5001 15/01/2025 ENTREGADO Talleres Martín S.L.
5006 22/03/2025 ENTREGADO Talleres Martín S.L.
5021 15/06/2026 ENVIADO Talleres Martín S.L.
3 filas seleccionadas.
El cliente 1013, también de Valencia, no aparece porque no tiene pedidos.
Ejercicio 7: Asignaciones con nombres
Muestra las asignaciones a proyectos con el nombre del empleado, el nombre del proyecto, la función y las horas, del proyecto 2.
Ver solución
SELECT e.nombre || ' ' || e.apellidos AS empleado, p.nombre AS proyecto, a.funcion, a.horas
FROM asignaciones a
JOIN empleados e ON e.emp_id = a.emp_id
JOIN proyectos p ON p.proyecto_id = a.proyecto_id
WHERE a.proyecto_id = 2
ORDER BY a.horas DESC;
EMPLEADO PROYECTO FUNCION HORAS
-------------------- ------------------- ---------------- ----------
Iván Castro Méndez Nueva tienda online Desarrollador 520
Sara Ortiz Delgado Nueva tienda online Desarrollador 410
Pablo Moreno Herrera Nueva tienda online Jefe de proyecto 300
Nuria Gil Soto Nueva tienda online Diseñador 120
4 filas seleccionadas.
Ejercicio 8: Producto cartesiano controlado
Genera todas las combinaciones posibles entre los proyectos abiertos (sin fecha de fin) y los departamentos de Madrid, con CROSS JOIN.
Ver solución
SELECT p.nombre AS proyecto, d.nombre AS departamento
FROM proyectos p
CROSS JOIN departamentos d
WHERE p.fecha_fin IS NULL
AND d.localidad = 'Madrid'
ORDER BY p.nombre, d.nombre;
PROYECTO DEPARTAMENTO
----------------------- ------------
Nueva tienda online DIRECCIÓN
Nueva tienda online VENTAS
Optimización de almacén DIRECCIÓN
Optimización de almacén VENTAS
Plan estratégico 2027 DIRECCIÓN
Plan estratégico 2027 VENTAS
6 filas seleccionadas.
3 proyectos abiertos × 2 departamentos de Madrid = 6 filas.
Ejercicio 9: Líneas del pedido 5010
Muestra las líneas del pedido 5010 con el nombre de cada producto, la cantidad, el precio, el descuento y el importe neto.
Ver solución
SELECT l.num_linea, pr.nombre AS producto, l.cantidad, l.precio_unitario, l.descuento,
l.cantidad * l.precio_unitario * (1 - l.descuento / 100) AS importe
FROM lineas_pedido l
JOIN productos pr ON pr.producto_id = l.producto_id
WHERE l.pedido_id = 5010
ORDER BY l.num_linea;
NUM_LINEA PRODUCTO CANTIDAD PRECIO_UNITARIO DESCUENTO IMPORTE
---------- -------------------- ---------- --------------- ---------- ----------
1 Portátil Básico 15 20 499 15 8483
2 Monitor 24 FHD 20 139 15 2363
3 Pack teclado y ratón 20 34,5 15 586,5
4 Webcam HD 5 45 0 225
4 filas seleccionadas.
Ejercicio 10: ¿Quién vendió qué?
Muestra, para los pedidos de marzo de 2025, el nombre del vendedor, el del cliente y los productos comprados.
Ver solución
SELECT p.pedido_id, e.nombre AS vendedor, c.nombre AS cliente, pr.nombre AS producto
FROM pedidos p
JOIN empleados e ON e.emp_id = p.empleado_id
JOIN clientes c ON c.cliente_id = p.cliente_id
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
JOIN productos pr ON pr.producto_id = l.producto_id
WHERE p.fecha_pedido >= DATE '2025-03-01' AND p.fecha_pedido < DATE '2025-04-01'
ORDER BY p.pedido_id, l.num_linea;
PEDIDO_ID VENDEDOR CLIENTE PRODUCTO
---------- -------- ---------------------- -----------------
5005 Elena Clínica Dental Sonrisa Silla Ergonómica
5005 Elena Clínica Dental Sonrisa Impresora Láser
5006 Carmen Talleres Martín S.L. Disco SSD 1TB
5006 Carmen Talleres Martín S.L. Ratón inalámbrico
4 filas seleccionadas.
Ejercicio 11: Productos comprados por un cliente
Muestra los productos distintos que ha comprado alguna vez Construcciones Levante S.A..
Ver solución
SELECT DISTINCT pr.producto_id, pr.nombre
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.cliente_id
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
JOIN productos pr ON pr.producto_id = l.producto_id
WHERE c.nombre = 'Construcciones Levante S.A.'
ORDER BY pr.producto_id;
PRODUCTO_ID NOMBRE
----------- ---------------------
4 Torre Oficina i5
5 Torre Workstation
8 Pack teclado y ratón
9 Monitor 24 FHD
10 Monitor 27 4K
11 Smartphone Gama Media
12 Smartphone Gama Alta
17 Webcam HD
8 filas seleccionadas.
Ejercicio 12: Departamentos con número de empleados
Muestra todos los departamentos con su número de empleados, incluidos los que no tienen ninguno.
Ver solución
SELECT d.dept_id, d.nombre, COUNT(e.emp_id) AS empleados
FROM departamentos d
LEFT JOIN empleados e ON e.dept_id = d.dept_id
GROUP BY d.dept_id, d.nombre
ORDER BY empleados DESC, d.dept_id;
DEPT_ID NOMBRE EMPLEADOS
---------- ------------- ----------
20 VENTAS 6
30 INFORMÁTICA 6
60 LOGÍSTICA 4
40 CONTABILIDAD 3
50 MARKETING 3
10 DIRECCIÓN 1
70 INVESTIGACIÓN 0
7 filas seleccionadas.
Ejercicio 13: Empleados con su departamento (todos)
Muestra todos los empleados con el nombre de su departamento, mostrando SIN DEPARTAMENTO para los que no tienen.
Ver solución
SELECT e.emp_id, e.nombre, NVL(d.nombre, 'SIN DEPARTAMENTO') AS departamento
FROM empleados e
LEFT JOIN departamentos d ON d.dept_id = e.dept_id
WHERE e.emp_id >= 140
ORDER BY e.emp_id;
EMP_ID NOMBRE DEPARTAMENTO
---------- -------- ----------------
140 Nuria MARKETING
141 Álvaro MARKETING
150 Rosa LOGÍSTICA
151 Manuel LOGÍSTICA
152 Cristina LOGÍSTICA
160 Tomás SIN DEPARTAMENTO
6 filas seleccionadas.
Ejercicio 14: Clientes y su comercial (todos)
Muestra todos los clientes con su comercial; los que no tienen deben mostrar Sin asignar.
Ver solución
SELECT c.cliente_id, c.nombre AS cliente,
NVL2(e.emp_id, e.nombre || ' ' || e.apellidos, 'Sin asignar') AS comercial
FROM clientes c
LEFT JOIN empleados e ON e.emp_id = c.comercial_id
WHERE c.cliente_id BETWEEN 1007 AND 1013
ORDER BY c.cliente_id;
CLIENTE_ID CLIENTE COMERCIAL
---------- ------------------------- --------------------
1007 Hotel Mar Azul S.L. David Torres Romero
1008 Gestoría Delta Sergio Jiménez Vidal
1009 Laura Benítez Sanz Sin asignar
1010 Instituto Tecnológico Sur Elena Navarro Castro
1011 Panadería La Espiga Sergio Jiménez Vidal
1012 Diseños Creativos S.L. Carmen Ruiz Ortega
1013 Carlos Ibáñez Rey Sin asignar
7 filas seleccionadas.
Se usa NVL2 sobre la clave del comercial (e.emp_id) y no NVL(e.nombre || ' ' || e.apellidos, 'Sin asignar'), que es lo que casi todo el mundo escribe primero. ¿Por qué? Recuerda que en Oracle la concatenación trata los nulos como cadenas vacías (módulo 27): para un cliente sin comercial, NULL || ' ' || NULL vale ' ' (un espacio), que no es nulo, así que NVL no lo sustituiría y verías un hueco en lugar de Sin asignar:
SELECT c.cliente_id, '[' || NVL(e.nombre || ' ' || e.apellidos, 'Sin asignar') || ']' AS comercial
FROM clientes c
LEFT JOIN empleados e ON e.emp_id = c.comercial_id
WHERE c.cliente_id IN (1009, 1013);
CLIENTE_ID COMERCIAL
---------- ---------
1009 [ ]
1013 [ ]
2 filas seleccionadas.
Los corchetes dejan ver el espacio.
Ejercicio 15: Clientes sin pedidos
Muestra los clientes que no han hecho ningún pedido, con su fecha de alta y su comercial (si lo tienen).
Ver solución
SELECT c.cliente_id, c.nombre, c.fecha_alta, NVL(e.nombre, '-') AS comercial
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.cliente_id
LEFT JOIN empleados e ON e.emp_id = c.comercial_id
WHERE p.pedido_id IS NULL;
CLIENTE_ID NOMBRE FECHA_ALTA COMERCIAL
---------- ----------------- ---------- ---------
1013 Carlos Ibáñez Rey 08/01/2025 -
1014 Farmacia Central 12/06/2025 Sergio
2 filas seleccionadas.
Ejercicio 16: Productos nunca vendidos
Muestra los productos que no aparecen en ninguna línea de pedido, con el nombre de su categoría.
Ver solución
SELECT pr.producto_id, pr.nombre, ca.nombre AS categoria, pr.stock
FROM productos pr
JOIN categorias ca ON ca.categoria_id = pr.categoria_id
LEFT JOIN lineas_pedido l ON l.producto_id = pr.producto_id
WHERE l.producto_id IS NULL;
PRODUCTO_ID NOMBRE CATEGORIA STOCK
----------- ------------------- --------- ----------
14 Altavoz Inteligente Audio 0
1 fila seleccionada.
Ejercicio 17: Proyectos sin asignaciones
Muestra los proyectos que no tienen ningún empleado asignado y el departamento responsable.
Ver solución
SELECT p.proyecto_id, p.nombre AS proyecto, d.nombre AS departamento
FROM proyectos p
JOIN departamentos d ON d.dept_id = p.dept_id
LEFT JOIN asignaciones a ON a.proyecto_id = p.proyecto_id
WHERE a.proyecto_id IS NULL;
PROYECTO_ID PROYECTO DEPARTAMENTO
----------- --------------------- ------------
5 Plan estratégico 2027 DIRECCIÓN
1 fila seleccionada.
Ejercicio 18: Departamentos y empleados, sin perder a nadie
Muestra todos los departamentos y todos los empleados emparejados cuando sea posible, de manera que aparezcan tanto el departamento sin empleados como el empleado sin departamento. Muestra solo las filas “huérfanas”.
Ver solución
SELECT NVL(d.nombre, '¿?') AS departamento, NVL(e.nombre, '¿?') AS empleado
FROM departamentos d
FULL JOIN empleados e ON e.dept_id = d.dept_id
WHERE d.dept_id IS NULL OR e.emp_id IS NULL;
DEPARTAMENTO EMPLEADO
------------- --------
¿? Tomás
INVESTIGACIÓN ¿?
2 filas seleccionadas.
Ejercicio 19: Pedidos con o sin empleado
Muestra los pedidos de noviembre de 2025 y julio de 2026 con el nombre del cliente y el del empleado, usando el operador (+).
Ver solución
SELECT p.pedido_id, p.fecha_pedido, c.nombre AS cliente, e.nombre AS empleado
FROM pedidos p, clientes c, empleados e
WHERE p.cliente_id = c.cliente_id
AND p.empleado_id = e.emp_id(+)
AND (TO_CHAR(p.fecha_pedido, 'YYYY-MM') IN ('2025-11', '2026-07'))
ORDER BY p.pedido_id;
PEDIDO_ID FECHA_PEDI CLIENTE EMPLEADO
---------- ---------- ---------------------- --------
5014 03/11/2025 Laura Benítez Sanz (nulo)
5015 27/11/2025 María José Herrera David
5022 01/07/2026 Diseños Creativos S.L. Carmen
5023 08/07/2026 Laura Benítez Sanz (nulo)
5024 10/07/2026 Gestoría Delta Sergio
5 filas seleccionadas.
El (+) va junto a e.emp_id porque EMPLEADOS es la tabla opcional. La condición sobre p.fecha_pedido no lleva (+) porque es de la tabla principal.
Ejercicio 20: Empleados y horas en proyectos
Muestra todos los empleados de Informática (30) con el total de horas que dedican a proyectos (0 si no participan en ninguno).
Ver solución
SELECT e.nombre, e.oficio, NVL(SUM(a.horas), 0) AS horas
FROM empleados e
LEFT JOIN asignaciones a ON a.emp_id = e.emp_id
WHERE e.dept_id = 30
GROUP BY e.emp_id, e.nombre, e.oficio
ORDER BY horas DESC;
NOMBRE OFICIO HORAS
------ ---------------- ----------
Pablo ANALISTA 750
Raquel PROGRAMADOR 600
Iván PROGRAMADOR 520
Sara PROGRAMADOR 410
Hugo TÉCNICO SISTEMAS 380
Marta DIRECTOR 0
6 filas seleccionadas.
Marta, la directora de Informática, no participa en ningún proyecto: SUM de nada es NULL y NVL lo convierte en 0.
Ejercicio 21: Pedidos con importe por cliente, incluidos los que no compran
Muestra todos los clientes con su número de pedidos y el importe total comprado (0 para los que no tienen pedidos), sin contar los cancelados.
Ver solución
SELECT c.nombre,
COUNT(DISTINCT p.pedido_id) AS pedidos,
NVL(ROUND(SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)), 2), 0) AS importe
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.cliente_id
AND p.estado <> 'CANCELADO'
LEFT JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
GROUP BY c.cliente_id, c.nombre
ORDER BY importe DESC;
NOMBRE PEDIDOS IMPORTE
--------------------------- ---------- ----------
Instituto Tecnológico Sur 2 20602,55
Construcciones Levante S.A. 3 14260,19
Academia Nova 2 7891,3
Talleres Martín S.L. 3 4704,15
Hotel Mar Azul S.L. 2 4202,77
Diseños Creativos S.L. 2 3054
Clínica Dental Sonrisa 2 2232
Gestoría Delta 1 1497
Antonio Ruiz Pérez 1 999
Panadería La Espiga 1 838
María José Herrera 2 837,9
Laura Benítez Sanz 2 498,6
Carlos Ibáñez Rey 0 0
Farmacia Central 0 0
14 filas seleccionadas.
La condición p.estado <> 'CANCELADO' va en el ON: si estuviera en el WHERE, los clientes sin pedidos (con p.estado nulo) desaparecerían. Así, Gestoría Delta conserva su pedido pendiente y descarta el cancelado.
Ejercicio 22: Condición en ON frente a WHERE
Muestra todos los departamentos con sus proyectos que empezaron en 2026. Hazlo primero con la condición de fecha en el ON y después en el WHERE, y explica la diferencia.
Ver solución
SELECT d.nombre AS departamento, p.nombre AS proyecto, p.fecha_inicio
FROM departamentos d
LEFT JOIN proyectos p ON p.dept_id = d.dept_id
AND p.fecha_inicio >= DATE '2026-01-01'
ORDER BY d.dept_id;
SELECT d.nombre AS departamento, p.nombre AS proyecto, p.fecha_inicio
FROM departamentos d
LEFT JOIN proyectos p ON p.dept_id = d.dept_id
WHERE p.fecha_inicio >= DATE '2026-01-01'
ORDER BY d.dept_id;
DEPARTAMENTO PROYECTO FECHA_INIC
------------- ----------------------- ----------
DIRECCIÓN Plan estratégico 2027 01/09/2026
VENTAS (nulo) (nulo)
INFORMÁTICA (nulo) (nulo)
CONTABILIDAD (nulo) (nulo)
MARKETING (nulo) (nulo)
LOGÍSTICA Optimización de almacén 01/02/2026
INVESTIGACIÓN (nulo) (nulo)
7 filas seleccionadas.
DEPARTAMENTO PROYECTO FECHA_INIC
------------ ----------------------- ----------
DIRECCIÓN Plan estratégico 2027 01/09/2026
LOGÍSTICA Optimización de almacén 01/02/2026
2 filas seleccionadas.
Con la condición en el ON salen los 7 departamentos (con proyecto solo los que tienen uno de 2026); con la condición en el WHERE solo quedan los 2 departamentos que tienen proyectos de 2026, porque para los demás p.fecha_inicio es nulo.
Ejercicio 23: Empleados y su jefe
Muestra cada empleado de Ventas (20) con el nombre completo de su jefe.
Ver solución
SELECT e.nombre || ' ' || e.apellidos AS empleado,
j.nombre || ' ' || j.apellidos AS jefe
FROM empleados e
JOIN empleados j ON j.emp_id = e.jefe_id
WHERE e.dept_id = 20
ORDER BY e.emp_id;
EMPLEADO JEFE
-------------------- ------------------
Luis Martínez Ruiz Ana García López
Carmen Ruiz Ortega Luis Martínez Ruiz
David Torres Romero Luis Martínez Ruiz
Elena Navarro Castro Luis Martínez Ruiz
Sergio Jiménez Vidal Luis Martínez Ruiz
Lucía Romero Serrano Luis Martínez Ruiz
6 filas seleccionadas.
Ejercicio 24: Número de subordinados
Muestra los empleados que son jefes de alguien con el número de subordinados directos que tienen.
Ver solución
SELECT j.emp_id, j.nombre, j.oficio, COUNT(*) AS subordinados
FROM empleados e
JOIN empleados j ON j.emp_id = e.jefe_id
GROUP BY j.emp_id, j.nombre, j.oficio
ORDER BY subordinados DESC, j.emp_id;
EMP_ID NOMBRE OFICIO SUBORDINADOS
---------- ------ ---------------- ------------
100 Ana DIRECTOR GENERAL 5
101 Luis DIRECTOR 5
102 Marta DIRECTOR 3
105 Javier DIRECTOR 3
120 Pablo ANALISTA 3
103 Pedro DIRECTOR 2
104 Laura DIRECTOR 2
7 filas seleccionadas.
Ejercicio 25: Jefe de otro departamento
Muestra los empleados cuyo jefe trabaja en un departamento distinto del suyo (o que no tienen departamento), con el departamento de ambos.
Ver solución
SELECT e.nombre AS empleado, e.dept_id AS dept_empleado,
j.nombre AS jefe, j.dept_id AS dept_jefe
FROM empleados e
JOIN empleados j ON j.emp_id = e.jefe_id
WHERE e.dept_id <> j.dept_id
OR e.dept_id IS NULL
ORDER BY e.emp_id;
EMPLEADO DEPT_EMPLEADO JEFE DEPT_JEFE
-------- ------------- ----- ----------
Luis 20 Ana 10
Marta 30 Ana 10
Pedro 40 Ana 10
Laura 50 Ana 10
Javier 60 Ana 10
Tomás (nulo) Marta 30
6 filas seleccionadas.
Los directores de departamento dependen de la directora general (departamento 10), y Tomás, sin departamento, depende de Marta.
Ejercicio 26: Comercial y jefe del comercial
Muestra los clientes de Madrid con el nombre de su comercial y el nombre del jefe de ese comercial.
Ver solución
SELECT c.nombre AS cliente, e.nombre AS comercial, j.nombre AS jefe_comercial
FROM clientes c
JOIN empleados e ON e.emp_id = c.comercial_id
JOIN empleados j ON j.emp_id = e.jefe_id
WHERE c.provincia = 'Madrid';
CLIENTE COMERCIAL JEFE_COMERCIAL
------------------ --------- --------------
María José Herrera David Luis
Academia Nova David Luis
2 filas seleccionadas.
EMPLEADOS aparece dos veces con papeles distintos: como comercial (e) y como jefe (j).
Ejercicio 27: Árbol de categorías
Muestra las categorías de tercer nivel (las que tienen padre y abuelo) con la ruta completa abuelo > padre > categoría.
Ver solución
SELECT a.nombre || ' > ' || p.nombre || ' > ' || h.nombre AS ruta
FROM categorias h
JOIN categorias p ON p.categoria_id = h.categoria_padre_id
JOIN categorias a ON a.categoria_id = p.categoria_padre_id
ORDER BY ruta;
RUTA
----------------------------------------------
Informática > Periféricos > Monitores
Informática > Periféricos > Ratones y teclados
2 filas seleccionadas.
Ejercicio 28: Productos con categoría y categoría padre
Muestra todos los productos de Electrónica (categoría 7 y sus subcategorías) con su categoría y la categoría padre.
Ver solución
SELECT pr.nombre AS producto, c.nombre AS categoria, NVL(cp.nombre, '-') AS categoria_padre
FROM productos pr
JOIN categorias c ON c.categoria_id = pr.categoria_id
LEFT JOIN categorias cp ON cp.categoria_id = c.categoria_padre_id
WHERE c.categoria_id = 7 OR c.categoria_padre_id = 7
ORDER BY c.nombre, pr.nombre;
PRODUCTO CATEGORIA CATEGORIA_PADRE
--------------------- ----------- ---------------
Altavoz Inteligente Audio Electrónica
Auriculares Bluetooth Audio Electrónica
Tablet 10 Electrónica -
Smartphone Gama Alta Telefonía Electrónica
Smartphone Gama Media Telefonía Electrónica
5 filas seleccionadas.
La Tablet está directamente en la categoría raíz Electrónica, así que no tiene padre (LEFT JOIN).
Ejercicio 29: Pedidos que no gestionó el comercial del cliente
Muestra los pedidos en los que el empleado que los gestionó no es el comercial asignado al cliente.
Ver solución
SELECT p.pedido_id, c.nombre AS cliente, c.comercial_id, p.empleado_id
FROM pedidos p
JOIN clientes c ON c.cliente_id = p.cliente_id
WHERE p.empleado_id <> c.comercial_id;
ninguna fila seleccionada
Ningún pedido cumple la condición con datos de ambos lados: todos los pedidos con empleado los gestionó el comercial del cliente. Los pedidos 5014 y 5023 (online, empleado_id nulo) del cliente 1009 (sin comercial) no se pueden comparar: NULL <> NULL es UNKNOWN.
Ejercicio 30: Pedidos durante un proyecto
Muestra los pedidos de clientes de la provincia de Valencia realizados mientras duraba el proyecto «Migración a la nube», es decir, cuya fecha está entre la fecha de inicio y la de fin del proyecto.
Ver solución
SELECT pr.nombre AS proyecto, p.pedido_id, p.fecha_pedido, c.nombre AS cliente
FROM proyectos pr
JOIN pedidos p ON p.fecha_pedido BETWEEN pr.fecha_inicio AND pr.fecha_fin
JOIN clientes c ON c.cliente_id = p.cliente_id
WHERE pr.nombre = 'Migración a la nube'
AND c.provincia = 'Valencia'
ORDER BY p.fecha_pedido;
PROYECTO PEDIDO_ID FECHA_PEDI CLIENTE
------------------- ---------- ---------- --------------------
Migración a la nube 5001 15/01/2025 Talleres Martín S.L.
Migración a la nube 5006 22/03/2025 Talleres Martín S.L.
2 filas seleccionadas.
La primera combinación no es una igualdad: cada pedido se empareja con el proyecto si su fecha cae entre las fechas de inicio y fin del proyecto.
Ejercicio 31: Ventas por categoría padre
Muestra el importe vendido (pedidos no cancelados) agrupado por la categoría padre del producto (o por la propia categoría si no tiene padre).
Ver solución
SELECT NVL(cp.nombre, c.nombre) AS familia,
ROUND(SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)), 2) AS importe
FROM lineas_pedido l
JOIN pedidos p ON p.pedido_id = l.pedido_id
JOIN productos pr ON pr.producto_id = l.producto_id
JOIN categorias c ON c.categoria_id = pr.categoria_id
LEFT JOIN categorias cp ON cp.categoria_id = c.categoria_padre_id
WHERE p.estado <> 'CANCELADO'
GROUP BY NVL(cp.nombre, c.nombre)
ORDER BY importe DESC;
FAMILIA IMPORTE
----------- ----------
Informática 40970,19
Periféricos 11266,29
Electrónica 6440,98
Oficina 2940
4 filas seleccionadas.
Ejercicio 32: Ventas por departamento del vendedor
Muestra el importe vendido por cada departamento (el del empleado que gestionó el pedido) y el número de pedidos, incluyendo una fila para los pedidos online.
Ver solución
SELECT NVL(d.nombre, 'ONLINE') AS departamento,
COUNT(DISTINCT p.pedido_id) AS pedidos,
ROUND(SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)), 2) AS importe
FROM pedidos p
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
LEFT JOIN empleados e ON e.emp_id = p.empleado_id
LEFT JOIN departamentos d ON d.dept_id = e.dept_id
WHERE p.estado <> 'CANCELADO'
GROUP BY NVL(d.nombre, 'ONLINE')
ORDER BY importe DESC;
DEPARTAMENTO PEDIDOS IMPORTE
------------ ---------- ----------
VENTAS 21 61118,86
ONLINE 2 498,6
2 filas seleccionadas.
El segundo LEFT JOIN también tiene que ser externo: si fuera INNER, eliminaría las filas de los pedidos online que el primer LEFT JOIN había conservado.
Ejercicio 33: Matriz de proyectos y empleados de Informática
Muestra, para cada empleado de Informática y cada proyecto del departamento 30, si está asignado (SÍ) o no (no). Pista: CROSS JOIN de empleados y proyectos y LEFT JOIN con asignaciones.
Ver solución
SELECT e.nombre AS empleado, pr.nombre AS proyecto,
CASE WHEN a.emp_id IS NOT NULL THEN 'SÍ' ELSE 'no' END AS asignado
FROM empleados e
CROSS JOIN proyectos pr
LEFT JOIN asignaciones a ON a.emp_id = e.emp_id
AND a.proyecto_id = pr.proyecto_id
WHERE e.dept_id = 30
AND pr.dept_id = 30
ORDER BY e.emp_id, pr.proyecto_id;
EMPLEADO PROYECTO ASI
-------- ------------------- ---
Marta Migración a la nube no
Marta Nueva tienda online no
Pablo Migración a la nube SÍ
Pablo Nueva tienda online SÍ
Raquel Migración a la nube SÍ
Raquel Nueva tienda online no
Iván Migración a la nube no
Iván Nueva tienda online SÍ
Sara Migración a la nube no
Sara Nueva tienda online SÍ
Hugo Migración a la nube SÍ
Hugo Nueva tienda online no
12 filas seleccionadas.
Un uso legítimo del CROSS JOIN: generar todas las combinaciones posibles y después comprobar cuáles existen.
Ejercicio 34: Clientes que compraron monitores
Muestra los clientes (sin repetir) que han comprado algún producto de la categoría Monitores.
Ver solución
SELECT DISTINCT c.cliente_id, c.nombre
FROM clientes c
JOIN pedidos p ON p.cliente_id = c.cliente_id
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
JOIN productos pr ON pr.producto_id = l.producto_id
JOIN categorias ca ON ca.categoria_id = pr.categoria_id
WHERE ca.nombre = 'Monitores'
ORDER BY c.cliente_id;
CLIENTE_ID NOMBRE
---------- ---------------------------
1001 Talleres Martín S.L.
1002 Construcciones Levante S.A.
1004 Academia Nova
1007 Hotel Mar Azul S.L.
1010 Instituto Tecnológico Sur
1012 Diseños Creativos S.L.
6 filas seleccionadas.
Es un semijoin: el DISTINCT evita que un cliente salga una vez por cada línea de monitor comprada.
Ejercicio 35: El mejor cliente de cada comercial
Muestra, para cada comercial, el importe total vendido a cada uno de sus clientes asignados, solo de los clientes con más de 2000 € de compras.
Ver solución
SELECT e.nombre AS comercial, c.nombre AS cliente,
ROUND(SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)), 2) AS importe
FROM empleados e
JOIN clientes c ON c.comercial_id = e.emp_id
JOIN pedidos p ON p.cliente_id = c.cliente_id
JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
WHERE p.estado <> 'CANCELADO'
GROUP BY e.nombre, c.cliente_id, c.nombre
HAVING SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)) > 2000
ORDER BY comercial, importe DESC;
COMERCIAL CLIENTE IMPORTE
--------- --------------------------- ----------
Carmen Construcciones Levante S.A. 14260,19
Carmen Talleres Martín S.L. 4704,15
Carmen Diseños Creativos S.L. 3054
David Academia Nova 7891,3
David Hotel Mar Azul S.L. 4202,77
Elena Instituto Tecnológico Sur 20602,55
Elena Clínica Dental Sonrisa 2232
7 filas seleccionadas.
Ejercicio 36: Informe de actividad de cada departamento
Muestra, para todos los departamentos, el número de empleados, el número de proyectos que dirige y la suma de sus presupuestos. Cuidado con la multiplicación de filas.
Ver solución
SELECT d.dept_id, d.nombre,
COUNT(DISTINCT e.emp_id) AS empleados,
COUNT(DISTINCT pr.proyecto_id) AS proyectos,
SUM(DISTINCT pr.presupuesto) AS presupuesto_proyectos_mal
FROM departamentos d
LEFT JOIN empleados e ON e.dept_id = d.dept_id
LEFT JOIN proyectos pr ON pr.dept_id = d.dept_id
GROUP BY d.dept_id, d.nombre
ORDER BY d.dept_id;
DEPT_ID NOMBRE EMPLEADOS PROYECTOS PRESUPUESTO_PROYECTOS_MAL
---------- ------------- ---------- ---------- -------------------------
10 DIRECCIÓN 1 1 (nulo)
20 VENTAS 6 0 (nulo)
30 INFORMÁTICA 6 2 210000
40 CONTABILIDAD 3 0 (nulo)
50 MARKETING 3 1 40000
60 LOGÍSTICA 4 1 35000
70 INVESTIGACIÓN 0 0 (nulo)
7 filas seleccionadas.
Al combinar un departamento con sus empleados y con sus proyectos se forma un producto entre ambas listas: Informática tiene 6 empleados y 2 proyectos, así que genera 12 filas. Los COUNT(DISTINCT ...) corrigen los recuentos, pero SUM(DISTINCT presupuesto) es incorrecto en general (si dos proyectos tuvieran el mismo presupuesto, se sumaría una sola vez). La solución correcta es agregar cada tabla por separado antes de combinar, con subconsultas en el FROM (módulo 31) o con WITH (módulo 32).
Ejercicios propuestos
Entre paréntesis tienes el número de filas que debe devolver cada consulta.
- Muestra el nombre de cada empleado de Contabilidad junto con la localidad de su departamento (3 filas).
- Muestra los productos de la categoría
Periféricosy de sus subcategorías con el nombre de la categoría (7 filas). - Muestra los pedidos gestionados por empleados del departamento de Ventas cuyo cliente sea de Madrid (4 filas).
- Muestra el nombre de los clientes que han comprado el producto
Portátil Gaming 16(3 filas). - Muestra todos los productos con el número de unidades vendidas, 0 si no se han vendido nunca (20 filas).
- Muestra los empleados del departamento de Ventas que no han gestionado ningún pedido (2 filas).
- Muestra los departamentos que no dirigen ningún proyecto (3 filas).
- Muestra los empleados con su jefe y el departamento del jefe, usando la sintaxis tradicional con
(+)para no perder a la directora general (24 filas). - Muestra los pares de clientes de la misma provincia (cada par una sola vez) (3 filas).
- Muestra las categorías raíz (sin padre) con el número de subcategorías directas que tienen (3 filas).
- Muestra el importe vendido de cada producto de la categoría
Portátiles, con el nombre del producto (3 filas). - Muestra para cada proyecto el número de empleados asignados y el nombre del departamento responsable, incluyendo el proyecto sin asignaciones con 0 empleados (5 filas).
- Muestra los pedidos cuyo importe neto supera la mitad del límite de crédito de su cliente (3 filas).
- Muestra todos los departamentos con la masa salarial de sus empleados, mostrando 0 en el departamento sin empleados (7 filas).
- Muestra los empleados asignados a algún proyecto de un departamento distinto del suyo (2 filas).
Resumen
| Concepto | Sintaxis | Recuerda |
|---|---|---|
| Producto cartesiano | FROM a, b / a CROSS JOIN b | Casi siempre es un error |
| Equijoin tradicional | FROM a, b WHERE a.x = b.y | N tablas → N − 1 condiciones |
| Inner join ANSI | a JOIN b ON a.x = b.y | Elimina las filas sin pareja |
USING | a JOIN b USING (col) | La columna no se cualifica |
NATURAL JOIN | a NATURAL JOIN b | Evítalo: condición implícita |
| Alias de tabla | FROM empleados e | Sin AS; cualifica todas las columnas |
| Externa izquierda | a LEFT JOIN b ON ... | Conserva todas las filas de a |
| Externa derecha | a RIGHT JOIN b ON ... | Conserva todas las filas de b |
| Externa completa | a FULL JOIN b ON ... | Conserva las de ambas |
Operador (+) | WHERE a.x = b.y(+) | Va en la tabla opcional; no hay FULL |
ON frente a WHERE | Filtro de la tabla opcional en el ON | En el WHERE anula el OUTER |
| Self join | empleados e JOIN empleados j ON e.jefe_id = j.emp_id | Alias distintos |
| Non-equijoin | JOIN b ON a.x BETWEEN b.min AND b.max | Tramos sin solapes ni huecos |
| Antijoin | LEFT JOIN b ... WHERE b.pk IS NULL | IS NULL sobre la clave de b |
| Semijoin | SELECT DISTINCT a.* ... JOIN b | O EXISTS (módulo 31) |
Contar tras OUTER | COUNT(b.pk) | COUNT(*) cuenta la fila de relleno |