Errores Comunes en el Diseño de Bases de Datos Relacionales
Evita errores comunes de diseño en bases de datos relacionales con Postgres y MySQL: restricciones, claves, índices y marcas de tiempo correctas.
La mayoría de los errores en esquemas relacionales son, en realidad, una sola equivocación: confiar en la aplicación para hacer cumplir lo que debería garantizar la base de datos. Una clave foránea que se “gestiona en la capa de servicio”, una regla de unicidad que se verifica con un SELECT antes del INSERT, un campo de estado que el ORM valida pero la columna no — cada uno de estos es una restricción que se sacó del motor diseñado para aplicarla y se trasladó a código que solo se ejecuta cuando uno recuerda invocarlo. El resultado es información que el esquema nunca habría permitido: filas huérfanas, cuentas duplicadas, precios negativos, marcas de tiempo que nadie puede interpretar. Esta guía recorre los errores de diseño recurrentes que cometen los desarrolladores de aplicaciones en Postgres y MySQL, explica por qué cada uno termina causando problemas y propone la solución, con un pequeño fragmento de código antes/después para cada caso. Los ejemplos utilizan la sintaxis moderna de Postgres 18 donde corresponde, pero los principios aplican a cualquier motor relacional.
Puntos Clave
- Aplique la integridad en la base de datos mediante restricciones
FOREIGN KEY,NOT NULL,CHECKyUNIQUE; la validación exclusiva en la aplicación solo se ejecuta cuando el código recuerda invocarla, y un ORM que permite declarar una relación sin una clave foránea a nivel de base de datos está postergando un error de integridad para producción. - Use una clave primaria sustituta (
bigintidentity o un UUID conuuidv7()) y añada una restricciónUNIQUEseparada sobre la clave natural, de modo que un cambio de marca o de correo electrónico sea unUPDATE, no una migración. - A partir de PostgreSQL 18 (lanzado el 25 de septiembre de 2025) ya no se necesita una extensión para identificadores ordenados temporalmente:
id uuid PRIMARY KEY DEFAULT uuidv7()ofrece localidad de índice cercana a la de unbiginty, al mismo tiempo, garantiza unicidad global. - Almacene las marcas de tiempo como
timestamptz, no comotimestamp, e indexe las columnas de clave foránea y de filtro sobre las que realmente realiza joins — Postgres no indexa automáticamente el lado referenciante de una clave foránea. - Trate el esquema como código vivo: versionelo con migraciones, prefiera los borrados lógicos (
deleted_at timestamptz) cuando otras filas referencian un registro, y documente las columnas conCOMMENT ON COLUMN.
La raíz de la mayoría de los errores en el diseño de bases de datos relacionales: las reglas en el código de la aplicación, no en el esquema
El error de mayor impacto es aplicar las reglas de datos únicamente en el código de la aplicación. Una restricción en la capa de servicio protege exactamente una ruta de código; una restricción en el esquema protege todas las rutas — la API, un trabajo en segundo plano, una sesión manual de psql, una migración de datos mal ejecutada y el próximo servicio que alguien escriba contra la misma base de datos. Cuando la regla vive solo en la aplicación, el primer escritor que la omite corrompe la tabla de forma permanente.
Esta es la trampa del ORM moderno. Herramientas como Prisma y Drizzle modelan con total naturalidad una relación en el código de la aplicación sin emitir jamás una clave foránea a nivel de base de datos, y un @default o un esquema Zod se perciben como validación. No lo son. Un ORM que permite declarar una relación sin una clave foránea a nivel de base de datos no le está ahorrando trabajo; está postergando un error de integridad de datos para producción, donde la reproducción de sesiones — no el conjunto de pruebas — es lo que finalmente lo detecta, porque el fallo no se manifiesta como un stack trace. Los datos corruptos aparecen en cambio como una reseña atribuida a un usuario que ya no existe, o un pedido duplicado — una pantalla confusa que el usuario realmente ve.
La solución es llevar la regla al nivel donde no pueda omitirse. Las restricciones de verificación de PostgreSQL, por ejemplo, rechazan datos incorrectos independientemente del cliente que los haya escrito: para exigir precios de producto positivos, se puede usar una restricción CHECK (price > 0) en la definición de la tabla.
-- Antes: nada impide un pago negativo o un email NULL
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint, -- sin FK: filas huérfanas en potencia
amount numeric, -- sin CHECK: se permiten valores negativos
email text -- sin NOT NULL, sin UNIQUE
);
-- Después: el motor garantiza las invariantes
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
amount numeric NOT NULL CHECK (amount > 0),
email text NOT NULL UNIQUE
);
Una clave foránea no es un costo de rendimiento — es lo único que se interpone entre usted y las filas huérfanas que se renderizan como una reseña de un usuario que ya no existe. A partir de Postgres 18, también es posible aplicar restricciones de forma escalonada en tablas con alta carga: ALTER TABLE ahora puede establecer el atributo NOT VALID en restricciones NOT NULL, lo que permite añadir la restricción sin un escaneo inmediato de la tabla completa y validarla posteriormente con un bloqueo más débil.
Discover how at OpenReplay.com.
Omitir la normalización — o aplicarla en exceso
La normalización es la práctica de almacenar cada dato una sola vez. La primera forma normal (1FN) implica que no hay grupos repetidos ni campos con múltiples valores; la tercera forma normal (3FN) implica que cada columna no clave depende de la clave y de nada más. Para los esquemas de aplicaciones, la 3FN es el punto de partida razonable — consulte la documentación de definición de datos de Postgres para ver los detalles técnicos. Las dos violaciones clásicas son los valores separados por comas en una sola columna y las columnas repetidas del tipo payment_1 … payment_12.
Los valores separados por comas en una sola columna son una desnormalización de la que se arrepentirá en el primer WHERE … LIKE '%,42,%'; la solución es una tabla hija o una tabla de unión, no una consulta de cadenas más elaborada.
-- Antes: etiquetas como cadena delimitada, números de teléfono como columnas
CREATE TABLE users (
id bigint PRIMARY KEY,
tags text, -- 'admin,beta,vip' — no consultable, sin restricciones
phone1 text, phone2 text, phone3 text -- grupo repetido, se agota
);
-- Después: una tabla de unión para etiquetas, una tabla hija para teléfonos
CREATE TABLE user_tags (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
tag text NOT NULL,
PRIMARY KEY (user_id, tag)
);
CREATE TABLE user_phones (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
phone text NOT NULL
);
El error inverso es la sobre-normalización: dividir una única address en seis tablas con joins, o modelar una relación uno a uno como dos tablas sin motivo alguno. Cada join adicional tiene un costo tanto en rendimiento como en comprensión. Normalice hasta que cada dato exista una sola vez, y luego deténgase.
Usar campos de negocio como claves primarias
No utilice un campo de negocio como email, username o company_name como clave primaria; use una clave sustituta y añada una restricción UNIQUE separada sobre el valor natural, de modo que un cambio de marca o de dirección sea un UPDATE, no una migración. Los valores de negocio cambian, y cuando cambia una clave primaria, todas las claves foráneas que la referencian deben propagarse en cascada — un valor que nunca debió ser una identidad se convierte en una migración a nivel de esquema.
El consejo de 2007 de evitar las claves sustitutas está desactualizado para los desarrolladores de aplicaciones. El estándar moderno es una clave primaria sustituta más una restricción UNIQUE real sobre la clave natural — se obtiene una identidad estable y la garantía de unicidad.
CREATE TABLE companies (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- identidad sustituta estable
name text NOT NULL UNIQUE -- clave natural igualmente aplicada
);
UUID vs bigint: la decisión
A partir de Postgres 18 ya no se necesita una extensión para identificadores ordenados temporalmente: Postgres 18 incorpora la función de generación de UUID uuidv7(), cuyo valor es ordenable temporalmente, junto con un alias uuidv4() para generar explícitamente UUIDs de versión 4. UUIDv7 — definido en el RFC 9562 (mayo de 2024, que reemplaza al RFC 4122) — incorpora un prefijo de marca de tiempo, por lo que las nuevas filas se añaden a la derecha del índice en lugar de dispersarse aleatoriamente como los UUIDs v4. Tenga en cuenta que para versiones anteriores de Postgres, gen_random_uuid() (v4) forma parte del núcleo desde la versión 13; la extensión pgcrypto ya no es necesaria.
| Tipo de clave | Almacenamiento | Localidad de índice | Unicidad global | Notas |
|---|---|---|---|---|
bigint identity | 8 bytes | Excelente (secuencial) | No | El más compacto y rápido; expone el conteo de filas; requiere asignación centralizada |
uuidv4() | 16 bytes | Deficiente (inserciones aleatorias) | Sí | Apto para entornos distribuidos; fragmentación de índice bajo escritura intensa |
uuidv7() | 16 bytes | Buena (ordenado temporalmente) | Sí | Ordenable por tiempo; expone la hora de creación; la opción recomendada en PG18 |
Use bigint cuando los IDs permanecen dentro de una sola base de datos, y uuidv7() cuando genera IDs en el lado del cliente o entre múltiples servicios.
Sin una estrategia deliberada de indexación
Indexe las columnas por las que realmente filtra y realiza joins — especialmente las claves foráneas, que Postgres no indexa automáticamente — pero evite indexar todas las columnas, ya que cada índice representa una amplificación de escritura en cada inserción y actualización. Esta es la regresión de rendimiento más común en los esquemas de aplicaciones, y es fácil equivocarse en ambas direcciones.
La trampa es específica de Postgres: indexa automáticamente el lado referenciado (padre) de una clave foránea, pero no la columna referenciante (hija). Dado que indexar las columnas referenciantes no siempre es necesario y existen múltiples opciones de indexación, declarar una restricción de clave foránea no crea automáticamente un índice en las columnas referenciantes. (InnoDB de MySQL sí crea uno automáticamente — por lo que este problema es específico de Postgres.)
-- payments.user_id es una FK pero no está indexada: cada join y cada
-- DELETE en users realiza un escaneo secuencial de payments
CREATE INDEX ON payments (user_id);
Indexe las columnas de FK y de filtro; omita los índices en columnas de baja selectividad y en tablas que rara vez se consultan por ese campo.
Una tabla haciendo demasiados trabajos
Una sola tabla forzada a modelar múltiples dominios — la tabla genérica entidad-atributo-valor (EAV), o una tabla notifications que contiene alertas, eventos de auditoría y registros del sistema — sacrifica la claridad del esquema por una flexibilidad ilusoria. Se pierde la seguridad de tipos, no es posible aplicar restricciones significativas y cada lectura se convierte en un caos de filtros y auto-joins. Use tablas separadas por dominio para que cada una tenga sus propias columnas, tipos y claves foráneas.
-- Antes: una bolsa de todo, tipado como text, sin restricciones reales
CREATE TABLE entity_attributes (
entity_id bigint, attr_name text, attr_value text
);
-- Después: tablas reales con columnas y restricciones reales
CREATE TABLE user_alerts (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
level text NOT NULL, body text);
CREATE TABLE audit_events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_id bigint REFERENCES users(id),
action text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now());
Si realmente necesita atributos flexibles, recurra a una columna jsonb tipada en la tabla real — no a un EAV tipado como cadena que anula todas las restricciones que el motor ofrece.
Nomenclatura inconsistente
Elija una convención de nomenclatura y aplíquela en todas partes. Para Postgres y la mayoría de los ORMs, esto significa:
snake_case(los identificadores sin comillas se convierten a minúsculas);- nombres de tablas en plural o singular, elegidos de forma consistente desde el principio;
created_at/updated_atpara las marcas de tiempo;- sin metadatos en los nombres (
tbl_users,col_varchar_address); - sin espacios ni comillas; y
- sin palabras reservadas como
user,orderogroupcomo identificadores sin comillas.
La nomenclatura es lo más fácil de hacer bien en el momento de crear la tabla y lo más costoso de cambiar una vez que los datos y las consultas dependen de ella.
Tratar el esquema como algo definitivo
Un esquema es código vivo, no un artefacto de una sola vez. Gestione cada cambio mediante migraciones versionadas, revisadas y reversibles — la herramienta de migración de su ORM (Prisma Migrate, Drizzle Kit) o un ejecutor independiente — de modo que el historial del esquema sea reproducible en todos los entornos.
El historial también determina cómo se eliminan los datos. Prefiera un borrado lógico (un campo deleted_at timestamptz) frente a un DELETE físico cuando otras filas referencian el registro, porque un borrado físico o deja huérfanos a los hijos o, con ON DELETE CASCADE, borra silenciosamente un historial que más adelante se le pedirá que produzca.
ALTER TABLE orders ADD COLUMN deleted_at timestamptz;
-- "Pedidos activos" se convierte en un filtro, no en una operación destructiva
CREATE VIEW active_orders AS SELECT * FROM orders WHERE deleted_at IS NULL;
Almacenar fechas y horas sin zona horaria
Almacene las marcas de tiempo como timestamptz, no como timestamp — una columna sin zona horaria registra silenciosamente lo que el servidor consideraba “ahora” en ese momento, y esa ambigüedad se vuelve irrecuperable en el instante en que sus servidores abarcan múltiples regiones. timestamptz almacena un punto absoluto en el tiempo (normalizado a UTC) y lo muestra en la zona horaria de la sesión; timestamp almacena una lectura de reloj de pared sin ningún anclaje. Consulte la documentación de fecha y hora de Postgres. Para agendas y reservas, Postgres 18 también incorporó restricciones temporales: admite restricciones PRIMARY KEY, UNIQUE y de clave foránea sin solapamientos, especificadas con WITHOUT OVERLAPS y PERIOD. (Las claves foráneas temporales no admiten acciones de cascada ON DELETE/ON UPDATE, por lo que no conviene depender de cascadas en ese contexto.)
Sin documentación ni pruebas del esquema
Un esquema sin documentar ni probar es un error que se acumula silenciosamente. Los valores mágicos son el síntoma clásico: un status_code del 1 al 3 que se rompe el día que alguien añade el 4, sin ningún registro de lo que significa cada número. Los remedios son concretos:
- documente la intención directamente en la base de datos con
COMMENT ON COLUMN; - mantenga un diagrama ER en el repositorio; y
- pruebe las migraciones igual que prueba el código — aplíquelas, reviértalas y valide contra entradas límite e inválidas.
COMMENT ON COLUMN orders.status IS
'enum: pending|paid|shipped|cancelled — ver app/orders/status.ts';
Una restricción CHECK (status IN (...)) junto con un comentario convierte un entero frágil en una columna autodescriptiva y autoaplicada.
Conclusión
Cada error aquí se reduce a la misma solución: hacer que la base de datos — no la aplicación, ni el ORM — sea la autoridad sobre qué datos son válidos. Añada la clave foránea, el NOT NULL, el CHECK y la restricción UNIQUE antes de escribir la consulta que los da por sentados. Comience con su tabla de mayor escritura: liste las invariantes que actualmente aplica en el código y traslade cada una al esquema, donde no puedan omitirse.
Preguntas Frecuentes
¿Cuándo debería usar una clave primaria UUID en lugar de una columna bigint identity?
Use bigint identity cuando los IDs se generan dentro de una sola base de datos y no necesitan ser creados por los clientes, ya que ocupa 8 bytes, se indexa secuencialmente y es la opción más rápida. Use UUID, específicamente uuidv7() en Postgres 18, cuando genere IDs en el lado del cliente o entre múltiples servicios y necesite unicidad global. UUIDv7 mantiene una localidad de índice casi secuencial porque incorpora un prefijo de marca de tiempo, a diferencia del UUIDv4 aleatorio.
¿Declarar una clave foránea en mi ORM también crea una restricción a nivel de base de datos?
No necesariamente. Herramientas como Prisma y Drizzle pueden modelar una relación en el código de la aplicación sin emitir una restricción FOREIGN KEY a nivel de base de datos, por lo que la relación existe únicamente en la comprensión del ORM y no en el motor. Sin la restricción en la base de datos, un trabajo en segundo plano, una consulta manual u otro servicio pueden crear filas huérfanas que el ORM nunca anticipó. Verifique que su migración realmente genere una cláusula REFERENCES en lugar de confiar únicamente en la declaración de la relación.
¿Cuál es la diferencia entre timestamp y timestamptz en PostgreSQL?
timestamptz almacena un punto absoluto en el tiempo normalizado a UTC y lo muestra en la zona horaria de la sesión, mientras que timestamp almacena una lectura de reloj de pared sin ningún anclaje de zona horaria. Un timestamp simple registra silenciosamente lo que el servidor consideraba hora local, y esa ambigüedad se vuelve irrecuperable una vez que los servidores abarcan múltiples regiones. Use timestamptz para las fechas y horas de la aplicación, de modo que un valor siempre haga referencia a un instante único e inequívoco independientemente de dónde se lea.
¿Aún necesito la extensión pgcrypto o uuid-ossp para generar UUIDs en Postgres?
No. La función gen_random_uuid(), que produce un UUID de versión 4, forma parte del núcleo de PostgreSQL desde la versión 13, por lo que no se requiere ninguna extensión. La documentación de pgcrypto en PG18 ahora marca su propia gen_random_uuid() como obsoleta porque llama a la función del núcleo. A partir de Postgres 18 también se dispone de uuidv7() integrado para identificadores ordenados temporalmente y un alias uuidv4(), todo sin necesidad de instalar ninguna extensión.