Capítulo 4 de 23 13 secciones 20 min

Compartir

Tipos de dato y nulos

Donde están los errores más caros y más silenciosos: la división que da cero y el nulo que se come una suma.

Los tipos deciden cómo se guarda y cómo se calcula, y hay dos que te pueden costar dinero. El primero es dividir enteros, porque en SQL 1/2 da 0 y no 0,5. El segundo es NULL, que no es cero ni vacío sino un "no se sabe", y que hace que cualquier cuenta donde participe devuelva NULL sin avisarte. Yo reviso los nulos antes de fiarme de cualquier promedio.

Hola! Bienvenida al capítulo de los errores silenciosos

Todo lo de este capítulo tiene algo en común: no da error. Te devuelve un número, tú lo pones en un reporte, y está mal 😬

Los que dan error se arreglan solos porque los ves. Estos hay que conocerlos.

Y dime si te suena: ¿alguna vez entregaste un reporte y el total no cuadraba por poquito? Ese poquito casi siempre está en este capítulo, y casi siempre tiene forma de NULL 🕳️

Los tipos que vas a usar

Un tipo dice qué se puede guardar en una columna y cómo se opera con ella. Son muchos y en la práctica se reducen a cinco familias.

Texto de largo variable

PostgreSQLVARCHAR(100) o TEXT -- TEXT no tiene penalización
MySQLVARCHAR(100) o TEXT -- TEXT no se puede indexar entero
SQL ServerNVARCHAR(100) o NVARCHAR(MAX) -- la N es para Unicode
SQLiteTEXT -- solo hay uno y acepta cualquier largo

La N de SQL Server importa en Perú: sin ella, VARCHAR puede no guardar bien las tildes y las eñes según la configuración. Usa siempre NVARCHAR.

Dinero y decimales exactos

PostgreSQLNUMERIC(12, 2)
MySQLDECIMAL(12, 2)
SQL ServerDECIMAL(12, 2) o MONEY
SQLiteREAL -- no tiene decimal exacto, y eso importa

Para plata NUNCA uses FLOAT ni REAL: son aproximados y los centavos se van perdiendo. DECIMAL y NUMERIC son exactos. SQLite no tiene ninguno de los dos, así que ahí el dinero se suele guardar en céntimos como entero.

Fecha con hora

PostgreSQLTIMESTAMP o TIMESTAMPTZ -- el TZ guarda la zona horaria
MySQLDATETIME o TIMESTAMP
SQL ServerDATETIME2 -- DATETIME es el viejo y tiene menos precisión
SQLiteTEXT en formato ISO -- no hay tipo fecha

SQLite guarda las fechas como texto, y funciona porque el formato AAAA-MM-DD se ordena igual como texto que como fecha. Por eso el formato internacional no es una manía: es lo que hace que ordene bien.

Y una diferencia de fondo que explica muchas cosas raras de SQLite:

SELECT typeof(id), typeof(nombre), typeof(precio)
FROM productos LIMIT 1;
typeof(id)  typeof(nombre)  typeof(precio)
----------  --------------  --------------
integer     text            real

SQLite tiene tipado dinámico: el tipo lo lleva cada valor, no la columna. Puedes meter un texto en una columna declarada como entero y lo acepta. Los otros tres te lo rechazan de plano.

Eso es cómodo mientras aprendes y es una fuente de desastres en producción, porque un dato mal cargado entra sin que nadie se entere 🙃

La división que devuelve cero

Este es el error más caro del capítulo y no da ningún aviso.

SELECT 1 / 2 AS mitad;
mitad
-----
0

Cero. Porque los dos son enteros, y en SQL entero dividido entero da entero. Se corta la parte decimal y ya.

SELECT 1 * 1.0 / 2 AS mitad,
       CAST(1 AS REAL) / 2 AS tambien_mitad;
mitad  tambien_mitad
-----  -------------
0.5    0.5

Se arregla haciendo que uno de los dos sea decimal, y hay dos maneras: multiplicar por 1.0 o convertir con CAST.

Dividir sin perder los decimales

PostgreSQLSELECT monto::numeric / cantidad -- o CAST(monto AS numeric)
MySQLSELECT monto / cantidad -- MySQL ya devuelve decimal, es el raro
SQL ServerSELECT CAST(monto AS DECIMAL(12,2)) / cantidad
SQLiteSELECT monto * 1.0 / cantidad

MySQL es el único que hace la división decimal por su cuenta. Si aprendes ahí y pasas a otro motor, tus porcentajes se convierten en ceros de golpe.

Dónde muerde de verdad, y lo he visto en informes de empresa:

SELECT canal,
       COUNT(*) AS pedidos,
       SUM(CASE WHEN monto > 1000 THEN 1 ELSE 0 END) / COUNT(*) AS mal,
       SUM(CASE WHEN monto > 1000 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS bien
FROM pedidos
GROUP BY canal;
canal        pedidos  mal  bien
-----------  -------  ---  -----------------
Marketplace  229      0    8.296943231441048
Tienda       206      0    5.825242718446602
Web          234      0    8.11965811965812
WhatsApp     231      0    9.956709956709958

La columna mal da cero en todos los canales. Un porcentaje entero que sale cero siempre es esto 💡

Qué pasa al comparar monto mayor que 500 según el valor: con 800 es verdadero y pasa el WHERE, con 200 es falso y no pasa, y con NULL es desconocido, así que tampoco pasa y además no aparece en el grupo contrario.
La casilla rosada es la que hace daño. Un NULL no está en el grupo de los que pasan ni en el de los que no pasan, así que los dos grupos juntos no suman el total y nadie lo nota.

NULL, que no es cero ni vacío

NULL significa "no se sabe". No es el número cero, no es el texto vacío, no es un espacio. Es la ausencia del dato.

Y de ahí sale su comportamiento, que al principio parece caprichoso y en realidad es coherente.

SELECT 100 + NULL AS suma,
       'Lima' || NULL AS texto,
       NULL = NULL AS son_iguales;
suma  texto  son_iguales
----  -----  -----------

Todo NULL. Y tiene lógica: si no sé cuánto es una cosa, tampoco sé cuánto es esa cosa más cien. Y no puedo afirmar que dos cosas que no conozco sean iguales 🤷

Dónde te muerde en serio

SELECT COUNT(*) AS todas,
       COUNT(monto) AS con_monto,
       COUNT(id_cliente) AS con_cliente
FROM pedidos;
todas  con_monto  con_cliente
-----  ---------  -----------
900    873        876

COUNT(*) cuenta filas y COUNT(columna) cuenta valores no nulos. Esa diferencia, si no la sabes, te cambia cualquier conteo.

Lo mismo con los promedios:

SELECT ROUND(AVG(monto), 2) AS promedio_sql,
       ROUND(SUM(monto) * 1.0 / COUNT(*), 2) AS sobre_todas_las_filas
FROM pedidos;
promedio_sql  sobre_todas_las_filas
------------  ---------------------
610.14        591.84

Dan distinto porque AVG ignora los nulos: divide entre los que tienen valor, no entre todas las filas. Ninguno de los dos está mal; lo que está mal es no saber cuál te dio tu reporte 🔍

COALESCE, que es el estándar

SELECT id, monto, COALESCE(monto, 0) AS con_repuesto
FROM pedidos
WHERE monto IS NULL
LIMIT 3;
id  monto  con_repuesto
--  -----  ------------
22         0
57         0
78         0

Si es nulo, ponme otro valor

PostgreSQLCOALESCE(monto, 0)
MySQLCOALESCE(monto, 0) o IFNULL(monto, 0)
SQL ServerCOALESCE(monto, 0) o ISNULL(monto, 0)
SQLiteCOALESCE(monto, 0) o IFNULL(monto, 0)

COALESCE está en los cuatro y además acepta varios repuestos en cadena: COALESCE(a, b, c, 0) devuelve el primero que no sea nulo. Los atajos de cada motor solo aceptan dos.

Y el aviso importante: rellenar con cero no siempre está bien. Si el monto es nulo porque nadie lo cargó, ponerle cero convierte "no sé" en "no vendió", y eso te baja el promedio con datos inventados. A veces la respuesta correcta es dejarlo nulo y decir cuántos había 💜

Convertir tipos con CAST

SELECT CAST('2026-01-15' AS TEXT) AS fecha_texto,
       CAST('123' AS INTEGER) + 1 AS numero,
       CAST(89.7 AS INTEGER) AS trunca;
fecha_texto  numero  trunca
-----------  ------  ------
2026-01-15   124     89

Fíjate en el último: CAST a entero trunca, no redondea. 89,7 se convierte en 89. Si querías redondear, es ROUND.

SELECT ROUND(89.7) AS redondea, CAST(89.7 AS INTEGER) AS trunca;
redondea  trunca
--------  ------
90.0      89

Convertir a número

PostgreSQLCAST('123' AS INTEGER) o '123'::integer
MySQLCAST('123' AS SIGNED) -- ojo, no acepta INTEGER
SQL ServerCAST('123' AS INT) o CONVERT(INT, '123')
SQLiteCAST('123' AS INTEGER)

CAST es estándar y está en los cuatro; lo que cambia es el nombre del tipo de destino. MySQL pide SIGNED donde los otros piden INTEGER o INT.

Ese ::integer de PostgreSQL es cortito y engancha, así que se copia muchísimo. Mira lo que hace fuera de su casa.

SELECT SUM(monto)::numeric FROM pedidos;
OperationalError: unrecognized token: ":"

unrecognized token quiere decir "no sé ni qué es ese símbolo". Los dos puntos dobles son de PostgreSQL y de nadie más: no están en MySQL, ni en SQL Server, ni en SQLite. CAST(algo AS tipo) es más largo de escribir y te sirve en los cuatro 💛

Verdadero, falso y una tercera cosa

Aquí está la idea que hace que todo lo anterior encaje, y casi nadie la cuenta: en SQL una comparación no devuelve dos valores, devuelve tres 🎲

SELECT COUNT(*) AS total,
       SUM(CASE WHEN monto > 500 THEN 1 ELSE 0 END) AS mayores,
       SUM(CASE WHEN NOT (monto > 500) THEN 1 ELSE 0 END) AS no_mayores
FROM pedidos;
total  mayores  no_mayores
-----  -------  ----------
900    558      315

Mira la suma: 558 más 315 dan 873, y la tabla tiene 900 pedidos. Faltan 27 😳

Son los 27 pedidos sin monto. No están en "mayores que 500" y tampoco en "no mayores que 500", porque para ellos la comparación no es ni verdadera ni falsa: es desconocida. Y el NOT de un desconocido sigue siendo desconocido.

Eso es lo que explica todos los sustos de este capítulo de una vez:

SELECT NULL = NULL       AS son_iguales,
       NULL <> NULL      AS son_distintos,
       NULL IS NULL      AS es_nulo;
son_iguales  son_distintos  es_nulo
-----------  -------------  -------
                            1

Las dos primeras salen vacías, que es como se ve un desconocido. La tercera sale 1 🎯

Por eso IS NULL no es una manía de la sintaxis: es el único operador que contesta verdadero o falso cuando hay un hueco. Todos los demás se encogen de hombros.

Y la regla práctica, que vale para el resto del libro: si una cuenta tiene que cuadrar con el total de filas, cuenta los tres casos, no dos. El tercero es el que se escapa.

El promedio que no es el promedio

Esta es la consecuencia cara, y la vas a ver en un informe algún día 💸

SELECT ROUND(SUM(monto), 2)               AS suma,
       COUNT(*)                            AS filas,
       ROUND(SUM(monto) / COUNT(*), 2)     AS a_mano,
       ROUND(AVG(monto), 2)                AS con_avg
FROM pedidos;
suma       filas  a_mano  con_avg
---------  -----  ------  -------
532653.85  900    591.84  610.14

Dieciocho soles de diferencia por pedido, con los mismos datos y en la misma consulta 😖

Los dos están bien calculados y contestan preguntas distintas. AVG divide entre los pedidos que tienen monto, o sea 873. El de la izquierda divide entre todas las filas, 900, repartiendo la suma también entre los 27 que no aportaron nada.

Cuál está bien depende de lo que preguntaste 🤔

  • 💰 "¿Cuánto vale un pedido típico?" Ahí van los 873, porque un pedido sin monto no es un pedido de cero soles: es un pedido del que no sabemos el monto.
  • 📉 "¿Cuánto facturamos por pedido registrado?" Ahí van los 900, y estás midiendo también lo mal que se registra.

Lo que no vale es no saber cuál de las dos estás calculando, que es lo que pasa cuando uno escribe AVG sin mirar los huecos.

NULLIF, que es COALESCE al revés

COALESCE cambia un nulo por un valor. NULLIF hace lo contrario: cambia un valor por un nulo. Y suena inútil hasta que ves para qué 🔧

SELECT 100 / 0                AS entre_cero,
       100 / NULLIF(0, 0)     AS con_nullif;
entre_cero  con_nullif
----------  ----------

Las dos salen vacías, y aquí hay una trampa que solo se ve sabiendo dónde estás parada 🪤

En SQLite, dividir entre cero devuelve NULL y la consulta sigue como si nada. En PostgreSQL, MySQL en modo estricto y SQL Server, esa misma línea revienta con un error de división por cero y te tira el reporte entero.

O sea que si desarrollas en SQLite y despliegas en Postgres, este es exactamente el tipo de cosa que funciona en tu máquina y falla el día de la presentación 😬

NULLIF(divisor, 0) convierte el cero en nulo antes de dividir, y un nulo no revienta en ningún motor: devuelve nulo. Es la forma portátil de protegerse.

Juntando los dos, que es como se usan de verdad:

SELECT canal,
       COUNT(*)                                                        AS pedidos,
       SUM(CASE WHEN monto IS NULL THEN 1 ELSE 0 END)                  AS sin_monto,
       ROUND(COALESCE(100.0 * SUM(CASE WHEN monto IS NULL THEN 1 ELSE 0 END)
                      / NULLIF(COUNT(*), 0), 0), 1)                    AS pct
FROM pedidos
GROUP BY canal
ORDER BY pct DESC;
canal        pedidos  sin_monto  pct
-----------  -------  ---------  ---
Tienda       206      8          3.9
Marketplace  229      7          3.1
WhatsApp     231      6          2.6
Web          234      6          2.6

Y la respuesta es tranquilizadora: entre el 2,6% y el 3,9% en los cuatro canales. No hay un canal culpable, el registro falla parejo en todos 🤷‍♀️

Eso también es un hallazgo y hay que decirlo. Si un canal tuviera el 20%, la conversación sería con esa gente; como están todos igual, la conversación es sobre el proceso.

Dónde se ponen los nulos al ordenar

SELECT id, monto FROM pedidos ORDER BY monto LIMIT 5;
id   monto
---  -----
22
57
78
83
152

Los nulos salen primero. Y ahora al revés:

SELECT id, monto FROM pedidos ORDER BY monto DESC LIMIT 5;
id   monto
---  -------
25   1399.98
674  1372.34
373  1341.15
382  1324.71
440  1322.73

Al final, así que aquí no se ven 👀

Esto importa más de lo que parece. Si pides "los cinco pedidos más pequeños" y ordenas ascendente, en SQLite te salen cinco filas vacías y ni un monto. El top se lo comen los huecos.

Y no es igual en todos los motores, que es lo peor que puede pasar:

Dónde caen los nulos al ordenar

PostgreSQLORDER BY monto -- nulos al final
MySQLORDER BY monto -- nulos al principio
SQL ServerORDER BY monto -- nulos al principio
SQLiteORDER BY monto -- nulos al principio

Y la forma que funciona igual en los cuatro: ORDER BY monto IS NULL, monto. Ese IS NULL devuelve 0 o 1, así que ordenar por él primero manda los huecos al final digas lo que digas después.

PostgreSQL y Oracle los tratan como el valor más grande, así que en orden ascendente salen al final; SQLite, MySQL y SQL Server los tratan como el más pequeño y salen al principio. La misma consulta, dos resultados 🙃

La única forma de no depender de eso es decirlo tú, y por eso el IS NULL del final aparece tanto en el código de verdad: ordena primero por si es nulo, y solo después por el monto.

Ejercicios

1. La división que engaña

Calcula qué porcentaje de pedidos pasan de S/1000, primero mal y después bien.

SELECT SUM(CASE WHEN monto > 1000 THEN 1 ELSE 0 END) / COUNT(*) AS mal,
       ROUND(SUM(CASE WHEN monto > 1000 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) AS bien
FROM pedidos;
mal  bien
---  ----
0    8.1
2. COUNT(*) contra COUNT(columna)

Sobre pedidos, muestra las dos cuentas y cuántos nulos hay.

SELECT COUNT(*) AS filas,
       COUNT(monto) AS con_valor,
       COUNT(*) - COUNT(monto) AS nulos
FROM pedidos;
filas  con_valor  nulos
-----  ---------  -----
900    873        27
3. El nulo que se come la suma

Comprueba que sumar algo a NULL devuelve NULL, y arréglalo con COALESCE.

SELECT 500 + NULL AS sin_arreglar,
       500 + COALESCE(NULL, 0) AS arreglado;
sin_arreglar  arreglado
------------  ---------
              500
4. Truncar contra redondear

Con el precio del producto más caro, muestra el valor original, truncado y redondeado.

SELECT precio,
       CAST(precio AS INTEGER) AS truncado,
       ROUND(precio) AS redondeado
FROM productos
ORDER BY precio DESC
LIMIT 1;
precio  truncado  redondeado
------  --------  ----------
89.35   89        89.0

Un céntimo por fila no suena a nada, y en un millón de filas es dinero.

5. El tipo de cada valor

Comprueba el tipado dinámico de SQLite: mira el tipo de tres columnas distintas.

SELECT typeof(id) AS tipo_id,
       typeof(fecha) AS tipo_fecha,
       typeof(monto) AS tipo_monto
FROM pedidos LIMIT 1;
tipo_id  tipo_fecha  tipo_monto
-------  ----------  ----------
integer  text        real

La fecha es text. En PostgreSQL, MySQL y SQL Server sería un tipo fecha de verdad, y ahí sí puedes restar dos fechas directamente.

6. Promedio con nulos

Calcula el monto promedio de las dos formas y explica por qué difieren.

SELECT ROUND(AVG(monto), 2) AS avg_ignora_nulos,
       ROUND(SUM(COALESCE(monto, 0)) * 1.0 / COUNT(*), 2) AS nulos_como_cero
FROM pedidos;
avg_ignora_nulos  nulos_como_cero
----------------  ---------------
610.14            591.84

El primero divide entre las filas con valor; el segundo entre todas. Cuál es el correcto depende de qué significa el hueco, y eso lo sabe quien cargó los datos, no la consulta.

7. Escríbelo para los cuatro

Sin ejecutar: el ticket promedio por pedido con dos decimales, en los cuatro motores.

-- PostgreSQL
SELECT ROUND(SUM(monto)::numeric / COUNT(*), 2) FROM pedidos;

-- MySQL (el único que divide decimal por su cuenta)
SELECT ROUND(SUM(monto) / COUNT(*), 2) FROM pedidos;

-- SQL Server
SELECT ROUND(CAST(SUM(monto) AS DECIMAL(12,2)) / COUNT(*), 2) FROM pedidos;

-- SQLite
SELECT ROUND(SUM(monto) * 1.0 / COUNT(*), 2) FROM pedidos;

Y en los cuatro existe AVG(monto), que hace lo mismo en una palabra. Cuando exista la función, úsala: es más corta y no tiene el problema de la división entera 🌟

7. Los nulos donde tú digas

Pide los cinco pedidos más baratos, de verdad.

SELECT id, monto
FROM pedidos
ORDER BY monto IS NULL, monto
LIMIT 5;
id   monto
---  -----
852  5.05
708  5.77
52   16.48
557  16.62
351  42.04

Ahí está el truco entero: monto IS NULL devuelve 0 o 1, y ordenar por eso primero manda todos los huecos al final 🎯

Sin esa línea, en SQLite esta misma consulta te devuelve cinco filas vacías y ni un solo monto, porque los nulos van primero. Y en PostgreSQL te habría funcionado sin ponerla, que es justo lo que hace que el error viaje: en un motor sale bien por casualidad y en el otro no.

La versión declarada de esto es ORDER BY monto NULLS LAST, que existe en PostgreSQL y en Oracle pero no en SQLite ni en MySQL. El IS NULL funciona en los cuatro.

8. Rellenar con cero, ¿cambia algo?

Calcula la suma y el promedio de dos formas: ignorando los nulos y tratándolos como cero.

SELECT ROUND(SUM(monto), 2)               AS suma_ignorando,
       ROUND(SUM(COALESCE(monto, 0)), 2)  AS suma_con_cero,
       ROUND(AVG(monto), 2)               AS promedio_ignorando,
       ROUND(AVG(COALESCE(monto, 0)), 2)  AS promedio_con_cero
FROM pedidos;
suma_ignorando  suma_con_cero  promedio_ignorando  promedio_con_cero
--------------  -------------  ------------------  -----------------
532653.85       532653.85      610.14              591.84

Las dos sumas dan exactamente lo mismo, y los dos promedios no 🤨

La suma no cambia porque sumar cero no suma nada, y SUM ya ignoraba esos huecos de todas formas. El promedio sí cambia porque el COALESCE convirtió 27 desconocidos en 27 pedidos de cero soles, y esos 27 ahora sí entran en el reparto.

De ahí sale la regla que uso: rellenar nulos con cero es una decisión sobre el denominador, no sobre el numerador. Y hay que tomarla a sabiendas, no por costumbre de escribir COALESCE en todo.

9. Las tres formas de contar, juntas

Cuenta filas, valores y valores distintos en una sola consulta.

SELECT COUNT(*)               AS todas,
       COUNT(monto)           AS con_valor,
       COUNT(DISTINCT canal)  AS canales
FROM pedidos;
todas  con_valor  canales
-----  ---------  -------
900    873        4

Tres números que se parecen y contestan tres preguntas distintas 🔢

  • 🧾 COUNT(*) cuenta filas, mire lo que mire.
  • 💵 COUNT(monto) cuenta valores que hay. La diferencia con el primero son los huecos, y aquí son 27.
  • 🏷️ COUNT(DISTINCT canal) cuenta valores diferentes. Y también se salta los nulos, aunque aquí no haya.

El truco de restar los dos primeros es la forma más corta que conozco de contar huecos sin escribir un CASE.

10. Dónde se pierden los montos

Mira si los pedidos sin monto se concentran en algún tipo de cliente.

SELECT c.segmento,
       COUNT(*)                                          AS pedidos,
       SUM(CASE WHEN p.monto IS NULL THEN 1 ELSE 0 END)  AS sin_monto,
       ROUND(AVG(p.monto), 2)                            AS ticket
FROM pedidos p
JOIN clientes c ON c.id = p.id_cliente
GROUP BY c.segmento
ORDER BY ticket DESC;
segmento    pedidos  sin_monto  ticket
----------  -------  ---------  ------
Minimarket  174      8          630.43
Horeca      257      8          610.05
Mayorista   194      3          607.59
Bodega      251      8          596.05

Ocho, ocho, ocho y tres. Repartidos, salvo Mayorista que tiene menos 🧐

Y aquí hay que tener cuidado con lo que se concluye. Mayorista tiene 3 huecos sobre 194 pedidos y Bodega tiene 8 sobre 251: son 1,5% contra 3,2%. Parece el doble, pero estamos hablando de cinco pedidos de diferencia, y con números tan chicos eso se mueve solo.

Lo honesto es decir que no se ve un patrón, no inventar uno. Cuando la diferencia son cinco filas, lo que hace falta no es una consulta más lista: son más datos.

La consulta que contesta cero y debería contestar uno

Con todo lo del capítulo en la cabeza, mira esta. Es la trampa más cara de los nulos y la contesta mal casi todo el mundo la primera vez 😬

La trampa

Quieres los clientes que nunca compraron. Lo escribes como se dice en español: los que no están en la lista de los que hicieron pedidos.

SELECT COUNT(*) FROM clientes
WHERE id NOT IN (SELECT id_cliente FROM pedidos);
-- 0

SELECT COUNT(*) FROM clientes c
WHERE NOT EXISTS (
    SELECT 1 FROM pedidos p WHERE p.id_cliente = c.id);
-- 1
Qué está mal

La misma pregunta y dos respuestas distintas. La verdadera es 1 😬

La tabla pedidos tiene 24 filas con el id_cliente en NULL. Y NOT IN con una lista que contiene un NULL no devuelve nada nunca. Por dentro es "id distinto de 3 Y distinto de 7 Y distinto de NULL", y esa última comparación es desconocida, así que la cadena entera deja de ser verdadera para todo el mundo.

Y fíjate en la forma que tiene de fallar, que es la peor de todas: no da error, no da un número raro, da cero. Y cero se lee como "qué bien, no hay ningún cliente sin comprar" en vez de como "esta consulta está rota".

La regla que uso: NOT IN solo contra una lista que yo escribí a mano. Contra una subconsulta, siempre NOT EXISTS, que trata los nulos como cualquiera esperaría.

Comprueba que lo tienes

Filtras con WHERE descuento != 15 y esperabas 2.400 filas, pero salen 1.900. Compruebas y hay 500 filas con el descuento vacío.

  • Los NULL no son distintos de 15 ni iguales: se caen del filtro
  • El != no funciona en SQL, hay que usar <>
  • Los nulos cuentan como cero y cero sí es distinto de 15
  • Falta un paréntesis en la condición

Lo que te llevas

  • 💸 Para dinero, DECIMAL o NUMERIC, nunca FLOAT.
  • ➗ Entero entre entero da entero: multiplica por 1.0 o usa CAST. Un porcentaje que sale cero siempre es esto.
  • 🕳️ NULL es "no se sabe": cualquier operación con él da NULL.
  • 🔢 COUNT(*) cuenta filas, COUNT(columna) cuenta valores. AVG ignora los nulos.
  • 🧰 COALESCE funciona en los cuatro y acepta varios repuestos.
  • ✂️ CAST a entero trunca, no redondea.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Un NULL no es cero ni vacío. Es un "no se sabe", y se contagia a todo lo que toca.

Y qué hacer con los nulos no es una pregunta de SQL, es una pregunta de estadística: si los borras cambias el promedio y si los rellenas también. Está contado en el libro de estadística 📐

En el capítulo 5 vamos a texto y fechas, que es donde los cuatro motores más se separan y donde vas a agradecer tener la tabla al lado.

Que tengas lindo día! 🌸

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?