El diseño de la base de datos es la decisión más difícil de cambiar después de que una aplicación entra en producción. Un esquema mal pensado genera consultas lentas, datos inconsistentes y migraciones dolorosas. Uno bien diseñado aguanta años de crecimiento sin apenas tocar la estructura. Este artículo, que nació como una guía de principios básicos publicada por MySQL Hispano en 2004 y que ha sido revisada y actualizada por David Carrero en 2026, recorre los principios que siguen siendo válidos y añade las herramientas que MySQL 8 pone a disposición del desarrollador moderno.
Almacenar solo la información necesaria
El «síndrome de la hoja de cálculo» es uno de los errores más comunes en diseño de bases de datos: añadir columnas «por si acaso» en lugar de almacenar únicamente lo que la aplicación va a necesitar. Datos que se pueden calcular a partir de otros no deben almacenarse por separado. El precio de un producto más el IVA no necesita su propia columna si el porcentaje de IVA es un campo de configuración: se calcula en la consulta.
Una preocupación habitual entre quienes empiezan es que añadir columnas después es complicado. En MySQL, ALTER TABLE permite modificar la estructura de una tabla sin perder datos:
ALTER TABLE productos ADD COLUMN popularidad TINYINT UNSIGNED DEFAULT 0; ALTER TABLE productos ADD COLUMN fecha_alta DATE NOT NULL; ALTER TABLE clientes DROP COLUMN telefono_fax; -- campo obsoleto
En MySQL 8 con InnoDB, muchas operaciones de ALTER TABLE son «online» (no bloquean lecturas mientras se ejecutan).
No uses SELECT *
La consulta SELECT * FROM tabla es cómoda durante el desarrollo y problemática en producción por varias razones:
- Transfiere columnas que quizá nunca se usen, aumentando el consumo de red y memoria.
- Si la tabla cambia (se añade o reordena una columna), el código que accede por posición numérica falla.
- El optimizador tiene menos información para elegir el plan de ejecución más eficiente.
- Dificulta la revisión de código: no queda claro qué datos necesita realmente la función que hace la consulta.
La alternativa es siempre explicitar las columnas:
-- En lugar de: SELECT * FROM productos WHERE activo = 1; -- Hacer: SELECT id_producto, nombre, precio, stock FROM productos WHERE activo = 1;
Normalización: evitar la redundancia
La normalización es el proceso de organizar las tablas para eliminar redundancias y dependencias problemáticas. No es necesario memorizar los nombres formales de las formas normales para aplicar sus principios.
El ejemplo clásico es la tabla de CDs con columnas track1, track2, ..., track20. El problema es evidente: ¿cuántas columnas poner? ¿Qué pasa con los álbumes de 22 pistas? La solución es separar los datos en dos tablas relacionadas:
CREATE TABLE album ( id_album INT NOT NULL AUTO_INCREMENT PRIMARY KEY, titulo VARCHAR(100) NOT NULL, artista VARCHAR(100) NOT NULL, anyo YEAR NOT NULL ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE pista ( id_pista INT NOT NULL AUTO_INCREMENT PRIMARY KEY, id_album INT NOT NULL, numero TINYINT UNSIGNED NOT NULL, titulo VARCHAR(100) NOT NULL, FOREIGN KEY (id_album) REFERENCES album (id_album) ON DELETE CASCADE ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
El campo id_album en la tabla pista es la relación: permite obtener todas las pistas de un álbum con un JOIN sencillo y almacenar cualquier número de pistas sin cambiar el esquema.
Los cuatro objetivos de la normalización que siguen siendo válidos en 2026:
- Eliminar la redundancia de datos (el mismo dato no debe estar en dos sitios).
- Evitar inconsistencias (si el dato está en dos sitios, pueden desincronizarse).
- Representar toda la información que la aplicación necesita.
- Facilitar las actualizaciones (modificar un dato en un solo lugar).
Dicho esto, la denormalización calculada (duplicar datos para evitar JOINs costosos en tablas enormes) es una técnica válida cuando el rendimiento lo justifica. Normaliza primero, denormaliza solo cuando tengas datos de rendimiento que lo justifiquen.
Elegir el tipo de dato correcto
El tipo de dato afecta al espacio en disco, al rendimiento de las consultas y a la validez de los datos. Guía actualizada para MySQL 8:
Tipo | Cuándo usarlo | Evitar |
| Valores 0-255 (puntuaciones, boolean numérico) | Usar INT cuando TINYINT basta |
| Claves primarias, contadores | BIGINT por defecto sin necesitarlo |
| Importes monetarios, porcentajes exactos | FLOAT/DOUBLE para dinero (errores de redondeo) |
| Textos de longitud variable con límite conocido | VARCHAR(255) para todo por comodidad |
| Contenido largo sin límite fijo (artículos, descripciones) | TEXT para campos cortos que sí tienen límite |
| Solo fecha (cumpleaños, fecha de alta) | DATETIME cuando no necesitas la hora |
| Fecha y hora en timezone local | TIMESTAMP si el rango 2038 puede ser un problema |
| Atributos variables o configuraciones por fila | JSON para datos que debes consultar con índices normales |
| Estados fijos con pocos valores (activo, pendiente, cancelado) | ENUM si el conjunto de valores va a cambiar frecuentemente |
Restricciones de integridad: deja que MySQL valide
El artículo original de 2004 recomendaba indexar apropiadamente pero apenas mencionaba las restricciones de integridad. En MySQL 8, las restricciones que siempre debiste usar son:
NOT NULL
Un campo que siempre debe tener valor debe declararse NOT NULL. NULL tiene semántica especial en SQL (cualquier operación con NULL devuelve NULL) y genera código de aplicación más complejo. Define NOT NULL por defecto y usa NULL solo cuando «sin valor» sea un estado válido del negocio.
FOREIGN KEY
Las claves foráneas son la forma en que MySQL garantiza la integridad referencial automáticamente:
ALTER TABLE pedidos ADD CONSTRAINT fk_pedidos_cliente FOREIGN KEY (id_cliente) REFERENCES clientes (id_cliente) ON DELETE RESTRICT ON UPDATE CASCADE;
ON DELETE RESTRICT evita borrar un cliente que tiene pedidos. ON UPDATE CASCADE propaga los cambios de la PK al campo FK. Esto te ahorra decenas de validaciones en el código de aplicación.
CHECK (MySQL 8.0.16+)
Hasta MySQL 8.0.15, las restricciones CHECK existían en la sintaxis pero MySQL las ignoraba completamente. A partir de MySQL 8.0.16 se evalúan y se aplican:
CREATE TABLE productos ( id_producto INT NOT NULL AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(100) NOT NULL, precio DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 0, CONSTRAINT chk_precio_positivo CHECK (precio > 0), CONSTRAINT chk_stock_no_negativo CHECK (stock >= 0) ) ENGINE = InnoDB;
Intentar insertar un producto con precio negativo lanzará un error a nivel de base de datos, independientemente de si la aplicación valida o no.
Índices: cuándo crear y cuándo no crear
Los índices aceleran las lecturas y penalizan las escrituras (INSERT, UPDATE, DELETE tienen que mantener los índices actualizados). La regla práctica:
- Indexa las columnas que aparecen frecuentemente en
WHERE,ORDER BY,GROUP BYy como columna de join. - No indexes columnas con muy baja cardinalidad (por ejemplo, una columna booleana). MySQL preferirá el escaneo completo de tabla.
- Los índices compuestos son ordenados: el orden de las columnas importa.
INDEX (apellido, nombre)acelera búsquedas por apellido solo o por apellido+nombre, pero no por nombre solo. - Usa
EXPLAINpara verificar que MySQL usa el índice que esperas.
-- Ver índices existentes SHOW INDEX FROM pedidos; -- Índice compuesto para consultas típicas de listado CREATE INDEX idx_pedidos_cliente_fecha ON pedidos (id_cliente, fecha_pedido DESC);
JSON para datos semi-estructurados
MySQL 5.7 introdujo el tipo de columna JSON con validación automática del formato. MySQL 8 amplió las funciones disponibles considerablemente. Es útil cuando diferentes filas necesitan atributos distintos sin justificar columnas separadas:
-- Tabla de productos con atributos variables por categoría
ALTER TABLE productos ADD COLUMN atributos JSON;
-- Electrónica
UPDATE productos SET atributos = '{"voltaje": 220, "frecuencia": 50}' WHERE id = 1;
-- Ropa
UPDATE productos SET atributos = '{"talla": "XL", "color": "azul"}' WHERE id = 2;
-- Consultar por atributo JSON con índice funcional (MySQL 8.0.13+)
ALTER TABLE productos ADD INDEX idx_talla ((CAST(atributos->>'$.talla' AS CHAR(10))));
Regla: usa JSON para atributos que varían por fila y que rara vez necesitas filtrar. Para datos que filtras frecuentemente, sigue siendo más eficiente una columna normal con índice.
UUID vs INT como clave primaria
El artículo original usaba AUTO_INCREMENT sin cuestionarlo. En arquitecturas modernas (sistemas distribuidos, replicación multi-maestro, APIs que no quieren exponer IDs secuenciales) aparece la opción de UUID. La comparativa:
Criterio | INT AUTO_INCREMENT | UUID (VARCHAR / BINARY) |
Tamaño | 4 bytes (INT) / 8 bytes (BIGINT) | 16 bytes (BINARY) / 36 bytes (VARCHAR) |
Rendimiento de índice | Excelente (secuencial) | Peor (aleatorio, fragmenta el B-tree) |
Unicidad global | No (depende de la instancia) | Sí |
Previsibilidad del ID | Predecible (seguridad) | No predecible |
Complejidad | Simple | Más complejo (generar, almacenar, mostrar) |
En MySQL 8 puedes usar UUID_TO_BIN(UUID(), true) para generar UUIDs ordenados en tiempo (versión 1 reordenada) que fragmentan menos el B-tree. Para la mayoría de aplicaciones web, BIGINT AUTO_INCREMENT sigue siendo la elección correcta.
Convenciones de nombres en 2026
Las convenciones del artículo original siguen siendo válidas. Algunas aclaraciones y añadidos para 2026:
- Usa
snake_caseen minúsculas (MySQL distingue mayúsculas/minúsculas según el sistema operativo; minúsculas evitan sorpresas en migraciones entre Linux y Windows). - Nombres de tabla en plural (
pedidos,clientes,productos), nombres de columna en singular (id_pedido,nombre). - Las claves primarias:
id_tablao simplementeid. Elige uno y sé consistente. - Las claves foráneas: mismo nombre que la columna a la que apuntan (
id_clienteen la tablapedidosreferencia aid_clienteenclientes). - Los índices: prefijo descriptivo (
idx_para índices normales,fk_para claves foráneas,ux_para unique). - Máximo 64 caracteres por nombre (límite de MySQL que no ha cambiado).
Charset: siempre utf8mb4
El artículo original no mencionaba el charset porque en 2004 el problema no era tan evidente. En 2026 no hay excusa: todas las bases de datos deben usar utf8mb4, no utf8 (que en MySQL solo almacena hasta 3 bytes por carácter, excluyendo emojis y muchos caracteres asiáticos):
CREATE DATABASE mi_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Configúralo también en el servidor para que sea el charset por defecto de las conexiones nuevas.
Imagen: Pexels / Ivan S
