En esta página

Normalización de bases de datos

14 min lectura TextoCap. 4 — Diseño y modelado

Normalización de bases de datos

La normalización es el proceso de diseñar un esquema de base de datos para reducir la redundancia de datos y prevenir anomalías de actualización, inserción y eliminación. Es la diferencia entre un esquema que crece limpiamente con el tiempo y uno que se convierte en una pesadilla de inconsistencias.

¿Por qué importa la normalización?

Considera esta tabla "todo en uno" que podrías diseñar si no conoces normalización:

-- ❌ Tabla mal diseñada: redundancias y anomalías
CREATE TABLE pedidos_mal (
  pedido_id         INTEGER,
  cliente_nombre    VARCHAR(150),  -- Repetido en cada pedido del mismo cliente
  cliente_email     VARCHAR(255),  -- ¿Qué pasa si cambia el email?
  cliente_ciudad    VARCHAR(100),  -- Redundante en cada fila
  producto_nombre   VARCHAR(200),  -- Un pedido con 5 productos = 5 filas
  producto_precio   NUMERIC(10,2), -- ¿Cuál es el precio actual del producto?
  categoria_nombre  VARCHAR(100),  -- Repetida para cada producto de esa categoría
  cantidad          INTEGER,
  total_pedido      NUMERIC(10,2)  -- Calculado: ¿y si alguien lo actualiza mal?
);

-- Ejemplo de datos con problemas:
INSERT INTO pedidos_mal VALUES
  (1, 'Ana García', '[email protected]', 'Madrid', 'Laptop Pro', 1299.99, 'Electrónica', 1, 1299.99),
  (1, 'Ana García', '[email protected]', 'Madrid', 'Mouse Pro',    49.99, 'Electrónica', 2,  199.98),
  (2, 'Ana García', '[email protected]', 'Madrid', 'Libro SQL', 35.99, 'Libros', 1, 35.99);
-- ¡El email de Ana aparece diferente en el pedido 2! Inconsistencia.

Los problemas de este diseño:

  • Anomalía de actualización: si Ana cambia su email, hay que actualizar todas sus filas (¿cuántas son?).
  • Anomalía de inserción: no puedes registrar un nuevo cliente sin que haga al menos un pedido.
  • Anomalía de eliminación: si se borra el único pedido de un cliente, también se pierde su información.
  • Redundancia: el nombre de la categoría se repite en cada producto, y en cada pedido de ese producto.

Primera Forma Normal (1NF)

Una tabla está en 1NF si:

  1. Cada columna contiene valores atómicos (indivisibles, no listas ni conjuntos).
  2. No hay grupos repetitivos de columnas.
  3. Cada fila es única (tiene una clave primaria).
-- ❌ Viola 1NF: columna con múltiples valores
CREATE TABLE clientes_mal (
  id      SERIAL PRIMARY KEY,
  nombre  VARCHAR(150),
  telefonos TEXT  -- '612345678, 698765432, 634567890' — ¡NO atómico!
);

-- ✅ Correcto: tabla separada para teléfonos
CREATE TABLE clientes (
  id     SERIAL PRIMARY KEY,
  nombre VARCHAR(150) NOT NULL,
  email  VARCHAR(255) NOT NULL UNIQUE
);

CREATE TABLE clientes_telefonos (
  id         SERIAL PRIMARY KEY,
  cliente_id INTEGER NOT NULL REFERENCES clientes(id) ON DELETE CASCADE,
  telefono   VARCHAR(20) NOT NULL,
  tipo       VARCHAR(20) DEFAULT 'movil'  -- 'movil', 'fijo', 'trabajo'
);

-- ❌ También viola 1NF: grupos repetitivos de columnas
CREATE TABLE pedidos_mal2 (
  pedido_id    INTEGER PRIMARY KEY,
  producto1_id INTEGER,
  producto1_qty INTEGER,
  producto2_id INTEGER,
  producto2_qty INTEGER,
  producto3_id INTEGER,
  producto3_qty INTEGER
  -- ¿Y si el pedido tiene 10 productos?
);

-- ✅ Correcto: tabla de líneas de pedido separada
CREATE TABLE pedidos_lineas (
  pedido_id   INTEGER NOT NULL REFERENCES pedidos(id),
  producto_id INTEGER NOT NULL REFERENCES productos(id),
  cantidad    INTEGER NOT NULL DEFAULT 1,
  precio_unit NUMERIC(10,2) NOT NULL,
  PRIMARY KEY (pedido_id, producto_id)
);

Segunda Forma Normal (2NF)

Una tabla está en 2NF si:

  1. Está en 1NF.
  2. Todos los atributos no-clave dependen de la clave primaria completa (no solo de parte de ella).

La 2NF solo es relevante cuando la clave primaria es compuesta (múltiples columnas):

-- ❌ Viola 2NF: la tabla tiene PK compuesta (pedido_id, producto_id)
-- pero 'nombre_producto' depende SOLO de producto_id, no de la PK completa
CREATE TABLE pedidos_detalle_mal (
  pedido_id       INTEGER,
  producto_id     INTEGER,
  nombre_producto VARCHAR(200),  -- Depende solo de producto_id (dependencia parcial)
  categoria       VARCHAR(100),  -- Depende solo de producto_id
  cantidad        INTEGER,
  precio_unit     NUMERIC(10,2),
  PRIMARY KEY (pedido_id, producto_id)
);

-- ✅ Correcto en 2NF: separar la información del producto
-- Los atributos del producto viven en la tabla 'productos'
-- La tabla intermedia solo tiene atributos que dependen de la PK completa
CREATE TABLE pedidos_items (
  pedido_id   INTEGER NOT NULL REFERENCES pedidos(id),
  producto_id INTEGER NOT NULL REFERENCES productos(id),
  cantidad    INTEGER NOT NULL,
  precio_unit NUMERIC(10,2) NOT NULL,  -- El precio al momento de la compra
  PRIMARY KEY (pedido_id, producto_id)
);
-- 'cantidad' y 'precio_unit' dependen del par (pedido, producto) completo
-- Los datos del producto (nombre, categoría) se consultan con JOIN

Tercera Forma Normal (3NF)

Una tabla está en 3NF si:

  1. Está en 2NF.
  2. No hay dependencias transitivas: ningún atributo no-clave depende de otro atributo no-clave.
-- ❌ Viola 3NF: dependencia transitiva
-- ciudad_nombre depende de ciudad_codigo, que NO es la clave primaria
CREATE TABLE empleados_mal (
  id            SERIAL PRIMARY KEY,
  nombre        VARCHAR(150),
  ciudad_codigo VARCHAR(10),   -- No es PK
  ciudad_nombre VARCHAR(100),  -- Depende de ciudad_codigo (transitiva)
  pais          VARCHAR(50)    -- Depende de ciudad_codigo (transitiva)
);

-- ✅ Correcto en 3NF: extraer la entidad 'ciudad'
CREATE TABLE ciudades (
  codigo VARCHAR(10)  PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,
  pais   VARCHAR(50)  NOT NULL
);

CREATE TABLE empleados (
  id           SERIAL     PRIMARY KEY,
  nombre       VARCHAR(150) NOT NULL,
  ciudad_codigo VARCHAR(10) REFERENCES ciudades(codigo)
);

-- ✅ Ejemplo más realista con empleados y departamentos
CREATE TABLE departamentos (
  id       SERIAL PRIMARY KEY,
  nombre   VARCHAR(100) NOT NULL,
  presupuesto NUMERIC(12,2),
  director_id INTEGER    -- Se llena después
);

CREATE TABLE empleados_normalizado (
  id              SERIAL     PRIMARY KEY,
  nombre          VARCHAR(150) NOT NULL,
  salario         NUMERIC(10,2) NOT NULL,
  departamento_id INTEGER      REFERENCES departamentos(id)
  -- el nombre y presupuesto del departamento están en la tabla departamentos
  -- no aquí (evita la dependencia transitiva)
);

Forma Normal de Boyce-Codd (BCNF)

BCNF es una versión más estricta de 3NF. Una tabla está en BCNF si para cada dependencia funcional X → Y, X es una superclave (puede determinar unívocamente cualquier fila).

En la práctica, la diferencia entre 3NF y BCNF solo aparece en casos con múltiples claves candidatas superpuestas, lo que es poco común. Para la mayoría de diseños empresariales, llegar a 3NF es suficiente.

Aplicar la normalización: caso práctico completo

Partimos de un Excel típico que nos da un cliente y lo llevamos a 3NF:

-- Excel original del cliente (todo en una tabla)
-- pedido | fecha       | cliente    | email           | ciudad  | producto     | cat     | precio | qty
-- 1001   | 2024-01-15  | Ana García | [email protected]    | Madrid  | Laptop Pro   | Electro | 1299   | 1
-- 1001   | 2024-01-15  | Ana García | [email protected]    | Madrid  | Mouse Inalám | Electro | 49.99  | 2
-- 1002   | 2024-01-20  | Luis Mart  | [email protected]   | Barcel  | Clean Code   | Libros  | 35.99  | 1

-- ✅ Diseño normalizado en 3NF:

CREATE TABLE categorias (
  id     SERIAL PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE productos (
  id           SERIAL        PRIMARY KEY,
  nombre       VARCHAR(200)  NOT NULL,
  precio_base  NUMERIC(10,2) NOT NULL CHECK (precio_base >= 0),
  categoria_id INTEGER       NOT NULL REFERENCES categorias(id)
);

CREATE TABLE clientes (
  id      SERIAL       PRIMARY KEY,
  nombre  VARCHAR(150) NOT NULL,
  email   VARCHAR(255) NOT NULL UNIQUE,  -- Email es clave candidata
  ciudad  VARCHAR(100)
);

CREATE TABLE pedidos (
  id         SERIAL      PRIMARY KEY,
  cliente_id INTEGER     NOT NULL REFERENCES clientes(id),
  fecha      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  estado     VARCHAR(20) NOT NULL DEFAULT 'pendiente'
);

CREATE TABLE pedidos_lineas (
  pedido_id   INTEGER     NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
  producto_id INTEGER     NOT NULL REFERENCES productos(id),
  cantidad    INTEGER     NOT NULL CHECK (cantidad > 0),
  precio_unit NUMERIC(10,2) NOT NULL,  -- Precio histórico (puede cambiar)
  PRIMARY KEY (pedido_id, producto_id)
);

-- Cargar los datos del Excel original
INSERT INTO categorias (nombre) VALUES ('Electrónica'), ('Libros');

INSERT INTO productos (nombre, precio_base, categoria_id) VALUES
  ('Laptop Pro',      1299.00, 1),
  ('Mouse Inalámbrico', 49.99, 1),
  ('Clean Code',        35.99, 2);

INSERT INTO clientes (nombre, email, ciudad) VALUES
  ('Ana García', '[email protected]', 'Madrid'),
  ('Luis Martínez', '[email protected]', 'Barcelona');

INSERT INTO pedidos (cliente_id, fecha) VALUES (1, '2024-01-15'), (2, '2024-01-20');

INSERT INTO pedidos_lineas (pedido_id, producto_id, cantidad, precio_unit) VALUES
  (1, 1, 1, 1299.00), (1, 2, 2, 49.99), (2, 3, 1, 35.99);

Desnormalización estratégica

La normalización es el punto de partida, pero a veces desnormalizar intencionalmente mejora el rendimiento de consultas críticas. La clave es hacerlo conscientemente, con una razón medible:

-- Caso: tabla de eventos de analytics con millones de filas por día
-- Hacer JOIN con clientes para obtener su ciudad en cada consulta es costoso

-- ❌ Normalizado (lento para analytics)
SELECT
  e.tipo_evento,
  c.ciudad,
  COUNT(*) AS eventos
FROM eventos e
JOIN clientes c ON e.cliente_id = c.id
WHERE e.fecha >= '2024-01-01'
GROUP BY e.tipo_evento, c.ciudad;

-- ✅ Desnormalizado (ciudad copiada en la tabla de eventos)
CREATE TABLE eventos (
  id          BIGSERIAL PRIMARY KEY,
  cliente_id  INTEGER   NOT NULL,
  ciudad      VARCHAR(100),  -- Desnormalización: copia de clientes.ciudad
  tipo_evento VARCHAR(50) NOT NULL,
  fecha       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Ahora la consulta analítica no necesita JOIN:
SELECT tipo_evento, ciudad, COUNT(*) AS eventos
FROM eventos
WHERE fecha >= '2024-01-01'
GROUP BY tipo_evento, ciudad;

-- ¿Y si cambia la ciudad del cliente?
-- En analytics histórico, generalmente NO actualizas los eventos pasados:
-- los eventos ocurrieron en el momento en que el cliente vivía en esa ciudad.

Resumen: guía de decisión

Situación Decisión
Datos maestros (clientes, productos, categorías) Normalizar a 3NF
Datos transaccionales (pedidos, pagos) Normalizar a 3NF, guardar precios históricos
Reportes OLAP de alto volumen Considerar desnormalización o vistas materializadas
Datos de auditoría/logs Desnormalizar (los datos históricos no cambian)
Datos con estructura variable JSONB puede ser apropiado

[!tip] Mide antes de desnormalizar: no desnormalices "por si acaso". Primero diseña en 3NF, luego mide el rendimiento real con datos reales. Si una consulta específica es demasiado lenta y los índices no son suficientes, evalúa la desnormalización solo para ese caso. Las vistas materializadas son frecuentemente una alternativa mejor: te dan el rendimiento de la desnormalización sin sacrificar la integridad del modelo normalizado.

La normalización es el fundamento del buen diseño de bases de datos. En la siguiente lección profundizaremos en los mecanismos de integridad: claves primarias, foráneas, restricciones CHECK, valores DEFAULT y cómo PostgreSQL garantiza que tus datos siempre sean válidos.


nextSteps:

  • slug: claves-y-restricciones label: "Siguiente: Claves y restricciones"