En la lección anterior aprendimos a interrogar una tabla. Pero las preguntas que de verdad le interesan a BiblioRed no caben en una tabla sola: "¿quién tiene ahora mismo el ejemplar EJ-3081?", "¿qué socios no han cogido nunca nada prestado?", "¿de qué autor es el libro que más se retrasa?". Las respuestas están repartidas entre socios, prestamos, ejemplares, libros y autores.

Ese reparto no es un defecto: es exactamente lo que decidimos en la lección 02-01 al separar la obra (libros) del objeto físico (ejemplares), y al no repetir el nombre del socio en cada préstamo. La información se guarda una sola vez, en el sitio que le corresponde, y se recompone cuando hace falta. La herramienta que la recompone es el JOIN, la reunión ⋈ del álgebra relacional.

Esta es la lección donde SQL deja de parecer un buscador de tablas y empieza a parecer un lenguaje de consulta de verdad. Es larga y densa; tómatela con calma y ejecuta cada ejemplo sobre tu biblioredb.

Contenido

  1. Por qué hay que recomponer los datos
  2. Producto cartesiano y CROSS JOIN
  3. INNER JOIN: la reunión básica
  4. LEFT JOIN y las preguntas negativas
  5. RIGHT JOIN y FULL OUTER JOIN
  6. Resumen visual de los tipos de JOIN
  7. SELF JOIN: unir una tabla consigo misma
  8. Encadenar tres o más tablas
  9. Filtrar en ON o filtrar en WHERE
  10. Subconsultas escalares
  11. Subconsultas con IN, EXISTS y NOT EXISTS
  12. Subconsultas correlacionadas
  13. Tablas derivadas: subconsultas en FROM
  14. Expresiones de tabla común: WITH
  15. Operadores de conjunto: UNION, INTERSECT, EXCEPT
  16. Errores comunes y consejos
  17. Ejercicios
  18. Conclusión

  1. Por qué hay que recomponer los datos

Mira la tabla prestamos en crudo:

SELECT prestamo_id, socio_id, ejemplar_id, fecha_prestamo FROM prestamos LIMIT 3;
prestamo_id socio_id ejemplar_id fecha_prestamo
1 14 2 2026-03-02
2 15 1 2026-03-05
3 16 5 2026-03-11

Para un ser humano esto no dice nada: 14, 2, 15, 1… son referencias. El nombre del socio está en socios, el código del ejemplar en ejemplares y el título en libros. La alternativa —guardar el nombre y el título dentro de cada préstamo— es justamente lo que hacía la hoja de cálculo, y ya vimos el resultado: redundancia, inconsistencia y errores de tecleo.

El trato del modelo relacional es este: se guarda sin repetir, y se paga un JOIN al consultar. Es un trato excelente, porque escribir bien ocurre una vez y leer mal ocurre para siempre.

  1. Producto cartesiano y CROSS JOIN

Antes de emparejar filas correctamente, hay que entender qué pasa si no las emparejas. El producto cartesiano × combina cada fila de una tabla con cada fila de la otra.

SELECT s.nombre AS sucursal, a.apellidos AS autor
FROM sucursales s
CROSS JOIN autores a
ORDER BY s.sucursal_id, a.autor_id;

Primeras filas:

sucursal autor
Centro Palma
Centro Follett
Centro Valcárcel
Centro Barreda
SELECT COUNT(*) FROM sucursales CROSS JOIN autores;
 count
-------
    32

4 sucursales × 8 autores = 32 filas. Con socios (10) y libros (9) serían 90; con las tablas reales de BiblioRed, 12.000 × 8.000 = 96 millones.

¿Sirve para algo? Sí, en un caso concreto: generar todas las combinaciones posibles de dos conjuntos, por ejemplo para construir una rejilla de "cada sucursal × cada estado posible" que después se rellena con datos. Fuera de eso, un producto cartesiano en producción casi siempre es un accidente.

La forma antigua, y el accidente clásico

Antes de SQL-92 no existía la palabra JOIN: las tablas se listaban en el FROM separadas por comas y la condición de emparejamiento se escribía en el WHERE.

-- Sintaxis antigua: funciona, pero es peligrosa
SELECT p.prestamo_id, s.apellidos
FROM prestamos p, socios s
WHERE s.socio_id = p.socio_id;

El peligro es evidente: si olvidas la condición del WHERE, obtienes un producto cartesiano en silencio. Doce préstamos por diez socios son 120 filas que parecen datos legítimos. Con tablas grandes, la consulta se cuelga.

-- ¡El olvido!
SELECT p.prestamo_id, s.apellidos FROM prestamos p, socios s;   -- 120 filas

Con la sintaxis moderna, JOIN ... ON, la condición está pegada a la unión y no se puede perder de vista. Usa siempre JOIN explícito.

  1. INNER JOIN: la reunión básica

SELECT columnas
FROM tabla_a
INNER JOIN tabla_b ON condición_de_emparejamiento;

INNER JOIN es un producto cartesiano seguido de un filtro: devuelve solo las parejas de filas que cumplen la condición. La palabra INNER es opcional (JOIN a secas significa INNER JOIN), pero escribirla deja claro que no es un LEFT.

SELECT p.prestamo_id,
       s.nombre || ' ' || s.apellidos AS socio,
       p.fecha_prestamo
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
ORDER BY p.prestamo_id;
prestamo_id socio fecha_prestamo
1 Marta Alsina 2026-03-02
2 Iván Pereda 2026-03-05
3 Nuria Bastos 2026-03-11
4 Marta Alsina 2026-04-06
5 Lucía Vendrell 2026-04-12
6 Nuria Bastos 2026-05-04
7 Álvaro Ferrán 2026-05-08
8 Sonia Quiroga 2026-05-19
9 Marta Alsina 2026-07-14
10 Iván Pereda 2026-07-18
11 Diego Salom 2026-07-21
12 Pau Miralles 2026-07-25

Doce filas: una por préstamo. Marta Alsina aparece tres veces porque tiene tres préstamos; eso no es duplicación, es la realidad.

Alias de tabla

p y s son alias de tabla. No son obligatorios, pero sí muy recomendables:

  • Acortan las referencias: s.apellidos en lugar de socios.apellidos.
  • Son imprescindibles cuando dos tablas tienen columnas con el mismo nombre. Si escribes SELECT socio_id FROM prestamos JOIN socios ON ..., el gestor no sabe de cuál de las dos hablas:
ERROR:  la referencia a la columna «socio_id» es ambigua
  • Son obligatorios en un SELF JOIN (apartado 7).

Consejo de estilo: usa iniciales reconocibles (s socios, p prestamos, e ejemplares, l libros, a autores, su sucursales) y sé coherente en todo el proyecto. En este curso siempre usaremos esas mismas.

Combinar JOIN con WHERE

SELECT s.socio_id,
       s.nombre || ' ' || s.apellidos AS socio,
       p.fecha_devolucion_prevista
FROM prestamos p
INNER JOIN socios s ON s.socio_id = p.socio_id
WHERE p.fecha_devolucion IS NULL
ORDER BY p.fecha_devolucion_prevista;
socio_id socio fecha_devolucion_prevista
14 Marta Alsina 2026-08-04
15 Iván Pereda 2026-08-08
17 Diego Salom 2026-08-11
19 Pau Miralles 2026-08-15

Los cuatro socios que tienen algo pendiente de devolver, con el plazo de cada uno.

USING y NATURAL JOIN

Cuando las columnas de ambas tablas se llaman exactamente igual, existe una abreviatura:

SELECT p.prestamo_id, s.apellidos
FROM prestamos p
INNER JOIN socios s USING (socio_id);

USING (socio_id) equivale a ON s.socio_id = p.socio_id, y además fusiona las dos columnas en una sola en el resultado. Es cómodo y nuestro esquema lo permite, porque nombramos las claves ajenas igual que las primarias.

Existe también NATURAL JOIN, que empareja automáticamente por todas las columnas de nombre coincidente:

-- NO lo uses
SELECT * FROM ejemplares NATURAL JOIN libros;

Aquí ejemplares y libros comparten libro_id… pero si algún día alguien añade a ambas una columna observaciones, el NATURAL JOIN empezará a emparejar también por ella y la consulta cambiará de significado sin que nadie la haya tocado. Es magia implícita: evítala. ON explícito o, como mucho, USING.

  1. LEFT JOIN y las preguntas negativas

INNER JOIN descarta lo que no empareja. A veces eso es justo lo que no quieres.

-- ¿Cuántos préstamos tiene cada socio? Con INNER JOIN, los socios
-- sin préstamos desaparecen del listado.
SELECT s.socio_id, s.apellidos, p.prestamo_id
FROM socios s
INNER JOIN prestamos p ON p.socio_id = s.socio_id;

Devuelve 12 filas y solo aparecen 8 socios: Ramón Etxebarri (13) y Elena Roig (20) se han esfumado.

LEFT JOIN (abreviatura de LEFT OUTER JOIN) conserva todas las filas de la tabla izquierda; cuando no hay pareja a la derecha, rellena esas columnas con NULL.

SELECT s.socio_id, s.apellidos, p.prestamo_id, p.fecha_prestamo
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
ORDER BY s.socio_id, p.prestamo_id;
socio_id apellidos prestamo_id fecha_prestamo
11 Ferrán 7 2026-05-08
12 Quiroga 8 2026-05-19
13 Etxebarri (NULL) (NULL)
14 Alsina 1 2026-03-02
14 Alsina 4 2026-04-06
14 Alsina 9 2026-07-14
15 Pereda 2 2026-03-05
15 Pereda 10 2026-07-18
16 Bastos 3 2026-03-11
16 Bastos 6 2026-05-04
17 Salom 11 2026-07-21
18 Vendrell 5 2026-04-12
19 Miralles 12 2026-07-25
20 Roig (NULL) (NULL)

14 filas: los 12 préstamos más las dos filas "vacías" de Etxebarri y Roig.

El patrón anti-join: encontrar lo que NO existe

Y aquí llega uno de los patrones más útiles de todo SQL. Si las filas sin pareja son las que tienen NULL en las columnas de la derecha, basta con filtrarlas:

SELECT s.socio_id, s.nombre, s.apellidos, s.fecha_alta
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
WHERE p.prestamo_id IS NULL
ORDER BY s.socio_id;
socio_id nombre apellidos fecha_alta
13 Ramón Etxebarri 2020-11-14
20 Elena Roig 2024-10-01

Los dos socios que nunca han tomado nada prestado. Es la diferencia − del álgebra relacional, implementada con un LEFT JOIN.

Detalle crucial: la columna que compruebas con IS NULL debe ser una que nunca pueda ser nula en la tabla derecha —la clave primaria es la elección segura—. Si comprobaras WHERE p.fecha_devolucion IS NULL obtendrías otra cosa completamente distinta (los préstamos abiertos, más los socios sin préstamos).

Otro caso real de BiblioRed: los ejemplares que nunca se han prestado, candidatos a expurgo.

SELECT e.codigo, l.titulo, e.estado, e.fecha_adquisicion
FROM ejemplares e
JOIN libros l      ON l.libro_id = e.libro_id
LEFT JOIN prestamos p ON p.ejemplar_id = e.ejemplar_id
WHERE p.prestamo_id IS NULL
ORDER BY e.codigo;
codigo titulo estado fecha_adquisicion
EJ-3083 El mapa del tiempo disponible 2021-06-01
EJ-3088 Álgebra para impacientes reparacion 2020-01-15
EJ-3091 El invierno de los pájaros disponible 2022-04-27
EJ-3093 Manual de jardinería urbana disponible 2023-09-11
EJ-3094 Manual de jardinería urbana baja 2023-09-11
EJ-3095 Memoria del Ensanche (1904) disponible 2003-01-15

Seis ejemplares que llevan en la estantería sin salir nunca. Justo el tipo de informe que la hoja de cálculo no podía producir.

  1. RIGHT JOIN y FULL OUTER JOIN

RIGHT JOIN es la imagen especular de LEFT JOIN: conserva todas las filas de la tabla derecha.

SELECT a.apellidos AS autor, l.titulo
FROM libros l
RIGHT JOIN autores a ON a.autor_id = l.autor_id
ORDER BY a.apellidos, l.titulo;
autor titulo
Barreda Álgebra para impacientes
Barreda Manual de jardinería urbana
Escolá (NULL)
Follett Los pilares de la Tierra
Lemos El invierno de los pájaros
Ordóñez Rutas del delta
Palma El mapa del tiempo
Sorrentino Cuadernos de Ravena
Valcárcel La casa de las mareas

Nueve filas: los ocho autores, con Óscar Barreda repetido por sus dos obras y Marina Escolá con NULL porque aún no tiene ninguna. Observa que "Memoria del Ensanche (1904)" no aparece: es el libro sin autor catalogado, y RIGHT JOIN conserva los autores, no los libros.

Toda consulta RIGHT JOIN se puede reescribir como LEFT JOIN invirtiendo las tablas, y esa es la forma que verás en el 95 % del código profesional:

SELECT a.apellidos AS autor, l.titulo
FROM autores a
LEFT JOIN libros l ON l.autor_id = a.autor_id;

Consejo: quédate con LEFT JOIN. Leer una consulta larga en la que unos JOIN van hacia la izquierda y otros hacia la derecha es innecesariamente difícil.

FULL OUTER JOIN

Conserva las filas sin pareja de ambos lados:

SELECT a.apellidos AS autor, l.titulo
FROM autores a
FULL OUTER JOIN libros l ON l.autor_id = a.autor_id
ORDER BY a.apellidos NULLS LAST, l.titulo;
autor titulo
Barreda Álgebra para impacientes
Barreda Manual de jardinería urbana
Escolá (NULL)
Follett Los pilares de la Tierra
Lemos El invierno de los pájaros
Ordóñez Rutas del delta
Palma El mapa del tiempo
Sorrentino Cuadernos de Ravena
Valcárcel La casa de las mareas
(NULL) Memoria del Ensanche (1904)

Diez filas: aparece tanto la autora sin libros como el libro sin autor. Es la consulta de auditoría por excelencia: enseña los dos lados descosidos de una vez.

Aviso sobre SQLite

RIGHT JOIN y FULL OUTER JOIN no existieron en SQLite hasta la versión 3.39 (junio de 2022). Si tu sqlite3 es anterior, esas dos consultas darán error de sintaxis. Comprueba tu versión con SELECT sqlite_version();. Soluciones:

  • Reescribir el RIGHT JOIN como LEFT JOIN con las tablas invertidas (siempre posible).
  • Simular el FULL OUTER JOIN con dos LEFT JOIN unidos por UNION (apartado 15).

LEFT JOIN e INNER JOIN, en cambio, funcionan en SQLite desde siempre.

  1. Resumen visual de los tipos de JOIN

Imagina dos tablas mínimas emparejadas por una clave:

  • IZQ con claves 1, 2, 3
  • DER con claves 2, 3, 4
Tipo de JOIN Filas que devuelve Claves del resultado Nº de filas
INNER JOIN Solo las que emparejan 2, 3 2
LEFT JOIN Todas las de la izquierda 1 (con NULL), 2, 3 3
RIGHT JOIN Todas las de la derecha 2, 3, 4 (con NULL) 3
FULL OUTER JOIN Todas las de ambos lados 1, 2, 3, 4 4
CROSS JOIN Todas las combinaciones 3 × 3 parejas 9
LEFT JOIN + IS NULL Solo las de la izquierda sin pareja 1 1

Y el árbol de decisión que conviene tener en la cabeza al escribir una consulta:

flowchart TD
    A["¿Qué filas quiero en el resultado?"] --> B{"¿Necesito filas<br/>sin correspondencia?"}
    B -->|No: solo las emparejadas| C["INNER JOIN"]
    B -->|Sí| D{"¿De qué lado?"}
    D -->|"Solo de la tabla principal<br/>(la del FROM)"| E["LEFT JOIN"]
    D -->|Solo de la secundaria| F["RIGHT JOIN<br/><i>mejor: dale la vuelta<br/>y usa LEFT JOIN</i>"]
    D -->|De los dos lados| G["FULL OUTER JOIN"]
    E --> H{"¿Quiero EXCLUSIVAMENTE<br/>las que no emparejan?"}
    H -->|Sí| I["LEFT JOIN + WHERE clave_derecha IS NULL<br/><i>(anti-join)</i>"]
    H -->|No| J["LEFT JOIN a secas"]
    B -->|"Quiero todas las combinaciones<br/>posibles, sin emparejar"| K["CROSS JOIN"]

  1. SELF JOIN: unir una tabla consigo misma

No es un tipo distinto de JOIN: es un INNER o LEFT JOIN normal en el que las dos tablas son la misma. Sirve para comparar filas de una tabla entre sí, y por eso los alias son obligatorios: hay que poder distinguir las dos "copias".

Pregunta: ¿qué parejas de socios están dados de alta en la misma sucursal?

SELECT a.apellidos AS socio_a,
       b.apellidos AS socio_b,
       a.sucursal_id
FROM socios a
INNER JOIN socios b
        ON b.sucursal_id = a.sucursal_id
       AND b.socio_id > a.socio_id
ORDER BY a.sucursal_id, a.apellidos, b.apellidos;
socio_a socio_b sucursal_id
Bastos Roig 1
Etxebarri Bastos 1
Etxebarri Roig 1
Ferrán Bastos 1
Ferrán Etxebarri 1
Ferrán Roig 1
Alsina Miralles 2
Alsina Pereda 2
Pereda Miralles 2
Quiroga Salom 3

Diez parejas. La condición b.socio_id > a.socio_id hace dos cosas a la vez y es el truco que hay que memorizar:

  1. Evita emparejar a cada socio consigo mismo (que sería a.socio_id = b.socio_id).
  2. Evita el duplicado simétrico: si sale (Ferrán, Roig), no sale también (Roig, Ferrán).

Sin esa condición obtendrías 4×4 + 3×3 + 2×2 + 1×1 = 30 filas en lugar de 10.

Otro SELF JOIN útil en BiblioRed: otras obras del mismo autor.

SELECT l1.titulo AS libro, l2.titulo AS otra_obra_del_mismo_autor
FROM libros l1
INNER JOIN libros l2 ON l2.autor_id = l1.autor_id
                    AND l2.libro_id <> l1.libro_id
ORDER BY l1.titulo;
libro otra_obra_del_mismo_autor
Álgebra para impacientes Manual de jardinería urbana
Manual de jardinería urbana Álgebra para impacientes

Aquí sí queremos las dos direcciones (para poder recomendar desde cualquiera de los dos libros), por eso usamos <> en lugar de >. Óscar Barreda es el único autor con dos obras en el fondo.

El uso canónico del SELF JOIN en el mundo real son las jerarquías: una tabla empleados con una columna jefe_id que apunta a la propia tabla. BiblioRed no tiene ninguna, pero el mecanismo es idéntico.

  1. Encadenar tres o más tablas

Los JOIN se encadenan de arriba abajo: el resultado del primero se une con la tabla siguiente, y así sucesivamente. La consulta estrella de BiblioRed recorre cinco tablas.

flowchart LR
    S["socios"] --> P["prestamos"]
    P --> E["ejemplares"]
    E --> L["libros"]
    L --> A["autores"]
SELECT s.nombre || ' ' || s.apellidos AS socio,
       e.codigo                       AS ejemplar,
       l.titulo,
       a.apellidos                    AS autor,
       p.fecha_prestamo,
       p.fecha_devolucion_prevista
FROM prestamos p
INNER JOIN socios s     ON s.socio_id    = p.socio_id
INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
INNER JOIN libros l     ON l.libro_id    = e.libro_id
LEFT  JOIN autores a    ON a.autor_id    = l.autor_id
WHERE p.fecha_devolucion IS NULL
ORDER BY p.fecha_prestamo;
socio ejemplar titulo autor fecha_prestamo fecha_devolucion_prevista
Marta Alsina EJ-3081 El mapa del tiempo Palma 2026-07-14 2026-08-04
Iván Pereda EJ-3084 Los pilares de la Tierra Follett 2026-07-18 2026-08-08
Diego Salom EJ-3086 La casa de las mareas Valcárcel 2026-07-21 2026-08-11
Pau Miralles EJ-3090 El invierno de los pájaros Lemos 2026-07-25 2026-08-15

Este es el listado que el bibliotecario quiere ver cada mañana. Y ya podemos responder a la pregunta del principio: quien tiene ahora mismo EJ-3081 es Marta Alsina, y debe devolverlo el 4 de agosto de 2026.

Tres decisiones de esa consulta merecen comentario:

  • Empezamos por prestamos, no por socios. Cuando encadenas varias tablas, arrancar por la tabla "central" —la que tiene las claves ajenas hacia las demás— hace la consulta mucho más natural de leer.
  • autores se une con LEFT JOIN. ¿Por qué? Porque libros.autor_id admite NULL (recuerda "Memoria del Ensanche"). Con un INNER JOIN, si alguien prestara ese ejemplar, el préstamo desaparecería del informe matinal sin que nadie se enterara. Es el error silencioso más frecuente al encadenar tablas: un INNER JOIN sobre una clave ajena opcional pierde filas.
  • El orden en que escribes los JOIN no determina el orden de ejecución. El optimizador (lección 01-04) decide por su cuenta. Tú escribes para que se lea bien; él ejecuta para que corra rápido.

  1. Filtrar en ON o filtrar en WHERE

Esta distinción es sutil, se pregunta en todas las entrevistas y provoca resultados incorrectos a diario. Con INNER JOIN da exactamente igual dónde pongas la condición. Con LEFT JOIN, cambia el resultado por completo.

La razón está en el orden de evaluación:

  1. ON se aplica mientras se emparejan las filas: decide qué es pareja y qué no.
  2. WHERE se aplica después de construida la unión: descarta filas del resultado ya montado, incluidas las filas rellenas de NULL que el LEFT JOIN acababa de conservar.

Pregunta: "lista de todos los socios, indicando qué préstamos han hecho a partir de julio de 2026".

Con la condición en ON

SELECT s.socio_id, s.apellidos, p.prestamo_id, p.fecha_prestamo
FROM socios s
LEFT JOIN prestamos p
       ON p.socio_id = s.socio_id
      AND p.fecha_prestamo >= '2026-07-01'
ORDER BY s.socio_id;
socio_id apellidos prestamo_id fecha_prestamo
11 Ferrán (NULL) (NULL)
12 Quiroga (NULL) (NULL)
13 Etxebarri (NULL) (NULL)
14 Alsina 9 2026-07-14
15 Pereda 10 2026-07-18
16 Bastos (NULL) (NULL)
17 Salom 11 2026-07-21
18 Vendrell (NULL) (NULL)
19 Miralles 12 2026-07-25
20 Roig (NULL) (NULL)

Diez filas: están todos los socios, que es lo que pedía la pregunta. Los que no tienen préstamos en julio salen con NULL.

Con la condición en WHERE

SELECT s.socio_id, s.apellidos, p.prestamo_id, p.fecha_prestamo
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
WHERE p.fecha_prestamo >= '2026-07-01'
ORDER BY s.socio_id;
socio_id apellidos prestamo_id fecha_prestamo
14 Alsina 9 2026-07-14
15 Pereda 10 2026-07-18
17 Salom 11 2026-07-21
19 Miralles 12 2026-07-25

Cuatro filas. El WHERE ha eliminado todas las filas con p.fecha_prestamo a NULL, porque NULL >= '2026-07-01' es DESCONOCIDO. El LEFT JOIN se ha convertido, de hecho, en un INNER JOIN.

Dónde va la condición Efecto en INNER JOIN Efecto en LEFT JOIN
En ON Idéntico Filtra qué se empareja; conserva todas las filas de la izquierda
En WHERE Idéntico Filtra el resultado; anula el efecto del LEFT

Regla práctica: en un LEFT JOIN, las condiciones sobre la tabla derecha van en el ON; las condiciones sobre la tabla izquierda van en el WHERE. La única excepción es el patrón anti-join WHERE clave_derecha IS NULL, donde quieres deliberadamente ese efecto.

  1. Subconsultas escalares

Una subconsulta es un SELECT dentro de otro SELECT, entre paréntesis. La variedad más simple es la escalar: devuelve exactamente una fila y una columna, es decir, un valor suelto, y por tanto se puede usar en cualquier sitio donde encaje un valor.

En el WHERE

-- Libros publicados después de "El mapa del tiempo"
SELECT titulo, anio_publicacion
FROM libros
WHERE anio_publicacion > (SELECT anio_publicacion FROM libros WHERE libro_id = 331)
ORDER BY anio_publicacion;
titulo anio_publicacion
Cuadernos de Ravena 2012
La casa de las mareas 2015
Rutas del delta 2017
Álgebra para impacientes 2019
El invierno de los pájaros 2021
Manual de jardinería urbana 2023

La subconsulta se ejecuta una vez, devuelve 2008, y la consulta externa lo usa como si lo hubieras escrito a mano. La ventaja es que no tienes que saber el valor de antemano: si mañana se corrige el año de ese libro, la consulta sigue siendo correcta.

Si una subconsulta escalar devuelve más de una fila, el gestor da error:

SELECT titulo FROM libros
WHERE anio_publicacion > (SELECT anio_publicacion FROM libros WHERE editorial = 'Editorial Andana');
ERROR:  más de una fila retornada por una subconsulta usada como expresión

Si devuelve cero filas, en cambio, no hay error: el resultado es NULL, y la comparación entera pasa a DESCONOCIDO, así que la consulta externa devuelve cero filas… en silencio. Otra aplicación de la lógica de tres valores.

En la lista del SELECT

SELECT e.codigo,
       e.estado,
       (SELECT l.titulo FROM libros l WHERE l.libro_id = e.libro_id) AS titulo
FROM ejemplares e
WHERE e.sucursal_id = 4
ORDER BY e.codigo;
codigo estado titulo
EJ-3088 reparacion Álgebra para impacientes
EJ-3092 disponible Rutas del delta

Esto es equivalente a un LEFT JOIN con libros. Como norma general, prefiere el JOIN: es más legible, más flexible (puedes traer varias columnas de la tabla unida) y el optimizador suele tratarlo mejor. La subconsulta en el SELECT se reserva para cuando necesitas un único dato calculado y el JOIN complicaría la consulta.

  1. Subconsultas con IN, EXISTS y NOT EXISTS

Cuando la subconsulta devuelve varias filas, no se puede comparar con =, pero sí preguntar por pertenencia o por existencia.

IN

-- Socios que tienen algún préstamo abierto
SELECT socio_id, nombre, apellidos
FROM socios
WHERE socio_id IN (SELECT socio_id FROM prestamos WHERE fecha_devolucion IS NULL)
ORDER BY socio_id;
socio_id nombre apellidos
14 Marta Alsina
15 Iván Pereda
17 Diego Salom
19 Pau Miralles

Compáralo con la versión JOIN. Con JOIN habría que añadir DISTINCT para no repetir a los socios con varios préstamos abiertos; con IN no hace falta, porque la pertenencia a un conjunto es sí o no. Esa es su ventaja principal.

NOT IN y la trampa del NULL

-- Libros que nadie ha reservado nunca
SELECT libro_id, titulo
FROM libros
WHERE libro_id NOT IN (SELECT libro_id FROM reservas)
ORDER BY libro_id;
libro_id titulo
334 Álgebra para impacientes
335 Cuadernos de Ravena
337 Rutas del delta
338 Manual de jardinería urbana
339 Memoria del Ensanche (1904)

Funciona porque reservas.libro_id es NOT NULL. Ahora la misma idea sobre una columna que admite nulos:

-- Autores sin ninguna obra en el fondo
SELECT autor_id, apellidos
FROM autores
WHERE autor_id NOT IN (SELECT autor_id FROM libros);
(0 filas)

Cero filas, cuando la respuesta correcta es "Marina Escolá". El motivo lo vimos en 02-01: la subconsulta devuelve {1, 2, 3, 4, 5, 6, 7, NULL} (el NULL es el de "Memoria del Ensanche"), y 8 NOT IN (..., NULL) se traduce en 8 <> 1 AND ... AND 8 <> NULL, cuyo último término es DESCONOCIDO. Y VERDADERO AND DESCONOCIDO es DESCONOCIDO, que no pasa el filtro. Con un solo NULL en la lista, NOT IN no devuelve jamás ninguna fila.

Las tres soluciones, de peor a mejor:

-- 1) Filtrar los NULL a mano: funciona, pero hay que acordarse siempre
SELECT autor_id, apellidos FROM autores
WHERE autor_id NOT IN (SELECT autor_id FROM libros WHERE autor_id IS NOT NULL);

-- 2) Anti-join con LEFT JOIN
SELECT a.autor_id, a.apellidos FROM autores a
LEFT JOIN libros l ON l.autor_id = a.autor_id
WHERE l.libro_id IS NULL;

-- 3) NOT EXISTS: inmune al problema por construcción
SELECT a.autor_id, a.apellidos FROM autores a
WHERE NOT EXISTS (SELECT 1 FROM libros l WHERE l.autor_id = a.autor_id);

Las tres devuelven ahora:

autor_id apellidos
8 Escolá

EXISTS y NOT EXISTS

EXISTS no compara valores: pregunta si la subconsulta devuelve al menos una fila. Devuelve TRUE o FALSE, nunca DESCONOCIDO, y por eso es inmune a la trampa anterior.

-- Socios con algún préstamo abierto (la misma pregunta que con IN)
SELECT s.socio_id, s.nombre, s.apellidos
FROM socios s
WHERE EXISTS (SELECT 1
              FROM prestamos p
              WHERE p.socio_id = s.socio_id
                AND p.fecha_devolucion IS NULL)
ORDER BY s.socio_id;
socio_id nombre apellidos
14 Marta Alsina
15 Iván Pereda
17 Diego Salom
19 Pau Miralles

El SELECT 1 es una convención: como solo importa si hay filas, no qué filas, se pone una constante. SELECT * funcionaría igual y el gestor lo optimiza idénticamente.

-- Socios que nunca han tomado nada prestado
SELECT s.socio_id, s.nombre, s.apellidos
FROM socios s
WHERE NOT EXISTS (SELECT 1 FROM prestamos p WHERE p.socio_id = s.socio_id)
ORDER BY s.socio_id;
socio_id nombre apellidos
13 Ramón Etxebarri
20 Elena Roig

Mismo resultado que el anti-join del apartado 4. Tres formas de expresar la misma pregunta:

Forma Legibilidad Riesgo con NULL Cuándo usarla
LEFT JOIN ... IS NULL Media Ninguno (si compruebas la clave primaria) Cuando además necesitas columnas de la tabla derecha
NOT IN (subconsulta) Alta Alto Solo si la columna es NOT NULL
NOT EXISTS Alta Ninguno La opción por defecto

  1. Subconsultas correlacionadas

Las subconsultas de EXISTS que acabas de ver tienen una particularidad: mencionan una columna de la consulta externa (s.socio_id). Eso las convierte en correlacionadas: no se pueden ejecutar por sí solas, porque dependen de la fila que se esté evaluando en cada momento.

Subconsulta independiente Subconsulta correlacionada
¿Se puede ejecutar aparte? No
¿Cuántas veces se evalúa? Una Conceptualmente, una por fila externa
Ejemplo WHERE libro_id IN (SELECT libro_id FROM reservas) WHERE EXISTS (SELECT 1 FROM reservas r WHERE r.libro_id = l.libro_id)

Un ejemplo de BiblioRed que responde a una pregunta genuinamente difícil de otro modo: ¿qué ejemplares pertenecen a un libro que tiene alguna reserva activa? (son los que hay que apartar en cuanto vuelvan al mostrador).

SELECT e.codigo, e.estado, e.sucursal_id, l.titulo
FROM ejemplares e
INNER JOIN libros l ON l.libro_id = e.libro_id
WHERE EXISTS (SELECT 1
              FROM reservas r
              WHERE r.libro_id = e.libro_id
                AND r.estado   = 'activa')
ORDER BY e.codigo;
codigo estado sucursal_id titulo
EJ-3081 prestado 2 El mapa del tiempo
EJ-3082 disponible 1 El mapa del tiempo
EJ-3083 disponible 3 El mapa del tiempo
EJ-3084 prestado 1 Los pilares de la Tierra
EJ-3085 disponible 2 Los pilares de la Tierra

Cinco ejemplares en alerta: los tres de "El mapa del tiempo" (dos reservas activas) y los dos de "Los pilares de la Tierra" (una).

Sobre el rendimiento: la descripción "se evalúa una vez por fila externa" es el modelo mental, no lo que ocurre necesariamente. Los optimizadores modernos suelen transformar una subconsulta correlacionada en un JOIN internamente. Aun así, sobre tablas grandes conviene medirlo; en la lección 06-03 aprenderás a comprobarlo con EXPLAIN.

  1. Tablas derivadas: subconsultas en FROM

Una subconsulta también puede ocupar el lugar de una tabla en el FROM. Se llama tabla derivada y, como cualquier tabla, necesita un alias.

SELECT su.nombre AS sucursal,
       ab.codigo,
       ab.fecha_prestamo
FROM (SELECT p.prestamo_id, p.fecha_prestamo, e.codigo, e.sucursal_id
      FROM prestamos p
      INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
      WHERE p.fecha_devolucion IS NULL) AS ab
INNER JOIN sucursales su ON su.sucursal_id = ab.sucursal_id
ORDER BY su.nombre, ab.fecha_prestamo;
sucursal codigo fecha_prestamo
Centro EJ-3084 2026-07-18
Centro EJ-3086 2026-07-21
Norte EJ-3081 2026-07-14
Norte EJ-3090 2026-07-25

Los cuatro préstamos abiertos, repartidos entre las sucursales donde vive cada ejemplar. La tabla derivada ab (de "abiertos") existe solo durante la consulta.

Las tablas derivadas resuelven casos en los que necesitas trabajar sobre un resultado intermedio, sobre todo cuando ese intermedio incluye agregados —cosa que veremos en la lección 02-05—. Su inconveniente es la legibilidad: si anidas dos o tres, la consulta se vuelve un laberinto de paréntesis que hay que leer de dentro afuera.

  1. Expresiones de tabla común: WITH

Una CTE (Common Table Expression, expresión de tabla común) es una tabla derivada a la que se le da nombre antes de usarla. Misma potencia, legibilidad incomparablemente mejor.

WITH abiertos AS (
    SELECT p.prestamo_id, p.fecha_prestamo, e.codigo, e.sucursal_id
    FROM prestamos p
    INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
    WHERE p.fecha_devolucion IS NULL
)
SELECT su.nombre AS sucursal,
       ab.codigo,
       ab.fecha_prestamo
FROM abiertos ab
INNER JOIN sucursales su ON su.sucursal_id = ab.sucursal_id
ORDER BY su.nombre, ab.fecha_prestamo;

Devuelve exactamente lo mismo que el apartado anterior, pero ahora la consulta se lee de arriba abajo, como un procedimiento: "primero calculo los abiertos, después los cruzo con las sucursales".

Varias CTE encadenadas

Se separan por comas, y cada una puede usar las anteriores:

WITH abiertos AS (
    SELECT p.prestamo_id, p.socio_id, p.fecha_prestamo, e.codigo
    FROM prestamos p
    INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
    WHERE p.fecha_devolucion IS NULL
),
socios_norte AS (
    SELECT socio_id, nombre, apellidos
    FROM socios
    WHERE sucursal_id = 2
)
SELECT sn.nombre || ' ' || sn.apellidos AS socio,
       ab.codigo,
       ab.fecha_prestamo
FROM socios_norte sn
INNER JOIN abiertos ab ON ab.socio_id = sn.socio_id
ORDER BY ab.fecha_prestamo;
socio codigo fecha_prestamo
Marta Alsina EJ-3081 2026-07-14
Iván Pereda EJ-3084 2026-07-18
Pau Miralles EJ-3090 2026-07-25

Tres de los cuatro préstamos abiertos corresponden a socios de la sucursal Norte; el cuarto es de Diego Salom, dado de alta en Sur.

Ventajas de las CTE:

  • Legibilidad: cada bloque tiene nombre y propósito.
  • Reutilización: una CTE se puede referenciar varias veces en la consulta principal, mientras que una tabla derivada habría que escribirla dos veces.
  • Depuración: puedes ejecutar el contenido de la CTE por separado para ver qué produce.

Notas de dialecto:

  • WITH es estándar y funciona en PostgreSQL (desde 8.4) y SQLite (desde 3.8.3).
  • Existe WITH RECURSIVE para recorrer jerarquías y grafos (árboles de categorías, listas de materiales). Es potente y queda fuera del alcance de este curso introductorio.
  • Históricamente PostgreSQL trataba cada CTE como una barrera de optimización; desde la versión 12 las integra en la consulta principal salvo que escribas MATERIALIZED. Si trabajas con una versión anterior y notas lentitud, esa puede ser la causa.

  1. Operadores de conjunto: UNION, INTERSECT, EXCEPT

Los JOIN combinan tablas a lo ancho (añaden columnas). Los operadores de conjunto las combinan a lo alto (apilan filas). Son la unión ∪, la intersección ∩ y la diferencia − del álgebra relacional.

Para usarlos, las dos consultas deben ser compatibles: mismo número de columnas y tipos compatibles, en el mismo orden. Los nombres de columna los pone la primera consulta.

UNION y UNION ALL

-- Socios "en movimiento": con préstamo abierto o con reserva activa
SELECT socio_id FROM prestamos WHERE fecha_devolucion IS NULL
UNION
SELECT socio_id FROM reservas  WHERE estado = 'activa'
ORDER BY socio_id;
socio_id
14
15
16
17
18
19

Seis socios. La primera consulta devuelve {14, 15, 17, 19} y la segunda {16, 18, 15}; Iván Pereda (15) está en las dos y aparece una sola vez, porque UNION elimina duplicados.

SELECT socio_id FROM prestamos WHERE fecha_devolucion IS NULL
UNION ALL
SELECT socio_id FROM reservas  WHERE estado = 'activa'
ORDER BY socio_id;
socio_id
14
15
15
16
17
18
19

Siete filas: UNION ALL no elimina duplicados. Y precisamente por eso es más rápido: no tiene que ordenar ni comparar nada. Regla práctica: usa UNION ALL salvo que necesites la deduplicación. Mucha gente escribe UNION por costumbre y paga el coste sin motivo.

INTERSECT

-- Socios que tienen préstamo abierto Y ADEMÁS reserva activa
SELECT socio_id FROM prestamos WHERE fecha_devolucion IS NULL
INTERSECT
SELECT socio_id FROM reservas  WHERE estado = 'activa';
socio_id
15

Iván Pereda: tiene "Los pilares de la Tierra" en préstamo y "El mapa del tiempo" reservado.

EXCEPT

-- Socios con préstamo abierto pero SIN ninguna reserva activa
SELECT socio_id FROM prestamos WHERE fecha_devolucion IS NULL
EXCEPT
SELECT socio_id FROM reservas  WHERE estado = 'activa'
ORDER BY socio_id;
socio_id
14
17
19

EXCEPT no es simétrico: A EXCEPT B no es lo mismo que B EXCEPT A. Si invirtieras el orden obtendrías {16, 18}, los socios con reserva activa y sin préstamos abiertos.

Detalles a tener en cuenta

  • INTERSECT y EXCEPT también eliminan duplicados por defecto; existen INTERSECT ALL y EXCEPT ALL.
  • El ORDER BY va al final, una sola vez, y ordena el resultado combinado. No puedes poner uno en cada rama (salvo entre paréntesis con LIMIT).
  • En Oracle, EXCEPT se llama MINUS.
  • SQLite admite UNION, UNION ALL, INTERSECT y EXCEPT desde siempre; es su punto fuerte frente a los JOIN externos.

Un uso muy práctico en SQLite antiguo: simular un FULL OUTER JOIN.

SELECT a.apellidos, l.titulo FROM autores a LEFT JOIN libros l ON l.autor_id = a.autor_id
UNION
SELECT a.apellidos, l.titulo FROM libros l LEFT JOIN autores a ON a.autor_id = l.autor_id;

Las diez filas del apartado 5, sin necesitar FULL OUTER JOIN.

Errores Comunes y Consejos

  • Olvidar la condición de unión. Con la sintaxis antigua de comas produce un producto cartesiano silencioso. Usa siempre JOIN ... ON.
  • Usar INNER JOIN sobre una clave ajena que admite NULL. Pierdes filas sin previo aviso. Si la columna puede ser nula, LEFT JOIN.
  • Poner la condición de la tabla derecha en el WHERE de un LEFT JOIN. Lo convierte en INNER JOIN y te quedas sin las filas que querías conservar. Va en el ON.
  • Comprobar IS NULL sobre la columna equivocada en un anti-join. Usa siempre la clave primaria de la tabla derecha, que nunca puede ser nula por sí misma.
  • NOT IN con una subconsulta que puede devolver NULL. Cero filas, siempre, en silencio. Usa NOT EXISTS.
  • Confundir "más filas de las esperadas" con un error del JOIN. Si un socio tiene tres préstamos, aparecerá tres veces: eso es correcto. El problema surge al contar (COUNT) sobre ese resultado, y lo veremos en la lección 02-05.
  • Encadenar JOIN sin alias. Con cinco tablas y columnas homónimas, una consulta sin alias es ilegible e incluso ambigua para el gestor.
  • Usar NATURAL JOIN. Empareja por nombres coincidentes y cambia de significado cuando alguien añade una columna.
  • Anidar tablas derivadas de tres niveles. Conviértelas en CTE con WITH: mismo resultado, la mitad de tiempo para entenderla.
  • Consejo: cuando una consulta multitabla devuelva algo raro, quítale cláusulas hasta que funcione. Ejecuta primero el JOIN a pelo con SELECT *, mira cuántas filas salen y ve añadiendo condiciones de una en una.
  • Consejo: escribe siempre la condición de unión en el orden ON tabla_nueva.columna = tabla_ya_presente.columna. Es una convención menor, pero al leer una consulta de cinco tablas se agradece muchísimo.

Ejercicios

Ejercicio 1: Reuniones básicas

  1. Lista los ejemplares con el título del libro al que pertenecen y el nombre de su sucursal. Ordena por sucursal y código.
  2. Muestra las reservas activas con el nombre del socio y el título del libro reservado.
  3. Lista todos los libros con el apellido de su autor, incluidos los que no tienen autor catalogado.
  4. Muestra los préstamos devueltos con retraso, indicando socio, título y las dos fechas.

Ejercicio 2: Preguntas negativas

  1. ¿Qué libros no tienen ningún ejemplar en la sucursal Centro (sucursal_id = 1)? Resuélvelo con NOT EXISTS.
  2. ¿Qué socios no han hecho ninguna reserva nunca? Resuélvelo de dos maneras: con anti-join y con NOT EXISTS.
  3. ¿Qué sucursales no tienen ningún ejemplar en estado prestado?
  4. ¿Qué autores tienen obra en el fondo pero ninguna de sus obras se ha prestado jamás?

Ejercicio 3: Consultas compuestas

  1. Usando una CTE, obtén los ejemplares disponibles de libros que tienen alguna reserva activa, con su código, título y sucursal. Es la lista de "apartar para reservas".
  2. Con operadores de conjunto, obtén los identificadores de los socios que han hecho alguna reserva pero nunca un préstamo.
  3. Lista, para cada sucursal, los socios dados de alta en ella y los ejemplares que custodia… y explica por qué no debe hacerse con un solo JOIN de tres tablas.

Soluciones

Solución 1

-- 1
SELECT su.nombre AS sucursal, e.codigo, l.titulo, e.estado
FROM ejemplares e
INNER JOIN libros l      ON l.libro_id    = e.libro_id
INNER JOIN sucursales su ON su.sucursal_id = e.sucursal_id
ORDER BY su.nombre, e.codigo;

Quince filas. Las dos primeras y las dos últimas:

sucursal codigo titulo estado
Centro EJ-3082 El mapa del tiempo disponible
Centro EJ-3084 Los pilares de la Tierra prestado
Sur EJ-3089 Cuadernos de Ravena disponible
Sur EJ-3094 Manual de jardinería urbana baja
-- 2
SELECT s.nombre || ' ' || s.apellidos AS socio, l.titulo, r.fecha_reserva
FROM reservas r
INNER JOIN socios s ON s.socio_id = r.socio_id
INNER JOIN libros l ON l.libro_id = r.libro_id
WHERE r.estado = 'activa'
ORDER BY r.fecha_reserva;
socio titulo fecha_reserva
Nuria Bastos El mapa del tiempo 2026-07-20
Lucía Vendrell Los pilares de la Tierra 2026-07-22
Iván Pereda El mapa del tiempo 2026-07-28
-- 3  LEFT JOIN, porque libros.autor_id admite NULL
SELECT l.titulo, a.apellidos AS autor
FROM libros l
LEFT JOIN autores a ON a.autor_id = l.autor_id
ORDER BY l.titulo;
titulo autor
Álgebra para impacientes Barreda
Cuadernos de Ravena Sorrentino
El invierno de los pájaros Lemos
El mapa del tiempo Palma
La casa de las mareas Valcárcel
Los pilares de la Tierra Follett
Manual de jardinería urbana Barreda
Memoria del Ensanche (1904) (NULL)
Rutas del delta Ordóñez

Con INNER JOIN habrías perdido "Memoria del Ensanche (1904)".

-- 4
SELECT s.nombre || ' ' || s.apellidos AS socio,
       l.titulo,
       p.fecha_devolucion_prevista,
       p.fecha_devolucion,
       p.recargo
FROM prestamos p
INNER JOIN socios s     ON s.socio_id    = p.socio_id
INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
INNER JOIN libros l     ON l.libro_id    = e.libro_id
WHERE p.fecha_devolucion > p.fecha_devolucion_prevista
ORDER BY p.fecha_prestamo;
socio titulo fecha_devolucion_prevista fecha_devolucion recargo
Iván Pereda El mapa del tiempo 2026-03-26 2026-04-02 1.40
Lucía Vendrell Cuadernos de Ravena 2026-05-03 2026-05-10 1.40
Sonia Quiroga Rutas del delta 2026-06-09 2026-06-30 4.20

Solución 2

-- 1
SELECT l.libro_id, l.titulo
FROM libros l
WHERE NOT EXISTS (SELECT 1 FROM ejemplares e
                  WHERE e.libro_id = l.libro_id AND e.sucursal_id = 1)
ORDER BY l.libro_id;
libro_id titulo
334 Álgebra para impacientes
335 Cuadernos de Ravena
337 Rutas del delta
-- 2a  Anti-join
SELECT s.socio_id, s.apellidos
FROM socios s
LEFT JOIN reservas r ON r.socio_id = s.socio_id
WHERE r.reserva_id IS NULL
ORDER BY s.socio_id;

-- 2b  NOT EXISTS
SELECT s.socio_id, s.apellidos
FROM socios s
WHERE NOT EXISTS (SELECT 1 FROM reservas r WHERE r.socio_id = s.socio_id)
ORDER BY s.socio_id;
socio_id apellidos
12 Quiroga
13 Etxebarri
17 Salom
19 Miralles
20 Roig

Han reservado alguna vez los socios 11, 14, 15, 16 y 18; los otros cinco, nunca.

-- 3
SELECT su.sucursal_id, su.nombre
FROM sucursales su
WHERE NOT EXISTS (SELECT 1 FROM ejemplares e
                  WHERE e.sucursal_id = su.sucursal_id AND e.estado = 'prestado')
ORDER BY su.sucursal_id;
sucursal_id nombre
3 Sur
4 Este
-- 4  Doble negación: autores CON libros pero SIN préstamos de esos libros
SELECT a.autor_id, a.apellidos
FROM autores a
WHERE EXISTS (SELECT 1 FROM libros l WHERE l.autor_id = a.autor_id)
  AND NOT EXISTS (SELECT 1
                  FROM libros l
                  INNER JOIN ejemplares e  ON e.libro_id    = l.libro_id
                  INNER JOIN prestamos  p  ON p.ejemplar_id = e.ejemplar_id
                  WHERE l.autor_id = a.autor_id)
ORDER BY a.autor_id;
(0 filas)

Ningún autor cumple las dos condiciones. Óscar Barreda (4) parecía candidato, porque "Manual de jardinería urbana" no se ha prestado nunca, pero su otra obra, "Álgebra para impacientes", sí (préstamo 4). Es un buen recordatorio de que en una condición "ninguna de sus obras" hay que examinar todas las obras del autor, no una a una.

Solución 3

-- 1
WITH reservados AS (
    SELECT DISTINCT libro_id FROM reservas WHERE estado = 'activa'
)
SELECT e.codigo, l.titulo, su.nombre AS sucursal
FROM ejemplares e
INNER JOIN reservados rv ON rv.libro_id    = e.libro_id
INNER JOIN libros l      ON l.libro_id     = e.libro_id
INNER JOIN sucursales su ON su.sucursal_id = e.sucursal_id
WHERE e.estado = 'disponible'
ORDER BY e.codigo;
codigo titulo sucursal
EJ-3082 El mapa del tiempo Centro
EJ-3083 El mapa del tiempo Sur
EJ-3085 Los pilares de la Tierra Norte

Tres ejemplares que el personal debe apartar. El DISTINCT de la CTE es importante: "El mapa del tiempo" tiene dos reservas activas, y sin él cada ejemplar suyo aparecería duplicado.

-- 2
SELECT socio_id FROM reservas
EXCEPT
SELECT socio_id FROM prestamos
ORDER BY socio_id;
(0 filas)

Los cinco socios que han reservado alguna vez (11, 14, 15, 16, 18) han hecho también algún préstamo. Prueba la operación inversa para ver la diferencia:

SELECT socio_id FROM prestamos
EXCEPT
SELECT socio_id FROM reservas
ORDER BY socio_id;
socio_id
12
17
19
-- 3  La consulta "ingenua"
SELECT su.nombre, s.apellidos, e.codigo
FROM sucursales su
LEFT JOIN socios s     ON s.sucursal_id  = su.sucursal_id
LEFT JOIN ejemplares e ON e.sucursal_id  = su.sucursal_id;

Por qué no debe hacerse así: socios y ejemplares no están relacionadas entre sí; ambas cuelgan de sucursales de forma independiente. Al unirlas en la misma consulta se produce un producto cartesiano dentro de cada sucursal: la sucursal Centro tiene 4 socios y 6 ejemplares, así que genera 24 filas. El total es 4×6 + 3×4 + 2×3 + 1×2 = 24 + 12 + 6 + 2 = 44 filas, ninguna de las cuales significa nada: emparejan a Nuria Bastos con un ejemplar que no ha tocado.

Este fenómeno tiene nombre —explosión de filas o fan trap— y es una de las causas más frecuentes de recuentos inflados. Las dos soluciones correctas:

-- a) Dos consultas separadas, que es lo que pedía la pregunta de verdad
SELECT su.nombre, s.apellidos FROM sucursales su
LEFT JOIN socios s ON s.sucursal_id = su.sucursal_id ORDER BY su.nombre;

SELECT su.nombre, e.codigo FROM sucursales su
LEFT JOIN ejemplares e ON e.sucursal_id = su.sucursal_id ORDER BY su.nombre;
-- b) Una sola consulta, resumiendo cada rama por separado antes de unirlas.
--    Esto necesita funciones de agregado: es exactamente el tema
--    de la lección siguiente, 02-05.

Conclusión

Esta lección ha convertido las siete tablas aisladas de BiblioRed en un sistema consultable:

  • Los datos están repartidos a propósito —cada hecho en un solo sitio— y se recomponen al consultar con la reunión ⋈ del álgebra relacional.
  • El producto cartesiano (CROSS JOIN) es el punto de partida conceptual y el accidente clásico de la sintaxis antigua de comas.
  • INNER JOIN ... ON devuelve solo lo que empareja; los alias de tabla son casi obligatorios y NATURAL JOIN es magia que conviene evitar.
  • LEFT JOIN conserva la tabla izquierda, y su combinación con WHERE clave_derecha IS NULL —el anti-join— es el patrón para responder a toda pregunta que empiece por "los que nunca…": socios sin préstamos, ejemplares nunca prestados.
  • RIGHT JOIN y FULL OUTER JOIN completan el cuadro (y llegaron tarde a SQLite, en la versión 3.39).
  • El SELF JOIN compara filas de una tabla consigo misma, con la condición > para no duplicar parejas simétricas.
  • Encadenar cinco tablas —socio → préstamo → ejemplar → libro → autor— responde a las preguntas reales del mostrador, siempre que uses LEFT JOIN donde la clave ajena admita nulos.
  • Filtrar en ON no es lo mismo que filtrar en WHERE en un LEFT JOIN: el WHERE lo degrada a INNER JOIN.
  • Las subconsultas en sus cinco formas: escalar, con IN, con EXISTS/NOT EXISTS, correlacionadas y como tabla derivada en el FROM. Con una regla grabada a fuego: NOT EXISTS en vez de NOT IN cuando pueda haber NULL.
  • Las CTE con WITH, que convierten una consulta laberíntica en un procedimiento legible de arriba abajo.
  • Los operadores de conjunto: UNION (deduplica), UNION ALL (más rápido), INTERSECT y EXCEPT (que no es simétrico).

Fíjate en que hemos rozado repetidamente un límite: podemos listar los préstamos de cada socio, pero no contarlos; podemos ver los ejemplares de cada sucursal, pero no cuántos hay; el ejercicio 3.3 se ha quedado a medias porque para resumir dos ramas hacía falta algo que aún no tenemos.

Ese algo llega en la lección 02-05, Agregación y Agrupación de Datos: COUNT, SUM, AVG, MIN y MAX, la cláusula GROUP BY, la diferencia entre HAVING y WHERE, el orden lógico en que se ejecuta realmente una consulta y —muy importante después de lo que acabamos de ver— el problema de contar filas infladas por un JOIN. Con ella, BiblioRed pasará de responder "qué hay" a responder "cuánto hay".

Fundamentos de Bases de Datos

Módulo 1: Introducción a las Bases de Datos

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados