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
| 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;
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
| 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;
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
| 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;
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
| 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 💛
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,
DECIMALoNUMERIC, nuncaFLOAT. - ➗ Entero entre entero da entero: multiplica por 1.0 o usa
CAST. Un porcentaje que sale cero siempre es esto. - 🕳️
NULLes "no se sabe": cualquier operación con él daNULL. - 🔢
COUNT(*)cuenta filas,COUNT(columna)cuenta valores.AVGignora los nulos. - 🧰
COALESCEfunciona en los cuatro y acepta varios repuestos. - ✂️
CASTa 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! 🌸