# Tipos de dato y nulos

URL canónica: https://missyera.com/guias/sql-desde-cero/tipos-y-nulos/
Última actualización: 2026-11-14
Autora: Gera Flores (Miss Yera)

> **Respuesta corta**
>
> 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.

Capítulo 4 de 23 del libro [SQL desde cero](https://missyera.com/guias/sql-desde-cero.md).

## 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

| PostgreSQL | `VARCHAR(100) o TEXT -- TEXT no tiene penalización` |
|---|---|
| MySQL | `VARCHAR(100) o TEXT -- TEXT no se puede indexar entero` |
| SQL Server | `NVARCHAR(100) o NVARCHAR(MAX) -- la N es para Unicode` |
| SQLite | `TEXT -- 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

| PostgreSQL | `NUMERIC(12, 2)` |
|---|---|
| MySQL | `DECIMAL(12, 2)` |
| SQL Server | `DECIMAL(12, 2) o MONEY` |
| SQLite | `REAL -- 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

| PostgreSQL | `TIMESTAMP o TIMESTAMPTZ -- el TZ guarda la zona horaria` |
|---|---|
| MySQL | `DATETIME o TIMESTAMP` |
| SQL Server | `DATETIME2 -- DATETIME es el viejo y tiene menos precisión` |
| SQLite | `TEXT 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;
```

```text
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;
```

```text
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;
```

```text
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

| PostgreSQL | `SELECT monto::numeric / cantidad -- o CAST(monto AS numeric)` |
|---|---|
| MySQL | `SELECT monto / cantidad -- MySQL ya devuelve decimal, es el raro` |
| SQL Server | `SELECT CAST(monto AS DECIMAL(12,2)) / cantidad` |
| SQLite | `SELECT 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;
```

```text
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.](https://missyera.com/static/diagramas/webp/sql-03-nulos.webp)

*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;
```

```text
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;
```

```text
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;
```

```text
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;
```

```text
id  monto  con_repuesto
--  -----  ------------
22         0
57         0
78         0
```

Si es nulo, ponme otro valor

| PostgreSQL | `COALESCE(monto, 0)` |
|---|---|
| MySQL | `COALESCE(monto, 0) o IFNULL(monto, 0)` |
| SQL Server | `COALESCE(monto, 0) o ISNULL(monto, 0)` |
| SQLite | `COALESCE(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;
```

```text
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;
```

```text
redondea  trunca
--------  ------
90.0      89
```

Convertir a número

| PostgreSQL | `CAST('123' AS INTEGER) o '123'::integer` |
|---|---|
| MySQL | `CAST('123' AS SIGNED) -- ojo, no acepta INTEGER` |
| SQL Server | `CAST('123' AS INT) o CONVERT(INT, '123')` |
| SQLite | `CAST('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 🎲

```sql
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;
```

```text
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:

```sql
SELECT NULL = NULL       AS son_iguales,
       NULL <> NULL      AS son_distintos,
       NULL IS NULL      AS es_nulo;
```

```text
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 💸

```sql
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;
```

```text
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é 🔧

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

```text
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:

```sql
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;
```

```text
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

```sql
SELECT id, monto FROM pedidos ORDER BY monto LIMIT 5;
```

```text
id   monto
---  -----
22
57
78
83
152
```

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

```sql
SELECT id, monto FROM pedidos ORDER BY monto DESC LIMIT 5;
```

```text
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

| PostgreSQL | `ORDER BY monto -- nulos al final` |
|---|---|
| MySQL | `ORDER BY monto -- nulos al principio` |
| SQL Server | `ORDER BY monto -- nulos al principio` |
| SQLite | `ORDER 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;
```

```text
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;
```

```text
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;
```

```text
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;
```

```text
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;
```

```text
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;
```

```text
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.

```sql
SELECT id, monto
FROM pedidos
ORDER BY monto IS NULL, monto
LIMIT 5;
```

```text
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.

```sql
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;
```

```text
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.

```sql
SELECT COUNT(*)               AS todas,
       COUNT(monto)           AS con_valor,
       COUNT(DISTINCT canal)  AS canales
FROM pedidos;
```

```text
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.

```sql
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;
```

```text
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](https://missyera.com/guias/estadistica-desde-cero/) 📐

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! 🌸

## Seguir leyendo

- Anterior: [SELECT, WHERE y ORDER BY](https://missyera.com/guias/sql-desde-cero/select-y-where.md)
- Siguiente: [Texto y fechas, donde los cuatro se separan](https://missyera.com/guias/sql-desde-cero/texto-y-fechas.md)

## Contacto

- Diagnóstico de 30 minutos con Miss Yera: https://calendly.com/soymissyera/reunion-con-yera
- Correo: hola@missyera.com
- Esta página en HTML: https://missyera.com/guias/sql-desde-cero/tipos-y-nulos/
- Índice de todas las páginas en texto: https://missyera.com/llms-index.txt
