Hay dos formas de escribir el mismo filtro: una que se entiende de un vistazo y otra que hay que descifrar. IN y BETWEEN existen para que puedas elegir la primera. IN sustituye una cadena de OR por una lista; BETWEEN sustituye dos comparaciones por un rango. Ninguno de los dos añade capacidad de expresión —todo lo que hacen podría escribirse sin ellos—, pero ambos reducen el ruido y, con ello, la probabilidad de equivocarse.

Dicho lo cual, esta lección tiene dos avisos que valen más que la sintaxis. El primero es el error más caro de todo SQL: NOT IN con una lista que contiene un NULL no devuelve "el resto de las filas", devuelve cero filas, sin error y sin aviso. El segundo es más sutil pero igual de frecuente: BETWEEN incluye ambos extremos, y eso convierte cualquier rango de fechas sobre una columna con hora en una fuente silenciosa de datos perdidos.

Contenido

  1. IN: la alternativa legible a una cadena de OR
  2. NOT IN y su comportamiento traicionero con NULL
  3. IN con números, texto y fechas
  4. BETWEEN: azúcar sintáctico para un rango cerrado
  5. NOT BETWEEN
  6. La trampa de BETWEEN con fechas y horas
  7. BETWEEN con texto y la colación
  8. BETWEEN SYMMETRIC
  9. IN, OR y BETWEEN: rendimiento y legibilidad
  10. Errores Comunes y Consejos
  11. Ejercicios
  12. Conclusión

  1. IN: la alternativa legible a una cadena de OR

En 02-03 escribiste esto para localizar los pedidos que aún no se habían enviado:

SELECT id, cliente_id, fecha_pedido, estado
FROM pedidos
WHERE estado = 'pagado'
   OR estado = 'pendiente'
ORDER BY id;

Con dos valores es legible. Con cinco, deja de serlo, y además tienes que recordar los paréntesis en cuanto aparezca un AND (la trampa de precedencia de 02-03). IN resuelve las dos cosas:

SELECT id,
       cliente_id,
       fecha_pedido,
       estado,
       metodo_pago
FROM pedidos
WHERE estado IN ('pendiente', 'pagado', 'enviado')
ORDER BY id;
id cliente_id fecha_pedido estado metodo_pago
16 4 2025-12-19 enviado tarjeta
17 7 2026-01-13 enviado paypal
18 5 2026-01-27 pagado transferencia
19 6 2026-02-09 pagado tarjeta
20 9 2026-02-21 pendiente contrareembolso

5 filas: los pedidos "vivos" de TiendaVerde, los que todavía tienen trabajo pendiente detrás. Los 14 entregados y el cancelado quedan fuera.

La equivalencia, demostrada

x IN (a, b, c) es exactamente x = a OR x = b OR x = c. No es una aproximación: es la definición del operador en el estándar, y PostgreSQL lo reescribe internamente así.

-- Estas dos consultas son la misma
WHERE estado IN ('pendiente', 'pagado', 'enviado')
WHERE estado = 'pendiente' OR estado = 'pagado' OR estado = 'enviado'

De esa equivalencia salen tres propiedades que conviene tener presentes:

Propiedad Consecuencia
El orden de la lista no importa IN ('a','b') e IN ('b','a') son idénticos
Los valores repetidos no molestan IN ('a','a','b') da lo mismo que IN ('a','b')
IN () con lista vacía no es válido PostgreSQL da error de sintaxis. Ojo al generar la lista desde código

Ese último punto es un clásico de las aplicaciones: si construyes la consulta concatenando los ids seleccionados por el usuario y el usuario no selecciona ninguno, generas IN () y la consulta revienta. La solución habitual es no generar la condición cuando la lista está vacía, o usar IN (NULL)… que, como verás en la sección siguiente, tiene sus propias sorpresas.

Un segundo ejemplo: productos de varias categorías

SELECT id,
       nombre,
       categoria_id,
       precio
FROM productos
WHERE categoria_id IN (1, 2, 4)
ORDER BY categoria_id, id;
id nombre categoria_id precio
1 Aceite de oliva virgen extra 500 ml 1 12.50
2 Arroz integral ecológico 1 kg 1 3.90
3 Miel de azahar cruda 500 g 1 9.75
4 Pasta de espelta 500 g 1 2.80
5 Tomate triturado ecológico 400 g 1 1.95
6 Crema facial de aloe vera 50 ml 2 18.90
7 Champú sólido de romero 80 g 2 8.40
8 Aceite corporal de almendras 200 ml 2 14.25
9 Bálsamo labial de caléndula 15 ml 2 4.60
14 Infusión de manzanilla ecológica 20 uds 4 3.25
15 Té verde matcha ceremonial 30 g 4 22.00
16 Kombucha de jengibre 750 ml 4 4.95
17 Zumo de naranja prensado en frío 1 L 4 5.40

13 filas: 5 de Alimentación, 4 de Cosmética natural y 4 de Bebidas.

Nota: IN también admite una subconsulta en lugar de una lista literal —WHERE categoria_id IN (SELECT id FROM categorias WHERE ...)—, y esa es de hecho su forma más potente. Pero las subconsultas son el módulo 7: aquí nos quedamos en las listas explícitas, y en 07-01 retomaremos IN con todo su alcance.

  1. NOT IN y su comportamiento traicionero con NULL

NOT IN es la negación, y su equivalencia es igual de mecánica:

x NOT IN (a, b)   ≡   NOT (x = a OR x = b)   ≡   x <> a AND x <> b

Fíjate bien en esa última forma, porque en ella está el problema.

El caso normal

SELECT id,
       cliente_id,
       fecha_pedido,
       estado
FROM pedidos
WHERE estado NOT IN ('entregado', 'cancelado')
ORDER BY id;
id cliente_id fecha_pedido estado
16 4 2025-12-19 enviado
17 7 2026-01-13 enviado
18 5 2026-01-27 pagado
19 6 2026-02-09 pagado
20 9 2026-02-21 pendiente

5 filas. Funciona perfectamente. Y funciona porque estado es NOT NULL y porque la lista no contiene ningún NULL.

El caso que arruina informes

Ahora la misma idea sobre pedidos.empleado_id, que admite nulos. Queremos los pedidos que no gestionaron ni Óscar (4) ni Laia (5):

SELECT id, cliente_id, empleado_id, estado
FROM pedidos
WHERE empleado_id NOT IN (4, 5)
ORDER BY id;
id cliente_id empleado_id estado
14 12 6 entregado
20 9 6 pendiente

2 filas. Los diez pedidos web con empleado_id nulo han desaparecido: es el mismo síntoma de <> que viste en 02-03, ni más ni menos.

Pero ahora viene el caso grave. Imagina que la lista la genera tu aplicación a partir de una consulta previa, y que uno de los valores que trae es NULL:

-- ⚠️ INCORRECTA: la lista contiene un NULL
SELECT id, cliente_id, empleado_id, estado
FROM pedidos
WHERE empleado_id NOT IN (4, 5, NULL)
ORDER BY id;
(0 filas)

Cero filas. No dos, no dieciocho: cero. Sin error, sin aviso, sin nada. Y no es un caso raro: es lo que ocurre cada vez que alguien escribe WHERE id NOT IN (SELECT columna_que_admite_nulos FROM otra_tabla), que es una construcción habitualísima.

Por qué ocurre

Desarrolla la equivalencia:

empleado_id NOT IN (4, 5, NULL)
≡ empleado_id <> 4 AND empleado_id <> 5 AND empleado_id <> NULL

El tercer factor, empleado_id <> NULL, nunca es verdadero. Da UNKNOWN para cualquier valor de empleado_id, incluso para el pedido 14 cuyo empleado es el 6. Y en la lógica de SQL:

Fila <> 4 <> 5 <> NULL AND de los tres ¿Pasa el WHERE?
Pedido 14 (empleado_id = 6) true true UNKNOWN UNKNOWN No
Pedido 2 (empleado_id = 4) false true UNKNOWN false No
Pedido 1 (empleado_id = NULL) UNKNOWN UNKNOWN UNKNOWN UNKNOWN No

TRUE AND TRUE AND UNKNOWN da UNKNOWN, y el WHERE solo deja pasar TRUE (regla de 02-03, sección 1). Ninguna fila puede sobrevivir: es matemáticamente imposible que la condición sea verdadera mientras haya un NULL en la lista.

flowchart TD
    A["WHERE x NOT IN (4, 5, NULL)"] --> B["x <> 4 AND x <> 5 AND x <> NULL"]
    B --> C["El tercer factor SIEMPRE es UNKNOWN"]
    C --> D["TRUE AND TRUE AND UNKNOWN = UNKNOWN"]
    D --> E["❌ WHERE solo deja pasar TRUE<br/>→ 0 filas, siempre"]

Y por qué IN sí funciona

La asimetría es lo más desconcertante del asunto. IN con la misma lista sí devuelve filas:

SELECT id, cliente_id, empleado_id
FROM pedidos
WHERE empleado_id IN (4, 5, NULL)
ORDER BY id;
id cliente_id empleado_id
2 2 4
4 4 5
6 5 4
8 7 5
10 9 4
12 10 5
16 4 4
18 5 5

8 filas, exactamente las mismas que daría IN (4, 5). La razón está en la tabla de verdad de OR: TRUE OR UNKNOWN es TRUE, mientras que TRUE AND UNKNOWN es UNKNOWN. Con IN (que es una cadena de OR) el NULL es inofensivo; con NOT IN (que es una cadena de AND) lo destruye todo.

Operador Se expande a Efecto de un NULL en la lista
IN Cadena de OR Ninguno. TRUE OR UNKNOWN = TRUE
NOT IN Cadena de AND Devastador. TRUE AND UNKNOWN = UNKNOWN → 0 filas

Cómo protegerse

Cuatro medidas, de la más simple a la más robusta:

Medida Cómo
Excluir los nulos de la lista Si la lista viene de una consulta, añade WHERE columna IS NOT NULL
Usar el anti-join de 03-03 LEFT JOIN ... WHERE derecha.id IS NULL es inmune a los nulos
Usar NOT EXISTS Semánticamente correcto ante nulos. Módulo 7
Declarar la columna NOT NULL La solución de raíz, si el modelo lo permite (módulo 5)

La regla que hay que grabar: desconfía de NOT IN siempre que la lista pueda contener un NULL. Si no puedes garantizarlo, usa NOT EXISTS o un anti-join. Este error ha llegado a producción en todas las empresas del mundo al menos una vez, y su síntoma —un informe que de pronto aparece vacío— siempre se atribuye primero a los datos y no a la consulta.

La lección 04-03 desarrolla la lógica de tres valores que hay debajo de todo esto, con las tablas de verdad completas.

  1. IN con números, texto y fechas

IN no está limitado a enteros. Funciona con cualquier tipo comparable, siempre que todos los elementos de la lista sean del mismo tipo que la expresión de la izquierda.

Con texto, el uso más frecuente después de los identificadores:

SELECT id,
       nombre,
       apellidos,
       ciudad,
       pais
FROM clientes
WHERE pais IN ('Portugal', 'Francia')
ORDER BY pais, id;
id nombre apellidos ciudad pais
9 Camille Dubois Lyon Francia
10 Julien Moreau París Francia
7 Sofia Moreira Costa Lisboa Portugal
8 Tiago Almeida Nunes Oporto Portugal

4 filas: la clientela internacional. Recuerda de 02-03 que el texto distingue mayúsculas: IN ('portugal') daría cero filas.

Con fechas, para días concretos y no para rangos:

SELECT id,
       cliente_id,
       fecha_pedido,
       estado
FROM pedidos
WHERE fecha_pedido IN (DATE '2025-03-04', DATE '2025-12-02', DATE '2026-02-21')
ORDER BY id;
id cliente_id fecha_pedido estado
1 1 2025-03-04 entregado
15 1 2025-12-02 entregado
20 9 2026-02-21 pendiente

3 filas. El prefijo DATE delante del literal no es obligatorio (PostgreSQL convierte la cadena por contexto), pero documenta el tipo y evita ambigüedades. Para días consecutivos, IN es la herramienta equivocada: ahí toca un rango, que es la segunda mitad de la lección.

Con listas mixtas de tipos, PostgreSQL intenta convertir y a veces falla:

SELECT id FROM productos WHERE categoria_id IN (1, 'dos');
ERROR:  invalid input syntax for type integer: "dos"

Un error explícito, que es lo mejor que puede pasar.

  1. BETWEEN: azúcar sintáctico para un rango cerrado

BETWEEN sustituye dos comparaciones por una:

x BETWEEN a AND b   ≡   x >= a AND x <= b

Dos consecuencias inmediatas de esa equivalencia:

  1. Incluye los dos extremos. Es un intervalo cerrado, [a, b].
  2. El orden importa. BETWEEN 10 AND 5 no da error: devuelve cero filas, porque exige x >= 10 AND x <= 5, que es imposible.

Veámoslo con un rango elegido para que ambos extremos existan en los datos:

SELECT id,
       nombre,
       categoria_id,
       precio
FROM productos
WHERE precio BETWEEN 3.50 AND 5.50
ORDER BY precio, id;
id nombre categoria_id precio
18 Cepillo de dientes de bambú 5 3.50
2 Arroz integral ecológico 1 kg 1 3.90
9 Bálsamo labial de caléndula 15 ml 2 4.60
16 Kombucha de jengibre 750 ml 4 4.95
17 Zumo de naranja prensado en frío 1 L 4 5.40
11 Estropajo vegetal de luffa (pack 3) 3 5.50

6 filas, y las dos que interesan son la primera y la última: el cepillo de bambú cuesta exactamente 3,50 € y el estropajo de luffa exactamente 5,50 €, y ambos aparecen. Si BETWEEN fuera abierto, esta consulta devolvería 4 filas.

La forma larga da lo mismo, letra por letra:

SELECT id, nombre, categoria_id, precio
FROM productos
WHERE precio >= 3.50
  AND precio <= 5.50
ORDER BY precio, id;

Idénticas 6 filas. BETWEEN no es más rápido ni más lento: el planificador de PostgreSQL lo expande a las dos comparaciones antes de decidir el plan. La ganancia es de legibilidad y de no repetir el nombre de la columna, lo que evita el error clásico de escribir WHERE precio >= 3.50 AND coste <= 5.50 por descuido.

  1. NOT BETWEEN

x NOT BETWEEN a AND b   ≡   x < a OR x > b
SELECT id,
       nombre,
       precio
FROM productos
WHERE precio NOT BETWEEN 3.50 AND 5.50
ORDER BY precio, id;

Devuelve 14 filas, que con las 6 anteriores suman los 20 productos. Que sumen es la comprobación de siempre —y aquí funciona porque precio es NOT NULL. Si admitiera nulos, esas filas no aparecerían ni en un lado ni en el otro y la cuenta no cerraría; el mecanismo es idéntico al de <> y al de NOT LIKE.

  1. La trampa de BETWEEN con fechas y horas

Aquí está el motivo por el que este curso fijó desde 02-03 la convención >= inicio AND < fin y no BETWEEN.

En TiendaVerde todas las columnas de fecha son DATE: guardan un día de calendario, sin hora. Con DATE, BETWEEN es perfectamente seguro:

SELECT id,
       cliente_id,
       fecha_pedido,
       estado,
       gastos_envio
FROM pedidos
WHERE fecha_pedido BETWEEN DATE '2025-06-01' AND DATE '2025-08-31'
ORDER BY fecha_pedido, id;
id cliente_id fecha_pedido estado gastos_envio
7 6 2025-06-11 entregado 6.50
8 7 2025-06-28 entregado 9.90
9 8 2025-07-15 entregado 9.90
10 9 2025-08-03 entregado 12.50

4 filas: el verano de 2025, los mismos pedidos que en 02-03 obtuviste con >= '2025-06-01' AND < '2025-09-01'.

Qué pasaría si la columna fuera TIMESTAMP

Supón que mañana el equipo decide guardar también la hora del pedido y fecha_pedido pasa a ser TIMESTAMP. Un valor como 2025-08-31 14:20:00 deja de ser "el 31 de agosto" para el motor: es un instante.

Y BETWEEN '2025-06-01' AND '2025-08-31' se convierte en:

fecha_pedido >= 2025-06-01 00:00:00  AND  fecha_pedido <= 2025-08-31 00:00:00

Porque la cadena '2025-08-31' se convierte al instante medianoche del 31 de agosto. Resultado: se pierden todos los pedidos del 31 de agosto salvo los realizados exactamente a las 00:00:00. Un día entero de facturación desaparece del informe, todos los meses, sin que nadie lo note hasta el cierre del trimestre.

Las cuatro escrituras posibles y su veredicto:

Escritura Con DATE Con TIMESTAMP Veredicto
BETWEEN '2025-06-01' AND '2025-08-31' ✅ Correcta ❌ Pierde el último día Frágil
>= '2025-06-01' AND <= '2025-08-31' ✅ Correcta ❌ Idéntico problema Frágil
BETWEEN '2025-06-01' AND '2025-08-31 23:59:59' ✅ Correcta ⚠️ Pierde los microsegundos finales del día Chapuza clásica
>= '2025-06-01' AND < '2025-09-01' ✅ Correcta Correcta La del curso

La tercera fila merece un comentario, porque es la que más se ve en código real. '2025-08-31 23:59:59' deja fuera el intervalo entre 23:59:59.000001 y 23:59:59.999999. Con TIMESTAMP de precisión de microsegundos, eso es casi un segundo de datos perdidos por cada rango. Parece despreciable hasta que cuentas transacciones de un sistema de pagos.

Regla del curso, ahora justificada del todo: los rangos de fechas se escriben >= inicio AND < fin, con el límite superior exclusivo y expresado como el primer instante del periodo siguiente. Funciona con DATE, con TIMESTAMP y con TIMESTAMPTZ; no depende de la precisión del tipo; y no te obliga a saber si el mes tiene 28, 30 o 31 días.

¿Entonces BETWEEN no sirve para fechas? Sí sirve, con dos condiciones: que la columna sea DATE (no TIMESTAMP) y que quien lea la consulta dentro de un año lo siga sabiendo. Como lo segundo no se puede garantizar, la convención uniforme sale más barata. Para números y para texto, en cambio, BETWEEN es la escritura idiomática y no tiene ninguna pega.

  1. BETWEEN con texto y la colación

BETWEEN también funciona con cadenas, comparándolas en el orden que define la colación de la base de datos (lo viste en 02-05):

SELECT id,
       nombre,
       apellidos
FROM clientes
WHERE apellidos BETWEEN 'A' AND 'C'
ORDER BY apellidos, id;
id nombre apellidos
8 Tiago Almeida Nunes
5 Ana Belmonte Roca
13 Núria Bosch Ferrer

3 filas. Y aquí hay una sorpresa que atrapa a casi todo el mundo: Inés Carrasco Vega no aparece, pese a que su apellido empieza por C.

El motivo es que BETWEEN 'A' AND 'C' exige apellidos <= 'C', y 'Carrasco Vega' es mayor que 'C' a secas: comparten el primer carácter y la primera cadena continúa, así que va después en el orden alfabético. El límite superior 'C' solo incluiría a alguien cuyo apellido fuera exactamente "C".

Para "todos los apellidos que empiezan por A, B o C" hay dos escrituras correctas:

-- Opción 1: subir el límite superior a la letra siguiente, exclusivo
WHERE apellidos >= 'A' AND apellidos < 'D'

-- Opción 2: usar LIKE (04-01)
WHERE apellidos LIKE 'A%' OR apellidos LIKE 'B%' OR apellidos LIKE 'C%'

La primera devuelve 4 filas (las tres anteriores más Carrasco Vega) y es el mismo patrón >= inicio AND < fin de las fechas. No es casualidad: es la forma robusta de expresar un rango sobre cualquier tipo ordenado.

Dos avisos más sobre texto:

  • El resultado depende de la colación. En una colación lingüística española, 'á' se ordena junto a 'a'; en la colación C (binaria por bytes), va después de toda la Z. La misma consulta puede devolver conjuntos distintos en dos servidores.
  • Sigue distinguiendo mayúsculas, con la misma advertencia de 04-01.

  1. BETWEEN SYMMETRIC

Como el orden de los extremos importa, BETWEEN 10 AND 5 devuelve cero filas. PostgreSQL ofrece una variante que reordena automáticamente:

SELECT id, nombre, precio
FROM productos
WHERE precio BETWEEN SYMMETRIC 5.50 AND 3.50
ORDER BY precio, id;

Devuelve las mismas 6 filas de la sección 4. BETWEEN SYMMETRIC a AND b equivale a BETWEEN LEAST(a,b) AND GREATEST(a,b).

¿Para qué sirve? Sobre todo cuando los dos extremos son parámetros y no controlas en qué orden llegan: un formulario con dos casillas "precio desde" y "precio hasta" que el usuario rellena al revés. Con SYMMETRIC la consulta devuelve algo sensato en lugar de un resultado vacío.

Nota de dialecto: BETWEEN SYMMETRIC es SQL estándar, pero en la práctica solo PostgreSQL lo implementa. MySQL, SQLite, SQL Server y Oracle no lo reconocen. Si necesitas portabilidad, BETWEEN LEAST(:a, :b) AND GREATEST(:a, :b) hace lo mismo en casi todos ellos.

  1. IN, OR y BETWEEN: rendimiento y legibilidad

La pregunta razonable es si alguna de estas escrituras es más rápida. La respuesta corta: entre IN y OR, no; entre listas y rangos, depende de qué estés expresando.

Escritura Legibilidad Rendimiento Cuándo usarla
x = a OR x = b Baja a partir de 3 valores Idéntico a IN Nunca, salvo con dos valores y ya estar dentro de una condición mayor
x IN (a, b, c) Alta Se reescribe a = ANY(ARRAY[...]); con listas largas PostgreSQL usa una tabla hash Valores discretos que no forman un rango
x >= a AND x <= b Media (repite la columna) Puede usar un índice B-tree por rango Cuando quieras dejar explícito qué extremo se incluye
x BETWEEN a AND b Alta Idéntico al anterior: el planificador lo expande Rangos continuos de números o de texto
x IN (1,2,3,4,5,…,100) Baja Funciona, pero es una lista donde debería haber un rango ⚠️ Señal de que querías BETWEEN 1 AND 100

Tres criterios prácticos para decidir:

  1. ¿Los valores son consecutivos? Entonces es un rango: BETWEEN. Escribir categoria_id IN (1,2,3,4,5,6) sobre las seis categorías es peor que no filtrar.
  2. ¿Los valores son arbitrarios? Entonces es una lista: IN. estado IN ('pagado','pendiente') no es un rango de nada.
  3. ¿La lista es larguísima y sale de otra tabla? Entonces no es ni lo uno ni lo otro: es una subconsulta o un JOIN (módulo 7). Una IN con dos mil literales generada desde código es un síntoma de que falta un JOIN.

Sobre el rendimiento hay un matiz que verás en el módulo 8: PostgreSQL trata IN con lista corta como una serie de comparaciones y, a partir de cierto tamaño, construye una tabla hash. Un IN con miles de elementos puede seguir siendo eficiente, pero el tiempo de análisis de la consulta crece, y esa consulta no se reutiliza bien desde la caché de planes porque cada llamada tiene una lista distinta. Es otra razón para preferir el JOIN cuando la lista viene de datos.

Errores Comunes y Consejos

  • NOT IN con un NULL en la lista. Cero filas, siempre, sin ningún aviso. Es el error más caro de esta lección y probablemente del curso.
  • Olvidar que NOT IN y NOT BETWEEN excluyen las filas nulas de la columna. Igual que <> en 02-03: comprueba que la condición y su negación sumen el total.
  • Escribir BETWEEN 10 AND 5. Cero filas, sin error. El primer extremo debe ser el menor, o usa BETWEEN SYMMETRIC.
  • Suponer que BETWEEN excluye algún extremo. Los incluye los dos. Si querías precio >= 10 AND precio < 20, BETWEEN 10 AND 20 no es equivalente.
  • Usar BETWEEN sobre una columna TIMESTAMP. Pierdes el último día. Usa >= inicio AND < fin.
  • Usar '…23:59:59' como límite superior. Pierdes casi un segundo por rango, y con precisión de microsegundos eso es un agujero real.
  • BETWEEN 'A' AND 'C' esperando todos los apellidos con C. Solo llega hasta la cadena "C" exacta. Usa >= 'A' AND < 'D'.
  • Generar IN () con una lista vacía desde código. Error de sintaxis en tiempo de ejecución. Contempla el caso antes de concatenar.
  • Convertir un rango en una lista. IN (1,2,3,…,100) es un BETWEEN 1 AND 100 mal escrito.
  • Consejo: al escribir NOT IN, di en voz alta "¿puede haber un nulo aquí?". Si la respuesta no es un no rotundo, cambia la estrategia.
  • Consejo: usa BETWEEN para números y texto, y >= … AND < … para fechas. Es una regla simple, uniforme y que nunca te traiciona.
  • Consejo: pon la lista de IN en orden alfabético o numérico aunque no importe para el resultado. Detectar un valor duplicado o ausente en una lista ordenada es trivial; en una desordenada, no.

Ejercicios

Ejercicio 1

Atención al cliente necesita dos listados. Escríbelos usando IN (no OR):

  1. Los pedidos pagados con tarjeta o PayPal, mostrando id, cliente_id, fecha_pedido, estado, metodo_pago y gastos_envio. ¿Cuántas filas?
  2. Los productos de las categorías 3 (Hogar sostenible) y 5 (Higiene personal) que además estén activos, mostrando id, nombre, categoria_id, precio y stock.

Ejercicio 2

Dirección pide el detalle del cuarto trimestre de 2025 (octubre, noviembre y diciembre).

  1. Escríbelo con BETWEEN y con la convención del curso, y comprueba que dan lo mismo.
  2. Explica qué ocurriría con cada una de las dos versiones si fecha_pedido fuera TIMESTAMP y hubiera un pedido registrado el 31 de diciembre de 2025 a las 18:40.

Ejercicio 3

Un compañero te enseña esta consulta y te dice que "no devuelve nada y no entiende por qué":

-- ⚠️ INCORRECTA
SELECT id, nombre, apellidos, referido_por_id
FROM clientes
WHERE referido_por_id NOT IN (1, 2, NULL);
  1. Explica qué pretendía y qué está pasando realmente.
  2. Corrígela para que devuelva "los clientes referidos por alguien que no sea Lucía (1) ni Carlos (2)".
  3. Corrígela para que devuelva "los clientes que no fueron referidos ni por Lucía ni por Carlos", incluyendo los que llegaron por su cuenta. Indica cuántas filas da cada versión.

Soluciones

Solución 1

1.

SELECT id,
       cliente_id,
       fecha_pedido,
       estado,
       metodo_pago,
       gastos_envio
FROM pedidos
WHERE metodo_pago IN ('tarjeta', 'paypal')
ORDER BY id;
id cliente_id fecha_pedido estado metodo_pago gastos_envio
1 1 2025-03-04 entregado tarjeta 4.95
3 3 2025-04-02 entregado tarjeta 4.95
4 4 2025-04-19 entregado paypal 4.95
5 1 2025-05-07 entregado tarjeta 0.00
6 5 2025-05-23 cancelado tarjeta 4.95
8 7 2025-06-28 entregado tarjeta 9.90
9 8 2025-07-15 entregado paypal 9.90
10 9 2025-08-03 entregado tarjeta 12.50
11 2 2025-09-09 entregado tarjeta 0.00
13 11 2025-10-22 entregado tarjeta 4.95
14 12 2025-11-14 entregado paypal 4.95
15 1 2025-12-02 entregado tarjeta 0.00
16 4 2025-12-19 enviado tarjeta 4.95
17 7 2026-01-13 enviado paypal 9.90
19 6 2026-02-09 pagado tarjeta 4.95

15 filas: 11 con tarjeta y 4 con PayPal. Los 5 restantes se pagaron por transferencia (3) o contrareembolso (2).

2.

SELECT id,
       nombre,
       categoria_id,
       precio,
       stock
FROM productos
WHERE categoria_id IN (3, 5)
  AND activo
ORDER BY categoria_id, id;
id nombre categoria_id precio stock
10 Detergente ecológico concentrado 1 L 3 11.20 70
11 Estropajo vegetal de luffa (pack 3) 3 5.50 110
12 Bolsas reutilizables de algodón (pack 5) 3 9.90 85
13 Velas de cera de soja (pack 2) 3 13.75 0
18 Cepillo de dientes de bambú 5 3.50 240
19 Desodorante natural en barra 50 g 5 7.80 75

6 filas: los 4 de Hogar sostenible y los 2 de Higiene personal, todos activos. Fíjate en que el producto 13 aparece pese a tener stock 0: está activo, y activo y stock son cosas distintas. Es el mismo matiz del ejercicio 1 de 02-03.

Solución 2

1. Las dos versiones:

-- Con BETWEEN (válida porque fecha_pedido es DATE)
SELECT id, cliente_id, fecha_pedido, estado, gastos_envio
FROM pedidos
WHERE fecha_pedido BETWEEN DATE '2025-10-01' AND DATE '2025-12-31'
ORDER BY fecha_pedido, id;
-- ✅ Convención del curso
SELECT id, cliente_id, fecha_pedido, estado, gastos_envio
FROM pedidos
WHERE fecha_pedido >= DATE '2025-10-01'
  AND fecha_pedido <  DATE '2026-01-01'
ORDER BY fecha_pedido, id;

Ambas devuelven lo mismo:

id cliente_id fecha_pedido estado gastos_envio
12 10 2025-10-01 entregado 12.50
13 11 2025-10-22 entregado 4.95
14 12 2025-11-14 entregado 4.95
15 1 2025-12-02 entregado 0.00
16 4 2025-12-19 enviado 4.95

5 filas. Observa que el pedido 12 es exactamente del 1 de octubre: entra por el extremo inferior, que ambas versiones incluyen.

2. Con TIMESTAMP y un pedido del 31 de diciembre a las 18:40:

Versión Qué evalúa ¿Incluye el pedido de las 18:40?
BETWEEN '2025-10-01' AND '2025-12-31' <= 2025-12-31 00:00:00 No. Se pierde
>= '2025-10-01' AND < '2026-01-01' < 2026-01-01 00:00:00

La primera versión perdería todos los pedidos del 31 de diciembre posteriores a medianoche, es decir, prácticamente todos. Y como el error no da ningún mensaje, el trimestre se cerraría con una cifra baja que nadie sabría explicar. Este es el motivo exacto de la convención del curso: la segunda versión no depende del tipo de la columna, y por tanto no se rompe el día que alguien cambie ese tipo.

Solución 3

1. Qué pretendía y qué pasa. Pretendía excluir a los clientes referidos por Lucía y por Carlos. Lo que ocurre es que la lista contiene un NULL, así que la condición se expande a:

referido_por_id <> 1 AND referido_por_id <> 2 AND referido_por_id <> NULL

y ese tercer factor es UNKNOWN para toda fila. TRUE AND TRUE AND UNKNOWN = UNKNOWN, que el WHERE descarta. Resultado: 0 filas, garantizadas.

2. Referidos por alguien que no sea Lucía ni Carlos:

-- ✅ CORRECTA
SELECT id,
       nombre,
       apellidos,
       referido_por_id
FROM clientes
WHERE referido_por_id NOT IN (1, 2)
ORDER BY id;
id nombre apellidos referido_por_id
8 Tiago Almeida Nunes 7
10 Julien Moreau 9
11 Elena Navarro Puig 6
13 Núria Bosch Ferrer 5

4 filas. Basta con quitar el NULL de la lista. Los 7 clientes con referido_por_id nulo siguen sin aparecer, y eso es correcto para esta pregunta: quien no fue referido por nadie no fue referido por "alguien que no sea Lucía ni Carlos".

3. Los que no fueron referidos ni por Lucía ni por Carlos, incluidos los que llegaron solos:

-- ✅ CORRECTA
SELECT id,
       nombre,
       apellidos,
       referido_por_id
FROM clientes
WHERE referido_por_id NOT IN (1, 2)
   OR referido_por_id IS NULL
ORDER BY id;
id nombre apellidos referido_por_id
1 Lucía Martínez Soler (null)
4 Javier Ortega Ruiz (null)
6 Pau Llorens Vidal (null)
7 Sofia Moreira Costa (null)
8 Tiago Almeida Nunes 7
9 Camille Dubois (null)
10 Julien Moreau 9
11 Elena Navarro Puig 6
12 Diego Ramos Herrera (null)
13 Núria Bosch Ferrer 5
14 Hugo Iglesias Pardo (null)

11 filas. La comprobación cierra: 4 clientes fueron referidos por Lucía (2, 3, 15) o por Carlos (5) —cuatro en total— y 15 − 4 = 11.

Resumen de las tres versiones:

Versión Filas Pregunta que responde
NOT IN (1, 2, NULL) 0 Ninguna: está rota
NOT IN (1, 2) 4 "Referidos por alguien distinto de Lucía y Carlos"
NOT IN (1, 2) OR ... IS NULL 11 "No referidos por Lucía ni por Carlos"

Las dos últimas son legítimas y responden a preguntas distintas. Elegir mal entre ellas es un error de análisis; la primera, en cambio, es un error de SQL. IS NULL, que aquí ha aparecido como parche, es el tema completo de la lección siguiente.

Conclusión

Ya escribes filtros de listas y de rangos con la escritura correcta:

  • IN es una cadena de OR y NOT IN es una cadena de AND. Todo lo demás se deduce de ahí: el orden no importa, los duplicados dan igual, y la lista vacía no es sintaxis válida.
  • NOT IN con un NULL en la lista devuelve exactamente cero filas, porque TRUE AND UNKNOWN es UNKNOWN y el WHERE solo deja pasar TRUE. IN con el mismo NULL funciona sin problema, porque TRUE OR UNKNOWN es TRUE. Ante la duda: anti-join o NOT EXISTS.
  • BETWEEN es >= a AND <= b: intervalo cerrado, con ambos extremos incluidos, y sensible al orden de los límites. NOT BETWEEN es < a OR > b, con la misma ceguera ante los nulos.
  • Con columnas DATE, BETWEEN es seguro; con TIMESTAMP pierde el último día. La convención >= inicio AND < fin funciona con cualquier tipo y es la que usa el curso.
  • Con texto, BETWEEN 'A' AND 'C' no incluye "Carrasco Vega": el rango cerrado sobre cadenas casi nunca significa lo que parece. Usa >= 'A' AND < 'D' o LIKE.
  • BETWEEN SYMMETRIC reordena los extremos automáticamente y solo existe en PostgreSQL.
  • Entre IN y OR no hay diferencia de rendimiento; la elección entre lista y rango debe seguir a la naturaleza del dato: valores discretos → IN, valores consecutivos → BETWEEN, valores que salen de otra tabla → JOIN o subconsulta (módulo 7).

En la lección siguiente, valores NULL e IS NULL, se salda por fin la deuda. Has visto asomar el mismo mecanismo en el WHERE de 02-03, en los LEFT JOIN de todo el módulo 3 y en el NOT IN de hoy, siempre con la misma promesa de "lo explicaremos en 04-03". Ahí llegan las tablas de verdad completas de la lógica de tres valores, IS NULL e IS NOT NULL, el IS DISTINCT FROM que trata el nulo como un valor más, y la incoherencia del estándar por la que dos NULL no son iguales en un WHERE pero sí se agrupan juntos en un GROUP BY.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados