Cómo validar datos en MySQL
Guía práctica para validar la calidad de datos en MySQL — las seis comprobaciones que necesita toda tabla, el SQL para escribirlas y las trampas de coerción, cotejamiento y fechas cero que hacen de MySQL un maestro ocultando datos malos.
· 8 min read
Para validar datos en MySQL, expresa cada expectativa como una consulta de agregación que devuelva un recuento de violaciones, ejecútalas como lote y haz que fallen cuando un recuento supere su umbral. Las seis comprobaciones que importan son: nulos en columnas obligatorias, claves duplicadas, valores fuera de un conjunto permitido, números fuera de un rango plausible, cadenas mal formadas y filas obsoletas. MySQL lo pone más difícil de lo que parece, porque es el motor más dispuesto a aceptar datos malos en silencio: la coerción implícita de tipos, las fechas permisivas y la igualdad dependiente del cotejamiento conspiran para que las filas inválidas parezcan válidas.
Por qué MySQL esconde los datos malos
Tres comportamientos explican la mayoría de las sorpresas.
Coerción implícita. Comparar una columna de texto con un número no da error: MySQL convierte la cadena. WHERE amount_text = 0 casa con 'abc', '' y '0.00' por igual, porque las tres se convierten en 0. Cualquier validación que compare entre tipos distintos está midiendo algo distinto de lo que crees.
Fechas cero. Salvo que NO_ZERO_DATE y NO_ZERO_IN_DATE estén en sql_mode, '0000-00-00' es un DATE almacenable. No es NULL, así que las comprobaciones de completitud pasan; no es una fecha real, así que las comprobaciones de frescura la comparan e informan de la fila como antiquísima.
Igualdad dependiente del cotejamiento. El cotejamiento por defecto utf8mb4_0900_ai_ci ignora acentos y mayúsculas, así que 'PAID', 'paid' y 'páid' son un único valor. En una columna _bin o _cs son tres. La misma comprobación de valores permitidos significa cosas distintas en dos tablas de la misma base de datos.
Nada de esto son errores. Son comportamientos por defecto que tienes que sortear al validar. Las comprobaciones en sí son las mismas seis que escribirías contra cualquier motor —la guía completa para validar datos cubre ese flujo de trabajo común—; lo que sigue es la versión correcta para MySQL de cada una.
Las seis comprobaciones que necesita toda tabla
1. Completitud — nulos en columnas obligatorias
SELECT COUNT(*) AS violations
FROM `sales`.`orders`
WHERE `order_id` IS NULL;
En una tabla anterior al modo estricto, amplíala para detectar el equivalente del nulo con cadena vacía:
WHERE `order_id` IS NULL OR `order_id` = '';
2. Unicidad — claves de negocio duplicadas
SELECT COUNT(*) AS violations
FROM (
SELECT `order_id`
FROM `sales`.`orders`
WHERE `order_id` IS NOT NULL
GROUP BY `order_id`
HAVING COUNT(*) > 1
) AS dupes;
Recuerda la salvedad del cotejamiento: en una columna que ignora mayúsculas, esto informa correctamente de 'ORD-1' y 'ord-1' como duplicados. En una columna _bin, no. Decide a qué te refieres y fija el cotejamiento.
3. Conformidad — valores fuera de un conjunto permitido
SELECT COUNT(*) AS violations
FROM `sales`.`orders`
WHERE `status` IS NOT NULL
AND `status` NOT IN ('pending', 'paid', 'shipped', 'refunded');
La protección IS NOT NULL es obligatoria: NULL NOT IN (...) es NULL, la fila se cae y una columna mayoritariamente nula informa de cero violaciones.
El tipo ENUM de MySQL parece hacer redundante esta comprobación. No la hace: fuera del modo estricto, un valor ENUM inválido se almacena como cadena vacía en lugar de rechazarse, así que la columna puede contener un valor que no está en su propia definición.
4. Exactitud — números fuera de un rango plausible
SELECT COUNT(*) AS violations
FROM `sales`.`orders`
WHERE `total_amount` IS NOT NULL
AND (`total_amount` < 0 OR `total_amount` > 100000);
Usa DECIMAL para el dinero. FLOAT y DOUBLE comparan con error de redondeo, y una columna de entero sin signo convierte en silencio un valor negativo en uno positivo enorme, algo que una comprobación de rango sí detecta y una restricción de esquema no.
5. Conformidad — identificadores mal formados
SELECT COUNT(*) AS violations
FROM `sales`.`orders`
WHERE `reference` IS NOT NULL
AND `reference` NOT REGEXP '^ORD-[0-9]{6}#39;;
MySQL 8.0 sustituyó el viejo motor POSIX por ICU, así que \d, \w, los cuantificadores perezosos y las clases Unicode funcionan todos. En MySQL 5.7 o MariaDB el motor antiguo es más estricto: quédate con patrones seguros en POSIX como [0-9] y [[:alpha:]] si necesitas soportar ambos. REGEXP también es sensible al cotejamiento: con un cotejamiento _ci, la coincidencia ignora mayúsculas independientemente del patrón, así que usa REGEXP BINARY cuando las mayúsculas importen.
6. Oportunidad — frescura
SELECT COUNT(*) AS violations
FROM `sales`.`orders`
WHERE `created_at` < UTC_TIMESTAMP() - INTERVAL 24 HOUR;
Usa UTC_TIMESTAMP(), no NOW(). NOW() devuelve la zona horaria de la sesión, que para un cliente que se conecta desde otra región no es la del servidor, y la comprobación se desplaza sin avisar. Si la columna es TIMESTAMP en lugar de DATETIME, MySQL ya convierte a UTC al escribir, que es uno de los pocos sitios donde su comportamiento implícito ayuda.
Añade una protección frente a fechas cero allí donde no se imponga el modo estricto:
WHERE `created_at` = '0000-00-00 00:00:00'
OR `created_at` < UTC_TIMESTAMP() - INTERVAL 24 HOUR;
Conseguir un usuario de solo lectura seguro
CREATE USER 'catalyst_ro'@'%' IDENTIFIED BY '<generated>';
GRANT SELECT ON `analytics`.* TO 'catalyst_ro'@'%';
Con SELECT basta: information_schema es legible por cualquier cuenta, restringido automáticamente a los objetos que esa cuenta puede ver, así que la importación del esquema no necesita ningún permiso adicional. Si tienes una réplica, apunta la conexión a ella: son escaneos de agregación y su sitio no es el primario de escritura.
Pon un techo para que un escaneo sobre una tabla sin índices no pueda bloquear un hilo:
SET SESSION max_execution_time = 60000; -- milliseconds, SELECT only
De SQL ad hoc a un contrato de datos
Las comprobaciones escritas a mano se degradan. La versión fiable es declarativa: las expectativas viven junto al esquema, en un formato revisable en una pull request y ejecutable por un runner. Catalyst usa el Open Data Contract Standard:
apiVersion: v3.0.0
kind: DataContract
info:
title: orders
version: 1.1.0
owner: data-platform
schema:
- name: orders
physicalName: orders
physicalType: table
properties:
- name: order_id
logicalType: string
physicalType: char(36)
required: true
primaryKey: true
quality:
- rule: nullCount
dimension: completeness
severity: error
mustBe: "0"
- rule: duplicateCount
dimension: uniqueness
severity: error
mustBe: "0"
- name: status
logicalType: string
quality:
- rule: validValues
dimension: conformity
severity: error
mustBe: "['pending', 'paid', 'shipped', 'refunded']"
- name: total_amount
logicalType: number
physicalType: decimal(12,2)
quality:
- rule: between
dimension: accuracy
severity: error
mustBe: "[0, 100000]"
- name: reference
logicalType: string
quality:
- rule: regex
dimension: conformity
severity: warning
mustBe: "'^ORD-[0-9]{6}#39;"
- name: created_at
logicalType: timestamp
quality:
- rule: freshness
dimension: timeliness
severity: error
mustBe: "<= 24h"
Catalyst importa las columnas y tipos desde information_schema, propone un contrato de base, compila cada regla al SQL de MySQL mostrado arriba y registra pass, warn o fail por comprobación, con las filas infractoras disponibles para inspección. El YAML va y vuelve sin alterarse, así que editar una regla en el constructor no reformatea el fichero ni pierde tus comentarios.
Trampas de MySQL que conviene conocer
| Trampa | Qué ocurre | Qué hacer |
|---|---|---|
| Coerción implícita | 'abc' = 0 es verdadero | No compares nunca entre tipos distintos |
| Fechas cero | '0000-00-00' pasa las comprobaciones de nulos | Activa NO_ZERO_DATE; protégete de forma explícita |
ENUM fuera del modo estricto | El valor inválido se guarda como '' | Mantén igualmente una regla validValues |
Cotejamiento _ci | 'PAID' es igual a 'paid' | Fija el cotejamiento o usa REGEXP BINARY |
NOW() | Zona horaria de la sesión | Usa UTC_TIMESTAMP() |
| Enteros sin signo | Un negativo se convierte en un positivo enorme | Añade una regla between |
Dinero en FLOAT | Los límites del rango bailan por redondeo | Usa DECIMAL |
| Expresiones regulares en MySQL 5.7 | Motor POSIX, sin \d | Usa [0-9] o actualiza a 8.0 |
Programación y alertas
Ejecuta las comprobaciones de cada dataset justo después del job que lo carga, y deja que la programación siga la cadencia de los datos en vez de un cron de número redondo. Alerta sobre transiciones —una comprobación que pasa de pass a fail— y no en cada ejecución de una comprobación que sigue fallando, y reserva la severidad error para las reglas que de verdad deberían detener a los consumidores aguas abajo. Las comprobaciones de patrón sobre campos de texto libre suelen corresponder al nivel warning, donde son una tendencia y no un aviso urgente.
Preguntas frecuentes
¿Puedo validar datos de MySQL sin escribir SQL?
Sí. Declara la expectativa —obligatorio, único, valores permitidos, rango numérico, expresión regular, antigüedad máxima, clave foránea— y la herramienta la compila a SQL correcto para MySQL. Catalyst lee tus columnas desde information_schema, sugiere un contrato inicial a partir de los tipos y la nulabilidad que encuentra y solo te pide SQL cuando la lógica es genuinamente a medida, mediante una regla customSql.
¿La validación de datos necesita acceso de escritura a MySQL?
No. Con GRANT SELECT basta: cada comprobación es un SELECT de agregación que devuelve un único número. Catalyst se conecta en modo solo lectura y guarda únicamente metadatos y resultados; tus filas se quedan en tu base de datos.
¿Funciona esto con MariaDB y Amazon Aurora MySQL?
Sí, con una salvedad: MariaDB conservó el motor de expresiones regulares POSIX antiguo, así que los patrones que usan \d, \w o cuantificadores perezosos se comportan de otro modo que en MySQL 8.0. Quédate con las clases de caracteres POSIX para tener reglas portables. Aurora MySQL se comporta igual que el MySQL original en todo lo de esta guía.
¿Por qué mi comprobación de completitud pasa en una columna llena de cadenas vacías?
Porque '' no es NULL. Fuera del modo estricto, MySQL convierte muchas inserciones inválidas en cadenas vacías o valores cero en lugar de rechazarlas, así que una columna puede estar completamente poblada y ser completamente inútil. Escribe la comprobación de completitud para cubrir ambos casos y añade una regla validValues o regex que exprese lo que la columna debería contener realmente.
¿Cómo se compara esto con las restricciones CHECK?
MySQL no empezó a aplicar las restricciones CHECK hasta la 8.0.16; antes se parseaban y se ignoraban, lo que es una categoría de trampa en sí misma. Incluso donde se aplican, las restricciones rechazan filas en el momento de la escritura, que a menudo es el comportamiento equivocado para una tabla analítica que preferirías aterrizar y poner en cuarentena. Un contrato de datos describe la garantía, se ejecuta según una programación y te da las filas que fallan en lugar de una carga rechazada.