Capítulo 4 de 15 8 secciones 9 min

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 de dato deciden cómo se guarda y cómo se calcula. El error más caro es dividir dos enteros: en SQL eso da un entero, así que 1/2 devuelve 0 y no 0,5. El otro es NULL, que no es cero ni vacío sino "no se sabe", y que hace que cualquier operación donde participe devuelva NULL sin avisar.

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.

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 💡

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 💛

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 🌟

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.

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

¿Tienes alguna duda o consulta?