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, RIGHT y FULL OUTER JOIN y el operador (+) de Oracle con sus limitaciones
  • Comprender la diferencia entre poner una condición en el ON o en el WHERE de un OUTER 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 JOIN tiene 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 con CROSS JOIN para 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.nombre da el error ORA-00904: invalid identifier si en el FROM escribiste empleados 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 JOIN entre EMPLEADOS y DEPARTAMENTOS.

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 JOIN en 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 siempre JOIN ... ON (o USING, 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 JOIN no cambia el resultado de un INNER 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 cada JOIN.

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 JOIN poniendo primero la tabla “principal” (la que quieres conservar entera). Mezclar LEFT y RIGHT en 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 los OUTER JOIN con 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 ON decide qué filas de la tabla opcional se emparejan; las filas de la tabla principal se conservan siempre.
  • Una condición en el WHERE se 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 IN y EXISTS / IN (módulo 31). El LEFT JOIN ... IS NULL es 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.

  1. Muestra el nombre de cada empleado de Contabilidad junto con la localidad de su departamento (3 filas).
  2. Muestra los productos de la categoría Periféricos y de sus subcategorías con el nombre de la categoría (7 filas).
  3. Muestra los pedidos gestionados por empleados del departamento de Ventas cuyo cliente sea de Madrid (4 filas).
  4. Muestra el nombre de los clientes que han comprado el producto Portátil Gaming 16 (3 filas).
  5. Muestra todos los productos con el número de unidades vendidas, 0 si no se han vendido nunca (20 filas).
  6. Muestra los empleados del departamento de Ventas que no han gestionado ningún pedido (2 filas).
  7. Muestra los departamentos que no dirigen ningún proyecto (3 filas).
  8. 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).
  9. Muestra los pares de clientes de la misma provincia (cada par una sola vez) (3 filas).
  10. Muestra las categorías raíz (sin padre) con el número de subcategorías directas que tienen (3 filas).
  11. Muestra el importe vendido de cada producto de la categoría Portátiles, con el nombre del producto (3 filas).
  12. 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).
  13. Muestra los pedidos cuyo importe neto supera la mitad del límite de crédito de su cliente (3 filas).
  14. Muestra todos los departamentos con la masa salarial de sus empleados, mostrando 0 en el departamento sin empleados (7 filas).
  15. 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