Las siete tablas de BiblioRed existen y están vacías. Esta lección las llena y enseña a interrogarlas. Es la lección más práctica del módulo: aquí aprendes las cuatro operaciones que cubren el 90 % del trabajo diario con una base de datos —insertar, consultar, modificar y borrar—, conocidas colectivamente como CRUD (Create, Read, Update, Delete).

Nos limitamos deliberadamente a una sola tabla por consulta. Recomponer información repartida entre varias tablas es un tema con suficiente sustancia para su propia lección (02-04), y agrupar y resumir, para la siguiente (02-05). Aquí construimos los cimientos: sin dominar WHERE y ORDER BY sobre una tabla, ningún JOIN saldrá bien.

El entregable de la lección es el juego de datos de prueba de BiblioRed: cuatro sucursales, ocho autores, diez socios, nueve libros, quince ejemplares, doce préstamos y cinco reservas. Todos ficticios y todos coherentes entre sí. Los usaremos hasta el final del curso, así que ejecútalos con atención y guárdalos en un fichero.

Contenido

  1. INSERT: dar de alta filas
  2. Entregable: el juego de datos de BiblioRed
  3. Reajustar los generadores de identificadores
  4. SELECT: proyección, alias y DISTINCT
  5. WHERE: filtrar filas
  6. ORDER BY: ordenar el resultado
  7. LIMIT y OFFSET: paginación
  8. UPDATE: modificar filas existentes
  9. DELETE: eliminar filas
  10. TRUNCATE: vaciar una tabla entera
  11. Errores comunes y consejos
  12. Ejercicios
  13. Conclusión

  1. INSERT: dar de alta filas

Forma básica

INSERT INTO tabla (columna1, columna2, ...) VALUES (valor1, valor2, ...);

Empecemos por las sucursales de BiblioRed. La red opera en la ciudad ficticia de Vallmar y tiene cuatro bibliotecas.

INSERT INTO sucursales (nombre, direccion, telefono, fecha_apertura)
VALUES ('Centro', 'Plaza Mayor 1', '900 100 001', '1998-04-12');
INSERT 0 1

La salida de PostgreSQL se lee así: INSERT <oid> <número de filas insertadas>. El primer número es un vestigio histórico y siempre vale 0; el segundo es el que importa.

Fíjate en tres cosas:

  • No hemos dado sucursal_id. La columna es GENERATED BY DEFAULT AS IDENTITY, así que PostgreSQL le asigna el 1.
  • La fecha va entre comillas simples, en formato ISO AAAA-MM-DD. El gestor la convierte a DATE porque la columna es de ese tipo.
  • El teléfono va entre comillas, aunque parezca un número: es VARCHAR, como decidimos en la lección anterior.

Sin lista de columnas: la forma frágil

SQL permite omitir la lista de columnas si das un valor para todas, en el orden exacto de la definición de la tabla:

-- Legal, pero no lo hagas
INSERT INTO sucursales VALUES (DEFAULT, 'Norte', 'Avenida del Parque 45', '900 100 002', '2005-09-30');

¿Por qué evitarlo? Porque el día que alguien añada una columna con ALTER TABLE, o reordene la definición, todos los INSERT sin lista de columnas se romperán o, peor, insertarán valores en la columna equivocada sin dar error. Escribe siempre la lista de columnas. Es la primera regla de higiene del SQL profesional.

Hagámoslo bien:

INSERT INTO sucursales (nombre, direccion, telefono, fecha_apertura)
VALUES ('Norte', 'Avenida del Parque 45', '900 100 002', '2005-09-30');

Esta es la sucursal Norte, que a partir de ahora tendrá sucursal_id = 2.

Insertar varias filas de una vez

Se pueden encadenar varias tuplas de valores separadas por comas. Es más rápido (una sola operación en lugar de N) y más legible:

INSERT INTO sucursales (nombre, direccion, telefono, fecha_apertura) VALUES
    ('Sur',  'Calle Olivar 12',   '900 100 003', '2011-02-18'),
    ('Este', 'Ronda del Puerto 8', NULL,         '2019-06-25');
INSERT 0 2

La sucursal Este todavía no tiene teléfono propio: escribimos NULL sin comillas. Si escribiéramos 'NULL' estaríamos guardando la cadena de cuatro letras N-U-L-L, que no es lo mismo en absoluto.

Otra opción es omitir la columna en la lista: lo que no se menciona queda a NULL (o a su valor por defecto, si lo tuviera).

-- (Solo ilustrativo, NO lo ejecutes: crearía una segunda sucursal Este.)
-- Equivalente para el teléfono, pero explícito es mejor que implícito.
INSERT INTO sucursales (nombre, direccion, fecha_apertura)
VALUES ('Este', 'Ronda del Puerto 8', '2019-06-25');

RETURNING: saber qué identificador se ha generado

Cuando dejas que el gestor genere la clave, surge un problema práctico inmediato: ¿qué número le ha tocado? Lo necesitarás para insertar las filas hijas. PostgreSQL lo resuelve con RETURNING:

INSERT INTO autores (nombre, apellidos, nacionalidad, anio_nacimiento)
VALUES ('Félix J.', 'Palma', 'española', 1968)
RETURNING autor_id, apellidos;
 autor_id | apellidos
----------+-----------
        1 | Palma
(1 fila)

RETURNING funciona igual con UPDATE y DELETE, y puede devolver cualquier expresión, incluido *. Es una extensión de PostgreSQL muy cómoda que evita el clásico "inserto y luego consulto".

SQLite no tiene RETURNING hasta la versión 3.35 (2021); en versiones anteriores se usa la función last_insert_rowid():

INSERT INTO autores (nombre, apellidos) VALUES ('Félix J.', 'Palma');
SELECT last_insert_rowid();

Qué comprueba el gestor en cada INSERT

Antes de aceptar la fila, el gestor verifica las tres reglas de integridad de la lección 02-01:

INSERT INTO sucursales (nombre, direccion) VALUES ('Norte', 'Otra dirección');
ERROR:  llave duplicada viola restricción de unicidad «uq_sucursales_nombre»
DETALLE:  Ya existe la llave (nombre)=(Norte).
INSERT INTO socios (nombre, apellidos, fecha_alta, sucursal_id, activo)
VALUES ('Prueba', 'Prueba', '2026-01-01', 99, TRUE);
ERROR:  inserción o actualización en la tabla «socios» viola la llave foránea «fk_socios_sucursal»
DETALLE:  La llave (sucursal_id)=(99) no está presente en la tabla «sucursales».

Esto es exactamente lo que la hoja de cálculo de BiblioRed no hacía. Aquí el error no es una molestia: es el sistema haciendo su trabajo.

  1. Entregable: el juego de datos de BiblioRed

Este es el script de carga. Ejecútalo entero y en este orden (las hijas necesitan a las padres) y guárdalo como datos_biblioredb.sql.

Un matiz sobre los identificadores que verás:

  • En sucursales, autores, ejemplares, prestamos y reservas dejamos que el gestor los genere.
  • En socios y libros los imponemos: BiblioRed está migrando desde la hoja de cálculo y quiere conservar los números de carné (Marta Alsina es la socia 14 desde 2021) y las signaturas del catálogo antiguo (331 es "El mapa del tiempo"). Es un caso realísimo, y en el apartado 3 veremos el efecto secundario que provoca.
-- ============================================================
--  BiblioRed - Juego de datos de prueba (todos ficticios)
--  Módulo 2, lección 02-03. Dialecto: PostgreSQL
-- ============================================================

-- PASO 0: partir de cero. Los ejemplos del apartado anterior ya
-- insertaron dos sucursales y un autor; los borramos para que el
-- script sea la única fuente de datos. El orden es el inverso al
-- de dependencias: primero las hijas, después las padres.
DELETE FROM reservas;
DELETE FROM prestamos;
DELETE FROM ejemplares;
DELETE FROM libros;
DELETE FROM socios;
DELETE FROM autores;
DELETE FROM sucursales;
ALTER TABLE sucursales ALTER COLUMN sucursal_id RESTART WITH 1;
ALTER TABLE autores    ALTER COLUMN autor_id    RESTART WITH 1;
ALTER TABLE ejemplares ALTER COLUMN ejemplar_id RESTART WITH 1;
ALTER TABLE prestamos  ALTER COLUMN prestamo_id RESTART WITH 1;
ALTER TABLE reservas   ALTER COLUMN reserva_id  RESTART WITH 1;

-- SUCURSALES: las cuatro bibliotecas de la red de Vallmar.
-- Identificadores 1..4 generados por el gestor: Centro=1, Norte=2, Sur=3, Este=4.
INSERT INTO sucursales (nombre, direccion, telefono, fecha_apertura) VALUES
    ('Centro', 'Plaza Mayor 1',        '900 100 001', '1998-04-12'),
    ('Norte',  'Avenida del Parque 45', '900 100 002', '2005-09-30'),
    ('Sur',    'Calle Olivar 12',       '900 100 003', '2011-02-18'),
    ('Este',   'Ronda del Puerto 8',    NULL,          '2019-06-25');

-- AUTORES: identificadores 1..8 generados por el gestor.
-- Marina Escolá (8) aún no tiene ninguna obra en el fondo:
-- nos servirá para practicar los LEFT JOIN de la próxima lección.
INSERT INTO autores (nombre, apellidos, nacionalidad, anio_nacimiento) VALUES
    ('Félix J.', 'Palma',      'española',   1968),
    ('Ken',      'Follett',    'británica',  1949),
    ('Irene',    'Valcárcel',  'española',   1975),
    ('Óscar',    'Barreda',    'española',   1981),
    ('Nadia',    'Sorrentino', 'italiana',   1970),
    ('Hugo',     'Lemos',      'portuguesa', 1958),
    ('Clara',    'Ordóñez',    'española',   1988),
    ('Marina',   'Escolá',     'española',   1992);

-- SOCIOS: números de carné heredados de la hoja de cálculo (11..20).
-- Pau Miralles (19) no facilitó correo: su email queda a NULL.
-- Ramón Etxebarri (13) está de baja: activo = FALSE.
INSERT INTO socios (socio_id, nombre, apellidos, email, fecha_alta, sucursal_id, activo) VALUES
    (11, 'Álvaro', 'Ferrán',    '[email protected]',   '2018-01-22', 1, TRUE),
    (12, 'Sonia',  'Quiroga',   '[email protected]',   '2019-05-03', 3, TRUE),
    (13, 'Ramón',  'Etxebarri', '[email protected]', '2020-11-14', 1, FALSE),
    (14, 'Marta',  'Alsina',    '[email protected]',    '2021-03-08', 2, TRUE),
    (15, 'Iván',   'Pereda',    '[email protected]',     '2021-09-19', 2, TRUE),
    (16, 'Nuria',  'Bastos',    '[email protected]',    '2022-01-30', 1, TRUE),
    (17, 'Diego',  'Salom',     '[email protected]',     '2023-02-11', 3, TRUE),
    (18, 'Lucía',  'Vendrell',  '[email protected]',  '2023-07-05', 4, TRUE),
    (19, 'Pau',    'Miralles',  NULL,                          '2024-04-16', 2, TRUE),
    (20, 'Elena',  'Roig',      '[email protected]',      '2024-10-01', 1, TRUE);

-- LIBROS: signaturas heredadas (331..339).
-- El 339 es una publicación municipal antigua: SIN ISBN y SIN autor
-- catalogado. Es la prueba viviente de por qué el ISBN no podía ser
-- clave primaria (lección 02-01).
INSERT INTO libros (libro_id, isbn, titulo, autor_id, editorial, anio_publicacion, idioma) VALUES
    (331, '9788401339097', 'El mapa del tiempo',          1,    'Editorial Andana',      2008, 'es'),
    (332, '9788401337208', 'Los pilares de la Tierra',    2,    'Editorial Andana',      1989, 'es'),
    (333, '9788412007701', 'La casa de las mareas',       3,    'Ediciones Marlia',      2015, 'es'),
    (334, '9788412007702', 'Álgebra para impacientes',    4,    'Prensa Técnica Norte',  2019, 'es'),
    (335, '9788412007703', 'Cuadernos de Ravena',         5,    'Ediciones Marlia',      2012, 'es'),
    (336, '9788412007704', 'El invierno de los pájaros',  6,    'Editorial Andana',      2021, 'es'),
    (337, '9788412007705', 'Rutas del delta',             7,    'Ediciones Marlia',      2017, 'ca'),
    (338, '9788412007706', 'Manual de jardinería urbana', 4,    'Prensa Técnica Norte',  2023, 'es'),
    (339, NULL,            'Memoria del Ensanche (1904)', NULL, 'Ayuntamiento de Vallmar', 1904, 'es');

-- EJEMPLARES: los objetos físicos. Identificadores 1..15 generados
-- en el orden de esta lista; EJ-3081 será el ejemplar_id 1.
INSERT INTO ejemplares (codigo, libro_id, sucursal_id, estado, fecha_adquisicion) VALUES
    ('EJ-3081', 331, 2, 'prestado',   '2019-03-14'),
    ('EJ-3082', 331, 1, 'disponible', '2019-03-14'),
    ('EJ-3083', 331, 3, 'disponible', '2021-06-01'),
    ('EJ-3084', 332, 1, 'prestado',   '2015-11-20'),
    ('EJ-3085', 332, 2, 'disponible', '2015-11-20'),
    ('EJ-3086', 333, 1, 'prestado',   '2016-02-09'),
    ('EJ-3087', 334, 2, 'disponible', '2020-01-15'),
    ('EJ-3088', 334, 4, 'reparacion', '2020-01-15'),
    ('EJ-3089', 335, 3, 'disponible', '2013-05-04'),
    ('EJ-3090', 336, 2, 'prestado',   '2022-04-27'),
    ('EJ-3091', 336, 1, 'disponible', '2022-04-27'),
    ('EJ-3092', 337, 4, 'disponible', '2018-10-02'),
    ('EJ-3093', 338, 1, 'disponible', '2023-09-11'),
    ('EJ-3094', 338, 3, 'baja',       '2023-09-11'),
    ('EJ-3095', 339, 1, 'disponible', '2003-01-15');

-- PRESTAMOS: ocho cerrados y cuatro abiertos.
-- fecha_devolucion NULL = préstamo todavía en curso.
-- Los cuatro préstamos abiertos corresponden a los cuatro ejemplares
-- cuyo estado es 'prestado' (1, 4, 6 y 10). Coherencia total.
INSERT INTO prestamos
    (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista, fecha_devolucion, recargo) VALUES
    (14,  2, '2026-03-02', '2026-03-23', '2026-03-19', 0.00),
    (15,  1, '2026-03-05', '2026-03-26', '2026-04-02', 1.40),
    (16,  5, '2026-03-11', '2026-04-01', '2026-03-30', 0.00),
    (14,  7, '2026-04-06', '2026-04-27', '2026-04-25', 0.00),
    (18,  9, '2026-04-12', '2026-05-03', '2026-05-10', 1.40),
    (16,  4, '2026-05-04', '2026-05-25', '2026-05-22', 0.00),
    (11,  1, '2026-05-08', '2026-05-29', '2026-05-27', 0.00),
    (12, 12, '2026-05-19', '2026-06-09', '2026-06-30', 4.20),
    (14,  1, '2026-07-14', '2026-08-04', NULL,         NULL),
    (15,  4, '2026-07-18', '2026-08-08', NULL,         NULL),
    (17,  6, '2026-07-21', '2026-08-11', NULL,         NULL),
    (19, 10, '2026-07-25', '2026-08-15', NULL,         NULL);

-- RESERVAS: apuntan al LIBRO (la obra), no al ejemplar.
INSERT INTO reservas (socio_id, libro_id, fecha_reserva, fecha_expiracion, estado) VALUES
    (16, 331, '2026-07-20', '2026-08-10', 'activa'),
    (18, 332, '2026-07-22', '2026-08-12', 'activa'),
    (14, 336, '2026-06-30', '2026-07-20', 'atendida'),
    (11, 333, '2026-07-05', '2026-07-25', 'cancelada'),
    (15, 331, '2026-07-28', '2026-08-18', 'activa');

Comprobación de la carga

SELECT 'sucursales' AS tabla, COUNT(*) AS filas FROM sucursales
UNION ALL SELECT 'autores',    COUNT(*) FROM autores
UNION ALL SELECT 'socios',     COUNT(*) FROM socios
UNION ALL SELECT 'libros',     COUNT(*) FROM libros
UNION ALL SELECT 'ejemplares', COUNT(*) FROM ejemplares
UNION ALL SELECT 'prestamos',  COUNT(*) FROM prestamos
UNION ALL SELECT 'reservas',   COUNT(*) FROM reservas;

(COUNT y UNION ALL son de las lecciones 02-05 y 02-04; aquí solo lo usamos como recuento de control.)

tabla filas
sucursales 4
autores 8
socios 10
libros 9
ejemplares 15
prestamos 12
reservas 5

Y la correspondencia entre códigos e identificadores de ejemplar, que necesitarás para entender los préstamos:

SELECT ejemplar_id, codigo, libro_id FROM ejemplares ORDER BY ejemplar_id LIMIT 4;
ejemplar_id codigo libro_id
1 EJ-3081 331
2 EJ-3082 331
3 EJ-3083 331
4 EJ-3084 332

Si tus recuentos coinciden, tienes el mismo juego de datos que el resto del curso.

Notas para SQLite

Tres cambios en el script:

PRAGMA foreign_keys = ON;   -- ¡en cada sesión!
  • TRUE/FALSE en socios.activo1/0.
  • Las líneas ALTER TABLE ... RESTART WITH del paso 0 no existen en SQLite: elimínalas. Con INTEGER PRIMARY KEY sin AUTOINCREMENT, el siguiente identificador se calcula solo a partir del máximo existente.
  • Las fechas se escriben igual ('2026-03-02'), pero se guardan como texto. Mientras uses el formato ISO, las comparaciones y ordenaciones seguirán funcionando, porque el orden alfabético de AAAA-MM-DD coincide con el cronológico. Es exactamente la razón por la que ese formato es el bueno.

  1. Reajustar los generadores de identificadores

Aquí llega el efecto secundario prometido. En socios y libros insertamos identificadores explícitos, pero el generador de la columna no se ha enterado: sigue apuntando al 1. Comprobémoslo dando de alta un socio sin indicar su identificador:

INSERT INTO socios (nombre, apellidos, fecha_alta, sucursal_id, activo)
VALUES ('Socio', 'Deprueba', '2026-08-01', 1, TRUE)
RETURNING socio_id;
 socio_id
----------
        1
(1 fila)

El nuevo socio ha recibido el 1, cuando los carnés de BiblioRed empiezan en el 11. Nada ha fallado, pero el desajuste ya está sembrado: los diez siguientes de alta se llevarían el 2, el 3… y el undécimo chocaría:

ERROR:  llave duplicada viola restricción de unicidad «pk_socios»
DETALLE:  Ya existe la llave (socio_id)=(11).

Es uno de los errores más desconcertantes para quien empieza, porque aparece semanas después de la carga y sin relación aparente con ella. La causa siempre es la misma: se han insertado claves a mano sin resincronizar el generador. Borremos el socio de prueba y arreglémoslo:

DELETE FROM socios WHERE apellidos = 'Deprueba';

La solución en PostgreSQL:

ALTER TABLE socios ALTER COLUMN socio_id RESTART WITH 21;
ALTER TABLE libros ALTER COLUMN libro_id RESTART WITH 340;

O, de forma automática y sin tener que mirar el máximo a ojo:

SELECT setval(pg_get_serial_sequence('socios', 'socio_id'),
              (SELECT MAX(socio_id) FROM socios));

En SQLite el problema no existe si la clave se declaró como INTEGER PRIMARY KEY sin AUTOINCREMENT: el siguiente valor se calcula como el máximo actual más uno, así que se ajusta solo.

  1. SELECT: proyección, alias y DISTINCT

SELECT es la instrucción más usada de SQL y la que más lecciones ocupa en este curso. Su forma mínima:

SELECT columna1, columna2 FROM tabla;

Proyección: elegir columnas

SELECT nombre, apellidos, fecha_alta FROM socios;
nombre apellidos fecha_alta
Álvaro Ferrán 2018-01-22
Sonia Quiroga 2019-05-03
Ramón Etxebarri 2020-11-14
Marta Alsina 2021-03-08
Iván Pereda 2021-09-19
Nuria Bastos 2022-01-30
Diego Salom 2023-02-11
Lucía Vendrell 2023-07-05
Pau Miralles 2024-04-16
Elena Roig 2024-10-01

Esto es la proyección π del álgebra relacional. Recuerda que el orden en que aparecen las filas no está garantizado sin ORDER BY: aquí salen en orden de inserción porque la tabla es pequeña y recién cargada, pero no cuentes con ello.

SELECT *: cómodo y peligroso

SELECT * FROM sucursales;
sucursal_id nombre direccion telefono fecha_apertura
1 Centro Plaza Mayor 1 900 100 001 1998-04-12
2 Norte Avenida del Parque 45 900 100 002 2005-09-30
3 Sur Calle Olivar 12 900 100 003 2011-02-18
4 Este Ronda del Puerto 8 (NULL) 2019-06-25

* significa "todas las columnas". Está perfecto para explorar en la consola, pero no lo uses en código de aplicación: traes datos que no necesitas por la red, y si mañana alguien añade una columna, tu programa recibe algo que no esperaba. En un script guardado, columnas explícitas.

Observa que el NULL del teléfono de Este aparece como celda vacía en psql. Puedes hacerlo visible:

biblioredb=> \pset null '(nulo)'

Alias con AS

Un alias renombra una columna en el resultado, sin tocar la tabla. Es el operador de renombrado ρ del álgebra relacional.

SELECT codigo    AS etiqueta,
       estado    AS situacion,
       fecha_adquisicion AS "fecha de compra"
FROM ejemplares
WHERE sucursal_id = 4;
etiqueta situacion fecha de compra
EJ-3088 reparacion 2020-01-15
EJ-3092 disponible 2018-10-02

Puntos a retener:

  • AS es opcional (codigo etiqueta funciona igual), pero escribirlo hace el SQL mucho más legible.
  • Para que un alias lleve espacios, mayúsculas o tildes hay que ponerlo entre comillas dobles: es un identificador. Aquí sí es aceptable, porque el alias solo vive en la salida.

También se pueden calcular columnas nuevas:

SELECT nombre || ' ' || apellidos AS nombre_completo,
       fecha_alta
FROM socios
WHERE sucursal_id = 2;
nombre_completo fecha_alta
Marta Alsina 2021-03-08
Iván Pereda 2021-09-19
Pau Miralles 2024-04-16

|| es el operador estándar de concatenación (funciona en PostgreSQL y SQLite; MySQL usa CONCAT()).

Cuidado con NULL en las concatenaciones: 'Pau' || NULL da NULL, no 'Pau'. Si apellidos pudiera ser nulo, el nombre completo desaparecería entero. En 02-05 veremos COALESCE, que resuelve exactamente esto.

DISTINCT: eliminar duplicados

SELECT estado FROM ejemplares;

Devuelve 15 filas con muchas repeticiones. Con DISTINCT:

SELECT DISTINCT estado FROM ejemplares ORDER BY estado;
estado
baja
disponible
prestado
reparacion

DISTINCT se aplica a la combinación completa de columnas seleccionadas, no a la primera:

SELECT DISTINCT sucursal_id, estado
FROM ejemplares
ORDER BY sucursal_id, estado;
sucursal_id estado
1 disponible
1 prestado
2 disponible
2 prestado
3 baja
3 disponible
4 disponible
4 reparacion

Son las ocho combinaciones distintas que existen, de las 15 filas originales.

Recuerda de la lección 02-01: en el álgebra relacional la proyección siempre elimina duplicados; en SQL hay que pedirlo. DISTINCT obliga al gestor a ordenar o construir una tabla de dispersión, así que tiene coste: no lo pongas "por si acaso".

  1. WHERE: filtrar filas

WHERE es la selección σ del álgebra: se queda con las filas cuya condición sea VERDADERO (recuerda: DESCONOCIDO no pasa).

Operadores de comparación

Operador Significado
= Igual
<> o != Distinto
<, >, <=, >= Menor, mayor, menor o igual, mayor o igual
SELECT titulo, anio_publicacion
FROM libros
WHERE anio_publicacion > 2015
ORDER BY anio_publicacion;
titulo anio_publicacion
Rutas del delta 2017
Álgebra para impacientes 2019
El invierno de los pájaros 2021
Manual de jardinería urbana 2023

Las comparaciones funcionan también sobre texto (orden alfabético según la configuración regional) y sobre fechas:

SELECT nombre, apellidos, fecha_alta
FROM socios
WHERE fecha_alta >= '2023-01-01'
ORDER BY fecha_alta;
nombre apellidos fecha_alta
Diego Salom 2023-02-11
Lucía Vendrell 2023-07-05
Pau Miralles 2024-04-16
Elena Roig 2024-10-01

Operadores lógicos: AND, OR, NOT

SELECT codigo, estado, sucursal_id
FROM ejemplares
WHERE sucursal_id = 1 AND estado = 'disponible';
codigo estado sucursal_id
EJ-3082 disponible 1
EJ-3091 disponible 1
EJ-3093 disponible 1
EJ-3095 disponible 1

AND tiene más precedencia que OR, igual que la multiplicación sobre la suma. Esto provoca errores silenciosos:

-- Lo que se quería: los ejemplares de las sucursales 1 o 2 que estén prestados
-- Lo que hace: los de la sucursal 1 (en cualquier estado)
--              MÁS los de la 2 que estén prestados
SELECT codigo, sucursal_id, estado FROM ejemplares
WHERE sucursal_id = 1 OR sucursal_id = 2 AND estado = 'prestado';

Devuelve 7 filas: los seis de la sucursal 1 más EJ-3081. Con paréntesis:

SELECT codigo, sucursal_id, estado FROM ejemplares
WHERE (sucursal_id = 1 OR sucursal_id = 2) AND estado = 'prestado';
codigo sucursal_id estado
EJ-3081 2 prestado
EJ-3084 1 prestado
EJ-3086 1 prestado

Consejo: usa paréntesis siempre que mezcles AND y OR, aunque sepas la precedencia. Quien lea tu consulta dentro de un año lo agradecerá.

BETWEEN: rangos

SELECT titulo, anio_publicacion
FROM libros
WHERE anio_publicacion BETWEEN 2010 AND 2019
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

BETWEEN a AND b es azúcar sintáctico de >= a AND <= b: ambos extremos están incluidos. Es la fuente de un error clásico con fechas y horas: BETWEEN '2026-03-01' AND '2026-03-31' sobre una columna TIMESTAMP deja fuera todo lo ocurrido el día 31 después de medianoche, porque 2026-03-31 09:00 es mayor que 2026-03-31 00:00. Con columnas DATE como las de BiblioRed no hay problema.

IN: pertenencia a una lista

SELECT codigo, estado
FROM ejemplares
WHERE estado IN ('reparacion', 'baja');
codigo estado
EJ-3088 reparacion
EJ-3094 baja

IN equivale a una cadena de OR, pero es mucho más legible. Existe también NOT IN:

SELECT codigo, estado FROM ejemplares WHERE estado NOT IN ('disponible', 'prestado');

Mismo resultado que antes.

Aviso importante sobre NOT IN y NULL: si la lista contiene un NULL, NOT IN no devuelve ninguna fila, por la lógica de tres valores de la lección 02-01. x NOT IN (1, 2, NULL) equivale a x <> 1 AND x <> 2 AND x <> NULL, y ese último término es siempre DESCONOCIDO. Con listas escritas a mano no ocurre; con listas que vienen de una subconsulta (lección 02-04) es una trampa habitual.

LIKE e ILIKE: búsqueda de patrones en texto

Dos comodines:

Comodín Significa
% Cero o más caracteres cualesquiera
_ Exactamente un carácter cualquiera
SELECT titulo FROM libros WHERE titulo LIKE '%del%';
titulo
El mapa del tiempo
Rutas del delta
Memoria del Ensanche (1904)

LIKE distingue mayúsculas y minúsculas en PostgreSQL:

SELECT titulo FROM libros WHERE titulo LIKE 'el %';
(0 filas)

Ningún título empieza por el en minúscula. Para ignorar la caja, PostgreSQL ofrece ILIKE (la I es de insensitive), que no es estándar pero es comodísimo:

SELECT titulo FROM libros WHERE titulo ILIKE 'el %';
titulo
El mapa del tiempo
El invierno de los pájaros

Alternativa portable, válida también en SQLite:

SELECT titulo FROM libros WHERE LOWER(titulo) LIKE 'el %';

En SQLite, LIKE ya es insensible a mayúsculas para caracteres ASCII (pero no para vocales acentuadas), y no existe ILIKE. Es una de las diferencias que más sorprenden al portar consultas.

Ejemplo con _:

SELECT codigo FROM ejemplares WHERE codigo LIKE 'EJ-308_';
codigo
EJ-3080…EJ-3089 → los diez de la primera decena

Concretamente devuelve EJ-3081 a EJ-3089: nueve filas, porque EJ-3080 no existe.

IS NULL: la ausencia de valor

Aquí volvemos a la trampa que anunciamos en 02-01.

-- MAL: cero filas, siempre, sin error
SELECT socio_id, nombre FROM socios WHERE email = NULL;
(0 filas)
-- BIEN
SELECT socio_id, nombre, apellidos FROM socios WHERE email IS NULL;
socio_id nombre apellidos
19 Pau Miralles

Y su complementario, que en BiblioRed tiene un significado muy concreto:

-- Los préstamos todavía abiertos: no hay fecha de devolución
SELECT prestamo_id, socio_id, ejemplar_id, fecha_devolucion_prevista
FROM prestamos
WHERE fecha_devolucion IS NULL
ORDER BY fecha_devolucion_prevista;
prestamo_id socio_id ejemplar_id fecha_devolucion_prevista
9 14 1 2026-08-04
10 15 4 2026-08-08
11 17 6 2026-08-11
12 19 10 2026-08-15

Cuatro préstamos abiertos, que coinciden exactamente con los cuatro ejemplares en estado prestado. La base de datos es coherente.

Y una comprobación de la otra trampa de los NULL, la de las desigualdades:

SELECT COUNT(*) FROM prestamos WHERE recargo <> 0;   -- 3
SELECT COUNT(*) FROM prestamos WHERE recargo = 0;    -- 5
-- 3 + 5 = 8, no 12: faltan los cuatro préstamos con recargo NULL

Ninguna de las dos consultas ve los NULL. Si quieres los préstamos "sin recargo pendiente", tienes que decirlo: WHERE recargo = 0 OR recargo IS NULL.

  1. ORDER BY: ordenar el resultado

Como una relación es un conjunto y no tiene orden, ORDER BY es la única forma de garantizarlo.

SELECT titulo, anio_publicacion FROM libros ORDER BY anio_publicacion DESC;
titulo anio_publicacion
Manual de jardinería urbana 2023
El invierno de los pájaros 2021
Álgebra para impacientes 2019
Rutas del delta 2017
La casa de las mareas 2015
Cuadernos de Ravena 2012
El mapa del tiempo 2008
Los pilares de la Tierra 1989
Memoria del Ensanche (1904) 1904

ASC (ascendente) es el valor por defecto; DESC invierte.

Varios criterios

Se ordena por el primero y, dentro de los empates, por el segundo:

SELECT sucursal_id, apellidos, nombre
FROM socios
ORDER BY sucursal_id ASC, apellidos ASC;
sucursal_id apellidos nombre
1 Bastos Nuria
1 Etxebarri Ramón
1 Ferrán Álvaro
1 Roig Elena
2 Alsina Marta
2 Miralles Pau
2 Pereda Iván
3 Quiroga Sonia
3 Salom Diego
4 Vendrell Lucía

Cada criterio lleva su propio ASC/DESC: ORDER BY sucursal_id ASC, fecha_alta DESC es perfectamente válido.

NULLS FIRST y NULLS LAST

¿Dónde va un NULL al ordenar? El estándar deja libertad, y PostgreSQL los coloca al final en ASC y al principio en DESC (equivale a tratarlos como el valor más grande). SQLite hace lo contrario: los pone al principio en ASC.

Como no queremos depender del gestor, se especifica:

SELECT prestamo_id, fecha_devolucion
FROM prestamos
ORDER BY fecha_devolucion DESC NULLS LAST, prestamo_id;
prestamo_id fecha_devolucion
8 2026-06-30
7 2026-05-27
6 2026-05-22
5 2026-05-10
4 2026-04-25
2 2026-04-02
3 2026-03-30
1 2026-03-19
9 (NULL)
10 (NULL)
11 (NULL)
12 (NULL)

Fíjate en el segundo criterio, prestamo_id: sin él, el orden entre los cuatro NULL sería arbitrario. Cuando el orden importe de verdad, termina siempre por un criterio que desempate sin ambigüedad, típicamente la clave primaria.

NULLS FIRST/NULLS LAST es sintaxis estándar y funciona en PostgreSQL; SQLite la admite desde la versión 3.30.

Ordenar por alias o por posición

SELECT nombre || ' ' || apellidos AS nombre_completo FROM socios ORDER BY nombre_completo;
SELECT nombre, apellidos FROM socios ORDER BY 2;   -- por la 2.ª columna: apellidos

Ordenar por alias es legítimo y legible. Ordenar por número de posición funciona, pero es frágil: si alguien reordena la lista del SELECT, la consulta cambia de sentido en silencio. Evítalo.

Ordenación de texto y acentos

ORDER BY titulo sitúa "Álgebra para impacientes" en primer lugar si la base de datos usa una configuración regional española, porque Á se ordena junto a A. Con la configuración C (byte a byte), Á iría después de la Z. No es un fallo: es la colación. Puedes ver la tuya con SHOW lc_collate; en PostgreSQL.

  1. LIMIT y OFFSET: paginación

SELECT titulo FROM libros ORDER BY titulo LIMIT 3;
titulo
Álgebra para impacientes
Cuadernos de Ravena
El invierno de los pájaros

OFFSET salta filas antes de empezar a contar:

SELECT titulo FROM libros ORDER BY titulo LIMIT 3 OFFSET 3;
titulo
El mapa del tiempo
La casa de las mareas
Los pilares de la Tierra

Esta es la mecánica de la paginación de cualquier catálogo web: la página N se obtiene con LIMIT tamaño OFFSET (N-1) * tamaño.

Dos advertencias:

  1. LIMIT sin ORDER BY no tiene sentido. "Dame 3 filas de las 9" sin decir cuáles significa "dame 3 cualesquiera", y pueden ser distintas en cada ejecución. Peor todavía: si paginas sin ordenar, la misma fila puede salir en la página 1 y en la 3, y otra no salir nunca.
  2. OFFSET grande es lento. Para llegar a la fila 100.000 el gestor tiene que producir y descartar las 100.000 anteriores. En catálogos grandes se usan técnicas de paginación por clave; es tema de rendimiento (lección 06-03).

La sintaxis estándar es OFFSET 3 ROWS FETCH FIRST 3 ROWS ONLY, más verbosa y menos usada. LIMIT/OFFSET funciona en PostgreSQL, SQLite y MySQL.

  1. UPDATE: modificar filas existentes

UPDATE tabla SET columna1 = valor1, columna2 = valor2 WHERE condición;

Antes de empezar: los ejemplos de este apartado y del siguiente modifican el juego de datos que acabamos de cargar, y las lecciones 02-04 y 02-05 lo dan por bueno. Al final de cada ejemplo incluimos la instrucción que deshace el cambio. Ejecútala.

El hábito que salva bases de datos

Antes de cualquier UPDATE o DELETE, ejecuta la misma condición con un SELECT. Si el SELECT devuelve las filas que esperabas, la modificación también las tocará a ellas.

-- Paso 1: comprobar el alcance
SELECT socio_id, nombre, apellidos, email FROM socios WHERE socio_id = 19;
socio_id nombre apellidos email
19 Pau Miralles (NULL)
-- Paso 2: ahora sí, modificar
UPDATE socios SET email = '[email protected]' WHERE socio_id = 19;
UPDATE 1

UPDATE 1 confirma que se ha tocado exactamente una fila. Si ves un número mayor del esperado, algo ha ido mal, y en PostgreSQL, si estás dentro de una transacción, todavía estás a tiempo (lección 06-01).

-- Paso 3: deshacer, para que el juego de datos siga como estaba
UPDATE socios SET email = NULL WHERE socio_id = 19;

Actualizar varias columnas y usar el valor anterior

Un UPDATE puede calcular el valor nuevo a partir del actual:

SELECT prestamo_id, recargo FROM prestamos WHERE prestamo_id = 8;   -- 4.20

UPDATE prestamos
SET recargo = recargo + 0.50
WHERE prestamo_id = 8;
UPDATE 1
SELECT prestamo_id, recargo FROM prestamos WHERE prestamo_id = 8;
prestamo_id recargo
8 4.70
-- Deshacer
UPDATE prestamos SET recargo = 4.20 WHERE prestamo_id = 8;

Importante: recargo + 0.50 sobre un NULL da NULL. Si hubiéramos ejecutado ese UPDATE sin WHERE, los cuatro préstamos abiertos habrían pasado de NULL a… NULL, y los otros ocho habrían subido de precio. Silenciosamente.

Un caso realista con dos instrucciones

Cuando Marta Alsina devuelve EJ-3081, hay que tocar dos tablas:

-- 1) Cerrar el préstamo
UPDATE prestamos
SET fecha_devolucion = '2026-08-01', recargo = 0.00
WHERE prestamo_id = 9;

-- 2) Liberar el ejemplar
UPDATE ejemplares
SET estado = 'disponible'
WHERE codigo = 'EJ-3081';
UPDATE 1
UPDATE 1

Esto plantea una pregunta incómoda: ¿qué pasa si la primera instrucción funciona y la segunda falla? La base quedaría en un estado inconsistente: un préstamo cerrado y un ejemplar marcado como prestado. La respuesta es la transacción, y es el contenido de la lección 06-01. De momento, deshacemos:

UPDATE prestamos SET fecha_devolucion = NULL, recargo = NULL WHERE prestamo_id = 9;
UPDATE ejemplares SET estado = 'prestado' WHERE codigo = 'EJ-3081';

El WHERE olvidado

-- CATASTRÓFICO: pone el mismo correo a los diez socios
UPDATE socios SET email = '[email protected]';
UPDATE 10

Y además violaría uq_socios_email, así que en este caso concreto la restricción nos salvaría. No siempre habrá una restricción que te salve. Un UPDATE ejemplares SET estado = 'baja'; habría dado de baja los 40.000 ejemplares de BiblioRed sin protestar.

Costumbres que evitan el desastre:

  1. Escribir el WHERE antes que el SET. Empieza tecleando UPDATE tabla WHERE ... y luego vuelve a insertar el SET. Suena raro, funciona.
  2. Probar con SELECT primero. Siempre.
  3. Trabajar dentro de una transacción en las operaciones delicadas (06-01).
  4. En psql, activar \set ON_ERROR_STOP on en los scripts para que se detengan al primer error.

  1. DELETE: eliminar filas

DELETE FROM tabla WHERE condición;

Vamos a crear una fila de usar y tirar para no estropear el juego de datos:

-- Alta de prueba
INSERT INTO socios (socio_id, nombre, apellidos, email, fecha_alta, sucursal_id, activo)
VALUES (99, 'Bruno', 'Temporal', '[email protected]', '2026-08-01', 1, TRUE);
INSERT 0 1
-- Paso 1: comprobar
SELECT socio_id, nombre, apellidos FROM socios WHERE socio_id = 99;
socio_id nombre apellidos
99 Bruno Temporal
-- Paso 2: borrar
DELETE FROM socios WHERE socio_id = 99;
DELETE 1

En PostgreSQL puedes usar RETURNING también aquí, para dejar constancia de lo que se llevó por delante:

DELETE FROM socios WHERE socio_id = 99 RETURNING socio_id, nombre, apellidos;

DELETE y la integridad referencial

Intenta borrar un socio que tiene préstamos:

DELETE FROM socios WHERE socio_id = 14;
ERROR:  update or delete on table "socios" violates foreign key constraint
        "fk_prestamos_socio" on table "prestamos"
DETALLE:  La llave (socio_id)=(14) todavía es referida desde la tabla «prestamos».

El gestor te está protegiendo. Si permitiera el borrado, los tres préstamos de Marta Alsina quedarían huérfanos: apuntando a un socio que ya no existe. Ese comportamiento —y sus alternativas, como el borrado en cascada— es el tema íntegro de la lección 02-06.

En SQLite, si olvidaste PRAGMA foreign_keys = ON, ese DELETE funcionará y te dejará tres filas huérfanas sin decir nada. Es exactamente el escenario que la lección 02-06 te enseñará a detectar y limpiar.

El WHERE olvidado, versión definitiva

DELETE FROM prestamos;
DELETE 12

Sin WHERE, DELETE vacía la tabla entera, sin preguntar y sin papelera. Si te ocurre fuera de una transacción, la única salida es la copia de seguridad (lección 06-04). Las cuatro costumbres del apartado anterior valen aquí con más razón todavía.

  1. TRUNCATE: vaciar una tabla entera

Cuando lo que quieres de verdad es vaciar una tabla completa, existe una instrucción específica:

TRUNCATE TABLE prestamos;
DELETE FROM tabla TRUNCATE TABLE tabla
Sublenguaje DML DDL
Admite WHERE No
Velocidad en tablas grandes Lenta: borra fila a fila Casi instantánea
Registra cada fila borrada No
Reinicia el generador de identificadores No Opcionalmente (RESTART IDENTITY)
Se puede deshacer con ROLLBACK En PostgreSQL sí; en otros gestores no
-- Vaciar y reiniciar el contador, arrastrando las tablas dependientes
TRUNCATE TABLE prestamos, reservas RESTART IDENTITY;

SQLite no tiene TRUNCATE; usa DELETE FROM tabla sin WHERE, que internamente optimiza.

No ejecutes ningún TRUNCATE sobre tu biblioredb: perderías el juego de datos que acabas de cargar. Si ya lo has hecho, vuelve a ejecutar el script del apartado 2.

Errores Comunes y Consejos

  • UPDATE o DELETE sin WHERE. El error más caro que se comete con SQL. Prueba siempre la condición con un SELECT antes.
  • Escribir 'NULL' en lugar de NULL. Con comillas es una cadena de texto de cuatro letras; sin comillas, la ausencia de valor. Una columna que mezcla ambas cosas es una columna arruinada.
  • Usar = NULL o <> NULL. Cero filas, sin error. Siempre IS NULL / IS NOT NULL.
  • Olvidar que <> valor excluye los NULL. WHERE recargo <> 0 no devuelve los préstamos con recargo nulo. Si los quieres, añade OR recargo IS NULL.
  • Mezclar AND y OR sin paréntesis. AND gana. Pon paréntesis siempre.
  • INSERT sin lista de columnas. Se rompe en cuanto alguien toca el esquema, a veces en silencio.
  • Insertar identificadores explícitos y no resincronizar la secuencia. Provoca errores de clave duplicada mucho después, cuando ya nadie recuerda la carga inicial.
  • LIMIT sin ORDER BY. El resultado no es reproducible y la paginación puede repetir u omitir filas.
  • Fiarse de que LIKE distingue mayúsculas. En PostgreSQL sí, en SQLite (ASCII) no, en MySQL depende de la colación. Si necesitas certeza, LOWER(columna) LIKE ....
  • Consejo: en psql, \pset null '(nulo)' hace visibles los NULL y \x on muestra los resultados en vertical, ideal para filas anchas.
  • Consejo: guarda todo el SQL en ficheros (esquema_biblioredb.sql, datos_biblioredb.sql) y ejecútalos con \i fichero.sql en psql o .read fichero.sql en sqlite3. Poder reconstruir la base en diez segundos te dará libertad para experimentar sin miedo.

Ejercicios

Todos se resuelven sobre una sola tabla. Escribe la consulta antes de mirar la solución y compara los resultados.

Ejercicio 1: Consultas de catálogo

  1. Los títulos y editoriales de los libros publicados antes de 2010, del más antiguo al más reciente.
  2. Los libros sin ISBN registrado.
  3. Las editoriales distintas del fondo, en orden alfabético.
  4. Los títulos que contienen la palabra "mar" en cualquier posición, sin distinguir mayúsculas.
  5. Los códigos de los ejemplares de la sucursal 2 que no estén disponibles.
  6. Los tres libros más recientes del fondo.

Ejercicio 2: Consultas sobre socios y préstamos

  1. Los socios dados de alta en 2021 o 2022, con nombre y apellidos en una sola columna llamada socio.
  2. Los socios que no están activos.
  3. Los préstamos devueltos con retraso (la fecha real de devolución es posterior a la prevista), ordenados por días de retraso… o, si no sabes calcular la diferencia todavía, simplemente por fecha de préstamo.
  4. Los préstamos abiertos cuya devolución estaba prevista antes del 10 de agosto de 2026.
  5. La segunda página de un listado de préstamos ordenado por fecha de préstamo descendente, con 5 préstamos por página.
  6. Los préstamos cuyo recargo no es cero, incluidos aquellos en los que el recargo aún no se ha calculado.

Ejercicio 3: Modificación segura

Escribe las instrucciones para cada operación, precedidas del SELECT de comprobación y seguidas de la instrucción que deshace el cambio.

  1. El ejemplar EJ-3088 sale del taller: pásalo a disponible.
  2. Corrige la editorial del libro 337: pasa de "Ediciones Marlia" a "Ediciones Marlia SL".
  3. Da de alta una socia nueva, Berta Colomer ([email protected]), en la sucursal Sur, con fecha de hoy (usa '2026-08-02'), activa, dejando que el gestor le asigne el identificador. Después, bórrala.
  4. La reserva 4 estaba cancelada por error: reactívala.

Soluciones

Solución 1

-- 1
SELECT titulo, editorial, anio_publicacion
FROM libros
WHERE anio_publicacion < 2010
ORDER BY anio_publicacion ASC;
titulo editorial anio_publicacion
Memoria del Ensanche (1904) Ayuntamiento de Vallmar 1904
Los pilares de la Tierra Editorial Andana 1989
El mapa del tiempo Editorial Andana 2008
-- 2  (¡IS NULL, no = NULL!)
SELECT libro_id, titulo FROM libros WHERE isbn IS NULL;
libro_id titulo
339 Memoria del Ensanche (1904)
-- 3
SELECT DISTINCT editorial FROM libros ORDER BY editorial;
editorial
Ayuntamiento de Vallmar
Ediciones Marlia
Editorial Andana
Prensa Técnica Norte
-- 4  (ILIKE en PostgreSQL; LOWER(titulo) LIKE '%mar%' es la forma portable)
SELECT titulo FROM libros WHERE titulo ILIKE '%mar%';
titulo
El mapa del tiempo
La casa de las mareas

Observa que "El mapa" entra por mapa y "las mareas" por mareas: LIKE busca subcadenas, no palabras completas.

-- 5
SELECT codigo, estado FROM ejemplares
WHERE sucursal_id = 2 AND estado <> 'disponible';
codigo estado
EJ-3081 prestado
EJ-3090 prestado
-- 6
SELECT titulo, anio_publicacion FROM libros ORDER BY anio_publicacion DESC LIMIT 3;
titulo anio_publicacion
Manual de jardinería urbana 2023
El invierno de los pájaros 2021
Álgebra para impacientes 2019

Solución 2

-- 1
SELECT nombre || ' ' || apellidos AS socio, fecha_alta
FROM socios
WHERE fecha_alta BETWEEN '2021-01-01' AND '2022-12-31'
ORDER BY fecha_alta;
socio fecha_alta
Marta Alsina 2021-03-08
Iván Pereda 2021-09-19
Nuria Bastos 2022-01-30
-- 2
SELECT socio_id, nombre, apellidos FROM socios WHERE activo = FALSE;
-- también vale: WHERE NOT activo
socio_id nombre apellidos
13 Ramón Etxebarri
-- 3
SELECT prestamo_id, fecha_prestamo, fecha_devolucion_prevista, fecha_devolucion, recargo
FROM prestamos
WHERE fecha_devolucion > fecha_devolucion_prevista
ORDER BY fecha_prestamo;
prestamo_id fecha_prestamo fecha_devolucion_prevista fecha_devolucion recargo
2 2026-03-05 2026-03-26 2026-04-02 1.40
5 2026-04-12 2026-05-03 2026-05-10 1.40
8 2026-05-19 2026-06-09 2026-06-30 4.20

Nótese que la condición compara dos columnas de la misma fila, algo perfectamente legítimo. Y que los cuatro préstamos abiertos no aparecen: NULL > fecha es DESCONOCIDO. En PostgreSQL, fecha_devolucion - fecha_devolucion_prevista daría directamente los días de retraso (7, 7 y 21).

-- 4
SELECT prestamo_id, socio_id, fecha_devolucion_prevista
FROM prestamos
WHERE fecha_devolucion IS NULL
  AND fecha_devolucion_prevista < '2026-08-10'
ORDER BY fecha_devolucion_prevista;
prestamo_id socio_id fecha_devolucion_prevista
9 14 2026-08-04
10 15 2026-08-08
-- 5  Página 2 con 5 por página: OFFSET (2-1) * 5 = 5
SELECT prestamo_id, fecha_prestamo
FROM prestamos
ORDER BY fecha_prestamo DESC, prestamo_id DESC
LIMIT 5 OFFSET 5;
prestamo_id fecha_prestamo
7 2026-05-08
4 2026-04-06
3 2026-03-11
2 2026-03-05
1 2026-03-02

(El segundo criterio prestamo_id DESC garantiza que la paginación sea estable aunque hubiera fechas repetidas.)

-- 6  La clave: NULL no es "distinto de cero", es "no se sabe"
SELECT prestamo_id, recargo
FROM prestamos
WHERE recargo <> 0 OR recargo IS NULL
ORDER BY prestamo_id;
prestamo_id recargo
2 1.40
5 1.40
8 4.20
9 (NULL)
10 (NULL)
11 (NULL)
12 (NULL)

Sin el OR recargo IS NULL habrías obtenido solo tres filas y habrías perdido de vista los cuatro préstamos en curso.

Solución 3

-- 1
SELECT ejemplar_id, codigo, estado FROM ejemplares WHERE codigo = 'EJ-3088';   -- reparacion
UPDATE ejemplares SET estado = 'disponible' WHERE codigo = 'EJ-3088';          -- UPDATE 1
UPDATE ejemplares SET estado = 'reparacion' WHERE codigo = 'EJ-3088';          -- deshacer

-- 2
SELECT libro_id, titulo, editorial FROM libros WHERE libro_id = 337;
UPDATE libros SET editorial = 'Ediciones Marlia SL' WHERE libro_id = 337;      -- UPDATE 1
UPDATE libros SET editorial = 'Ediciones Marlia'    WHERE libro_id = 337;      -- deshacer

-- 3  Sin dar socio_id: lo genera la columna IDENTITY (será el 21 si
--    resincronizaste la secuencia en el apartado 3; si no, dará error de
--    clave duplicada, que es justamente la lección de aquel apartado).
INSERT INTO socios (nombre, apellidos, email, fecha_alta, sucursal_id, activo)
VALUES ('Berta', 'Colomer', '[email protected]', '2026-08-02', 3, TRUE)
RETURNING socio_id;
--  socio_id
-- ----------
--        21

SELECT socio_id, nombre, apellidos FROM socios WHERE apellidos = 'Colomer';
DELETE FROM socios WHERE apellidos = 'Colomer';                                -- DELETE 1

-- 4
SELECT reserva_id, estado FROM reservas WHERE reserva_id = 4;                  -- cancelada
UPDATE reservas SET estado = 'activa'    WHERE reserva_id = 4;                 -- UPDATE 1
UPDATE reservas SET estado = 'cancelada' WHERE reserva_id = 4;                 -- deshacer

Comprobación final de que todo ha quedado como estaba:

SELECT COUNT(*) FROM socios;      -- 10
SELECT COUNT(*) FROM prestamos;   -- 12
SELECT estado FROM reservas WHERE reserva_id = 4;   -- cancelada

Conclusión

Esta lección ha llevado a BiblioRed de un esquema vacío a una base de datos viva y consultable:

  • INSERT con lista de columnas explícita (siempre), en una o varias filas, con NULL sin comillas y con RETURNING en PostgreSQL para conocer los identificadores generados. Y la comprobación automática de unicidad y de claves ajenas que la hoja de cálculo nunca tuvo.
  • El juego de datos de BiblioRed: 4 sucursales, 8 autores, 10 socios, 9 libros, 15 ejemplares, 12 préstamos y 5 reservas, todo ficticio y coherente. Es el material de trabajo del resto del curso.
  • El efecto secundario de insertar claves a mano y cómo resincronizar el generador con RESTART WITH o setval.
  • SELECT con proyección de columnas, alias AS (con comillas dobles cuando llevan espacios), concatenación con || y DISTINCT aplicado a la combinación completa de columnas.
  • WHERE con comparaciones, AND/OR/NOT y su precedencia, BETWEEN (extremos incluidos), IN y NOT IN (con su trampa con NULL), LIKE/ILIKE con % y _, e IS NULL, que es la única forma de preguntar por la ausencia.
  • ORDER BY con varios criterios, ASC/DESC, NULLS FIRST/NULLS LAST y la importancia de un criterio de desempate.
  • LIMIT/OFFSET para paginar, siempre con ORDER BY.
  • UPDATE y DELETE con la disciplina del SELECT previo, la lectura del número de filas afectadas y el respeto que impone el WHERE olvidado; más TRUNCATE como vaciado rápido de nivel DDL.

Hasta aquí, cada consulta ha mirado una sola tabla, y eso deja preguntas sin responder: no sabemos quién tiene EJ-3081, ni qué título es el más prestado, ni qué socios no han cogido nunca nada. Los datos están repartidos a propósito —esa es la esencia del modelo relacional— y ahora toca recomponerlos.

En la lección 02-04, Consultas Multitabla: JOIN y Subconsultas, aprenderás a unir socios con prestamos, prestamos con ejemplares, ejemplares con libros y libros con autores, todo en una sola consulta; a usar LEFT JOIN para encontrar lo que no tiene correspondencia; y a anidar consultas dentro de consultas. Es donde SQL empieza a resultar realmente potente.

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