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
IN: la alternativa legible a una cadena deORNOT INy su comportamiento traicionero conNULLINcon números, texto y fechasBETWEEN: azúcar sintáctico para un rango cerradoNOT BETWEEN- La trampa de
BETWEENcon fechas y horas BETWEENcon texto y la colaciónBETWEEN SYMMETRICIN,ORyBETWEEN: rendimiento y legibilidad- Errores Comunes y Consejos
- Ejercicios
- Conclusión
IN: la alternativa legible a una cadena de OR
IN: la alternativa legible a una cadena de OREn 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:
INtambié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 retomaremosINcon todo su alcance.
NOT IN y su comportamiento traicionero con NULL
NOT IN y su comportamiento traicionero con NULLNOT IN es la negación, y su equivalencia es igual de mecánica:
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 sí 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;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:
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:
| 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 INsiempre que la lista pueda contener unNULL. Si no puedes garantizarlo, usaNOT EXISTSo 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.
IN con números, texto y fechas
IN con números, texto y fechasIN 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:
Un error explícito, que es lo mejor que puede pasar.
BETWEEN: azúcar sintáctico para un rango cerrado
BETWEEN: azúcar sintáctico para un rango cerradoBETWEEN sustituye dos comparaciones por una:
Dos consecuencias inmediatas de esa equivalencia:
- Incluye los dos extremos. Es un intervalo cerrado,
[a, b]. - El orden importa.
BETWEEN 10 AND 5no da error: devuelve cero filas, porque exigex >= 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.
NOT BETWEEN
NOT BETWEENSELECT 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.
- La trampa de
BETWEEN con fechas y horas
BETWEEN con fechas y horasAquí 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:
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 conDATE, conTIMESTAMPy conTIMESTAMPTZ; 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.
BETWEEN con texto y la colación
BETWEEN con texto y la colaciónBETWEEN 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ónC(binaria por bytes), va después de toda laZ. La misma consulta puede devolver conjuntos distintos en dos servidores. - Sigue distinguiendo mayúsculas, con la misma advertencia de 04-01.
BETWEEN SYMMETRIC
BETWEEN SYMMETRICComo 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 SYMMETRICes 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.
IN, OR y BETWEEN: rendimiento y legibilidad
IN, OR y BETWEEN: rendimiento y legibilidadLa 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:
- ¿Los valores son consecutivos? Entonces es un rango:
BETWEEN. Escribircategoria_id IN (1,2,3,4,5,6)sobre las seis categorías es peor que no filtrar. - ¿Los valores son arbitrarios? Entonces es una lista:
IN.estado IN ('pagado','pendiente')no es un rango de nada. - ¿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). UnaINcon dos mil literales generada desde código es un síntoma de que falta unJOIN.
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 INcon unNULLen 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 INyNOT BETWEENexcluyen 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 usaBETWEEN SYMMETRIC. - Suponer que
BETWEENexcluye algún extremo. Los incluye los dos. Si queríasprecio >= 10 AND precio < 20,BETWEEN 10 AND 20no es equivalente. - Usar
BETWEENsobre una columnaTIMESTAMP. 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 unBETWEEN 1 AND 100mal 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
BETWEENpara números y texto, y>= … AND < …para fechas. Es una regla simple, uniforme y que nunca te traiciona. - Consejo: pon la lista de
INen 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):
- Los pedidos pagados con tarjeta o PayPal, mostrando
id,cliente_id,fecha_pedido,estado,metodo_pagoygastos_envio. ¿Cuántas filas? - 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).
- Escríbelo con
BETWEENy con la convención del curso, y comprueba que dan lo mismo. - Explica qué ocurriría con cada una de las dos versiones si
fecha_pedidofueraTIMESTAMPy 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);- Explica qué pretendía y qué está pasando realmente.
- Corrígela para que devuelva "los clientes referidos por alguien que no sea Lucía (1) ni Carlos (2)".
- 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 |
Sí |
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:
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:
INes una cadena deORyNOT INes una cadena deAND. 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 INcon unNULLen la lista devuelve exactamente cero filas, porqueTRUE AND UNKNOWNesUNKNOWNy elWHEREsolo deja pasarTRUE.INcon el mismoNULLfunciona sin problema, porqueTRUE OR UNKNOWNesTRUE. Ante la duda: anti-join oNOT EXISTS.BETWEENes>= a AND <= b: intervalo cerrado, con ambos extremos incluidos, y sensible al orden de los límites.NOT BETWEENes< a OR > b, con la misma ceguera ante los nulos.- Con columnas
DATE,BETWEENes seguro; conTIMESTAMPpierde el último día. La convención>= inicio AND < finfunciona 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'oLIKE. BETWEEN SYMMETRICreordena los extremos automáticamente y solo existe en PostgreSQL.- Entre
INyORno 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 →JOINo 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
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
