Parte V · Integración

19. SQL, modelado de datos e índices

Un ORM traduce objetos a filas, pero no piensa por ti. La base de datos es el único componente del stack que no puedes reiniciar sin consecuencias, el que guarda el valor real de la empresa y el que, cuando va mal, deja al descubierto todo lo que no sabías de SQL. Este capítulo baja al motor: modelo relacional, normalización, consultas de verdad, índices, planes de ejecución, transacciones y operación. PostgreSQL como referencia, con las diferencias de MySQL señaladas cuando importan.

COREAVANZADO Tiempo de lectura: ~150 min Prerrequisitos: capítulos 14 a 17 (MikroORM) o nociones básicas de bases de datos

19.1 Qué vas a poder hacer al terminar

19.1.1 Por qué necesitas SQL aunque uses un ORM

Es la pregunta razonable: si em.find(Task, { status: 'todo' }) funciona, ¿para qué aprender SQL? Por siete razones muy concretas, todas ellas problemas que aparecen en producción y no en desarrollo:

Analogía Un ORM es como un traductor simultáneo. Te permite negociar en un idioma que no hablas, y funciona perfectamente para las frases habituales. Pero si el contrato se complica, o si el traductor entiende algo a medias, el único que puede detectarlo es alguien que conozca el idioma. El ORM te ahorra escribir SQL; no te ahorra entenderlo.
El dominio de este capítulo Todos los ejemplos usan el esquema de TaskFlow, la aplicación que se construye a lo largo del libro: users (usuarios), teams (equipos), projects (proyectos), tasks (tareas), tags (etiquetas), task_tags (relación N:M entre tareas y etiquetas) y comments (comentarios, con jerarquía de respuestas). El motor de referencia es PostgreSQL 16; cuando MySQL 8 se comporta de otra forma, se indica de manera explícita.

19.2 Fundamentos del modelo relacional

El modelo relacional lo publicó Edgar F. Codd en 1970 en un artículo titulado A Relational Model of Data for Large Shared Data Banks. Su idea era radical para la época: en lugar de que el programa navegue por punteros entre registros, los datos se describen como relaciones matemáticas y se consultan de forma declarativa; el sistema decide cómo obtenerlos. Cincuenta y seis años después, ese desacoplamiento entre qué quieres y cómo se obtiene sigue siendo la razón por la que un optimizador puede reescribir tu consulta y hacerla mil veces más rápida sin que tú cambies una línea.

19.2.1 Terminología: la formal y la real

Término formalTérmino habitualDefinición precisa
RelaciónTablaConjunto de tuplas con el mismo esquema. Al ser un conjunto, en teoría no hay orden ni duplicados; SQL relaja ambas cosas y trabaja con multiconjuntos (bags).
TuplaFila, registroUn elemento de la relación: una combinación concreta de valores, uno por atributo.
AtributoColumna, campoUn par nombre–dominio. El nombre identifica; el dominio restringe.
DominioTipo de datoConjunto de valores permitidos para un atributo (integer, timestamptz, un ENUM...). Es la primera línea de defensa de la integridad.
GradoNúmero de columnasCuántos atributos tiene la relación.
CardinalidadNúmero de filasCuántas tuplas contiene en un instante dado.
SQL no es el modelo relacional SQL se desvía de la teoría en tres puntos que causan la mayoría de las sorpresas: permite filas duplicadas (una tabla sin clave primaria es un multiconjunto), el orden de las columnas y de las filas es observable (SELECT *, ORDER BY), y admite NULL, que Codd nunca planteó así y que rompe la lógica booleana clásica. Los tres puntos se tratan más abajo.

19.2.2 Claves: candidata, primaria, foránea, natural y subrogada

Natural frente a subrogada

CriterioClave natural (email, NIF, ISBN)Clave subrogada (id autoincremental, UUID)
SignificadoTiene sentido en el negocioNo significa nada, solo identifica
EstabilidadBaja: la gente cambia de email y las normativas cambian de formatoTotal: nunca hay motivo de negocio para cambiarla
TamañoVariable, a menudo grande; se propaga a todas las claves foráneas e índices4 u 8 bytes (int/bigint) o 16 (uuid)
JoinsMás costosos si es texto largoComparación de enteros, lo más rápido posible
ExposiciónFiltra información del negocioUn id secuencial revela volumen y permite enumeración
VeredictoÚsala como UNIQUE, no como PKPor defecto como PK, con la clave natural en un UNIQUE aparte

Recomendación profesional: clave primaria subrogada bigint generada por identidad, más una restricción UNIQUE sobre la clave natural. Así obtienes joins baratos, estabilidad total y, además, no pierdes la garantía de unicidad del negocio. El error habitual no es elegir mal la PK: es renunciar a la clave natural y acabar con tres usuarios con el mismo correo.

19.2.3 Integridad de entidad e integridad referencial

Codd definió dos reglas de integridad que hoy están integradas en cualquier motor:

«Las claves foráneas las valida la aplicación» Es el argumento con el que se justifica no declararlas, y es falso en tres frentes. Primero, la aplicación no es el único cliente de la base de datos: hay scripts, migraciones, consolas de administración y el becario con acceso a psql. Segundo, sin la restricción no hay atomicidad: entre tu SELECT de comprobación y tu INSERT cabe una transacción concurrente que borre la fila referenciada. Tercero, el optimizador usa las claves foráneas para eliminar joins innecesarios y para estimar cardinalidades. Renunciar a ellas es pagar por adelantado con corrupción de datos silenciosa.

Acciones referenciales: ON DELETE y ON UPDATE

AcciónQué hace al borrar/actualizar la fila referenciadaCuándo usarla en TaskFlow
NO ACTIONRechaza la operación. Es el valor por defecto y la comprobación se puede diferir al final de la transacción.Por defecto, si no tienes un motivo mejor
RESTRICTIgual que NO ACTION pero no se puede diferir: falla inmediatamente.comments.author_id: no quieres perder la autoría
CASCADEPropaga el borrado (o la actualización) a las filas hijas.task_tags, comments.task_id: son partes de la tarea
SET NULLPone la clave foránea a NULL. Requiere que la columna lo admita.tasks.assignee_id: si se borra el usuario, la tarea queda sin asignar
SET DEFAULTPone el valor por defecto de la columna, que debe existir en la tabla padre.Raro; útil con una fila «sin categoría»
ON DELETE CASCADE: potente y peligroso

Un DELETE FROM teams WHERE id = 7 con cascadas bien puestas puede borrar el equipo, sus proyectos, sus tareas, sus etiquetas, sus relaciones y sus comentarios. Eso es exactamente lo que quieres… hasta el día en que alguien lo ejecuta sin WHERE. Dos salvaguardas: reserva CASCADE para lo que es realmente parte del padre (composición, no asociación) y usa borrado lógico (deleted_at) para las entidades con valor histórico.

Además, PostgreSQL no crea índice en la columna de la clave foránea. Sin él, cada borrado en la tabla padre provoca un recorrido secuencial completo de la tabla hija para comprobar la restricción. Es la causa número uno de borrados inexplicablemente lentos.

19.2.4 NULL y la lógica de tres valores

NULL no es un valor: es un marcador de ausencia de información. No es cero, no es la cadena vacía y no es false. Como no se sabe qué hay, cualquier comparación con él devuelve UNKNOWN, un tercer valor lógico. Y el WHERE solo deja pasar las filas cuya condición es TRUE: UNKNOWN se descarta igual que FALSE. Ahí empiezan todas las sorpresas.

  TABLAS DE VERDAD CON TRES VALORES (T = true, F = false, U = unknown/NULL)

     AND │  T    F    U            OR  │  T    F    U            NOT
    ─────┼───────────────         ─────┼───────────────         ─────┬─────
       T │  T    F    U              T │  T    T    T              T │  F
       F │  F    F    F              F │  T    F    U              F │  T
       U │  U    F    U              U │  T    U    U              U │  U

  Regla mnemotécnica:  AND se queda con el "peor" valor,  OR con el "mejor",
  y NOT U sigue siendo U  ->  por eso NOT IN con nulos no devuelve nada.
nulos.sql · ejecútalo entero: cada línea sorprende a alguien
-- 1) NULL no es igual a NULL: el resultado es NULL, que NO es TRUE
SELECT NULL = NULL          AS igual,          -- NULL
       NULL <> NULL         AS distinto,       -- NULL
       NULL IS NULL         AS es_nulo,        -- true
       NULL IS NOT DISTINCT FROM NULL AS seguro;  -- true  <- comparación "null-safe"

-- 2) Aritmética y concatenación: NULL contamina toda la expresión
SELECT 100 + NULL           AS suma,           -- NULL
       'hola ' || NULL      AS concat,         -- NULL
       concat('hola ', NULL) AS concat_fn,     -- 'hola '  <- concat() ignora nulos
       coalesce(NULL, NULL, 'valor por defecto') AS con_coalesce;

-- 3) La trampa de NOT IN. Esta consulta NUNCA devuelve filas:
SELECT 3 IN     (1, 2, NULL) AS dentro,        -- NULL (no false)
       3 NOT IN (1, 2, NULL) AS fuera;         -- NULL (no true)
-- Porque 3 NOT IN (1,2,NULL) equivale a 3<>1 AND 3<>2 AND 3<>NULL
--                                     = TRUE  AND TRUE  AND UNKNOWN = UNKNOWN

-- 4) Los agregados IGNORAN los nulos, salvo count(*)
-- tasks: 5 filas, de las cuales 2 tienen assignee_id NULL y 1 estimate_hours NULL
SELECT count(*)              AS filas,         -- 5  cuenta filas
       count(assignee_id)    AS asignadas,     -- 3  cuenta valores no nulos
       count(DISTINCT assignee_id) AS personas,-- 2
       avg(estimate_hours)   AS media,         -- media de 4 valores, no de 5
       sum(estimate_hours)   AS total;         -- NULL si TODAS fueran nulas

-- 5) sum() sobre cero filas devuelve NULL, no 0. Protégelo siempre:
SELECT coalesce(sum(estimate_hours), 0) AS total_seguro
FROM   tasks WHERE project_id = -1;            -- 0

-- 6) DISTINCT y GROUP BY sí tratan los nulos como iguales entre sí
SELECT DISTINCT assignee_id FROM tasks;        -- devuelve UNA fila con NULL

-- 7) UNIQUE permite varios NULL (son "distintos" para el índice)
--    Desde PostgreSQL 15 se puede cambiar: UNIQUE NULLS NOT DISTINCT

-- 8) La ordenación: en PostgreSQL los nulos van al final en ASC
SELECT title, due_at FROM tasks ORDER BY due_at ASC NULLS LAST;
antijoin.sqlINCORRECTO
-- Objetivo: usuarios que NO tienen ninguna tarea asignada.
-- Si UNA sola fila de tasks tiene assignee_id NULL,
-- la subconsulta devuelve un NULL y el resultado es
-- SIEMPRE cero filas. Sin error, sin aviso.
SELECT u.id, u.email
FROM   users u
WHERE  u.id NOT IN (SELECT t.assignee_id FROM tasks t);
antijoin.sqlCORRECTO
-- NOT EXISTS es inmune a los nulos: evalúa por filas,
-- no compara valores. Además el planificador lo ejecuta
-- como un Anti Join, que es la estrategia óptima.
SELECT u.id, u.email
FROM   users u
WHERE  NOT EXISTS (
         SELECT 1 FROM tasks t
         WHERE  t.assignee_id = u.id
       );

-- Alternativa equivalente y también correcta:
SELECT u.id, u.email
FROM   users u
LEFT JOIN tasks t ON t.assignee_id = u.id
WHERE  t.id IS NULL;
Diferencias con MySQL MySQL ofrece el operador <=> («null-safe equal»), equivalente al IS NOT DISTINCT FROM del estándar que implementa PostgreSQL. En cambio, MySQL ordena los nulos primero en orden ascendente y no admite la cláusula NULLS FIRST/LAST; hay que emular la ordenación con una expresión auxiliar. La lógica de tres valores es idéntica en ambos.

Cuándo NULL es la respuesta correcta y cuándo es un error de diseño

19.3 Álgebra relacional aplicada

El álgebra relacional es el conjunto de operaciones que definen qué se puede pedir a una base de datos relacional. No es un adorno académico: es el lenguaje interno del optimizador. Cuando PostgreSQL reescribe tu consulta —empujando un filtro hacia dentro de una subconsulta, convirtiendo un IN en un semi-join o eliminando un join redundante— lo que está haciendo son transformaciones algebraicas basadas en equivalencias demostrables.

OperaciónNotaciónQué haceSQL equivalente
Selecciónσcond(R)Filtra filas que cumplen una condiciónWHERE
Proyecciónπa,b(R)Selecciona columnas (y elimina duplicados)SELECT DISTINCT a, b
Renombradoρx(R)Da otro nombre a una relación o atributoAS
Producto cartesianoR × SCombina cada fila de R con cada fila de SCROSS JOIN, o FROM a, b sin condición
Join naturalR ⋈ SProducto filtrado por igualdad en los atributos comunesINNER JOIN … ON / USING
UniónR ∪ SFilas de R o de S, sin duplicadosUNION
DiferenciaR − SFilas de R que no están en SEXCEPT
IntersecciónR ∩ SFilas presentes en ambasINTERSECT
Semi-joinR ⋉ SFilas de R que tienen pareja en S, sin duplicarlasWHERE EXISTS, IN
Anti-joinR ▷ SFilas de R que no tienen pareja en SWHERE NOT EXISTS
DivisiónR ÷ SFilas de R emparejadas con todos los elementos de SDoble NOT EXISTS o GROUP BY … HAVING count(*) = n

Tres consecuencias prácticas de que esto sea un álgebra:

algebra.sql · la misma pregunta expresada de cuatro formas equivalentes
-- Pregunta: proyectos del equipo 1 que tienen al menos una tarea urgente (priority = 1).

-- (a) Semi-join explícito con EXISTS: la forma más clara y la más eficiente.
SELECT p.id, p.name
FROM   projects p
WHERE  p.team_id = 1
  AND  EXISTS (SELECT 1 FROM tasks t WHERE t.project_id = p.id AND t.priority = 1);

-- (b) Con IN: equivalente aquí porque tasks.project_id es NOT NULL.
SELECT p.id, p.name
FROM   projects p
WHERE  p.team_id = 1
  AND  p.id IN (SELECT t.project_id FROM tasks t WHERE t.priority = 1);

-- (c) Con JOIN: cuidado, DUPLICA proyectos (uno por tarea urgente).
--     Hay que añadir DISTINCT, lo que obliga al motor a ordenar o a agrupar.
SELECT DISTINCT p.id, p.name
FROM   projects p
JOIN   tasks t ON t.project_id = p.id AND t.priority = 1
WHERE  p.team_id = 1;

-- (d) División relacional: proyectos que tienen tareas en TODOS los estados posibles.
SELECT p.id, p.name
FROM   projects p
WHERE  NOT EXISTS (
         SELECT 1
         FROM   unnest(enum_range(NULL::task_status)) AS s(estado)
         WHERE  NOT EXISTS (
                  SELECT 1 FROM tasks t
                  WHERE  t.project_id = p.id AND t.status = s.estado
                )
       );
El criterio para elegir entre las tres primeras Si solo quieres filtrar la tabla de la izquierda, usa EXISTS: expresa la intención exacta (semi-join), no duplica filas y permite al motor parar en cuanto encuentra la primera coincidencia. Usa JOIN cuando necesites datos de la tabla de la derecha. El DISTINCT defensivo sobre un join es casi siempre la señal de que lo que querías era un EXISTS.

19.4 DDL: definir el esquema

El esquema es el contrato más duradero de tu sistema. El código se reescribe cada dos años; los datos y su estructura sobreviven a varias reescrituras. Merece la pena diseñarlo con cuidado.

schema.sql · esquema completo de TaskFlow
-- Extensiones que usaremos
CREATE EXTENSION IF NOT EXISTS pg_trgm;      -- búsqueda por similitud
CREATE EXTENSION IF NOT EXISTS citext;       -- texto sin distinguir mayúsculas

CREATE TABLE users (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email         citext NOT NULL UNIQUE,          -- citext: 'Ana@x.com' = 'ana@x.com'
  password_hash text   NOT NULL,
  full_name     text   NOT NULL,
  is_active     boolean NOT NULL DEFAULT true,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz,
  deleted_at    timestamptz,
  CONSTRAINT users_full_name_not_blank CHECK (length(btrim(full_name)) > 0)
);

CREATE TABLE teams (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name       text NOT NULL,
  slug       text NOT NULL UNIQUE,
  created_at timestamptz NOT NULL DEFAULT now()
);

-- Entidad pivote CON atributos: no es un simple M:N
CREATE TABLE team_members (
  team_id   uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  user_id   uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role      text NOT NULL DEFAULT 'member',
  joined_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (team_id, user_id),                       -- clave compuesta natural
  CONSTRAINT team_members_role_valid CHECK (role IN ('owner', 'admin', 'member', 'guest'))
);

CREATE TABLE projects (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id     uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  name        text NOT NULL,
  description text,
  archived_at timestamptz,
  created_at  timestamptz NOT NULL DEFAULT now(),
  -- El nombre es único DENTRO del equipo, no globalmente
  CONSTRAINT projects_team_name_unique UNIQUE (team_id, name)
);

CREATE TYPE task_status AS ENUM ('open', 'in_progress', 'done', 'cancelled');

CREATE TABLE tasks (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  project_id   uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  assignee_id  uuid REFERENCES users(id) ON DELETE SET NULL,  -- si se va, la tarea queda huérfana
  title        varchar(180) NOT NULL,
  description  text,
  status       task_status NOT NULL DEFAULT 'open',
  priority     smallint NOT NULL DEFAULT 3,
  estimate_h   numeric(6,2),
  due_date     date,
  completed_at timestamptz,
  version      integer NOT NULL DEFAULT 1,          -- bloqueo optimista (capítulo 17)
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz,
  CONSTRAINT tasks_priority_range CHECK (priority BETWEEN 1 AND 5),
  -- Regla de negocio expresada en el esquema: si está hecha, tiene fecha de cierre
  CONSTRAINT tasks_done_needs_date CHECK (
    (status <> 'done') OR (completed_at IS NOT NULL)
  )
);

CREATE TABLE tags (
  id    serial PRIMARY KEY,
  name  citext NOT NULL UNIQUE,
  color char(7) NOT NULL DEFAULT '#5b8cff'
);

CREATE TABLE task_tags (
  task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  tag_id  integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (task_id, tag_id)
);

CREATE TABLE comments (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  task_id    bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  author_id  uuid   NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  parent_id  bigint REFERENCES comments(id) ON DELETE CASCADE,   -- auto-referencia: hilos
  body       text   NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
Cada CHECK es un test que se ejecuta siempre Una restricción en la base de datos no se puede olvidar, no depende de que el desarrollador use el ORM correctamente y protege también frente a scripts manuales y a migraciones de datos. Las validaciones de class-validator (capítulo 10) dan buenos mensajes de error al usuario; las restricciones del esquema garantizan que los datos nunca queden en un estado imposible. Necesitas las dos: no son redundantes, son capas distintas.

19.4.1 ON DELETE: la decisión que más se descuida

AcciónQué hace al borrar el padreCuándo usarla en TaskFlow
CASCADEBorra también los hijosComposición real: al borrar una tarea, sus comentarios y sus etiquetas asociadas no tienen sentido
SET NULLDeja la columna a NULLAgregación débil: al borrar un usuario, sus tareas quedan sin responsable, no se destruyen
RESTRICTImpide el borradoProtección: no permitir borrar un usuario que tiene comentarios (integridad histórica)
NO ACTIONComo RESTRICT, pero la comprobación puede diferirse al final de la transacciónPor defecto; útil con restricciones DEFERRABLE
SET DEFAULTPone el valor por defectoRaro; requiere que el valor por defecto exista en la tabla referenciada

19.5 Tipos de datos: elegir bien de entrada

NecesidadTipo recomendadoPor qué / qué evitar
Identificador secuencialbigint GENERATED ALWAYS AS IDENTITYEstándar SQL, sustituye a serial. Usa bigint desde el principio: migrar de int a bigint en una tabla de mil millones de filas es una noche muy larga
Identificador opaco o distribuidouuid (v7 si puedes)v4 es aleatorio y fragmenta el índice B-tree al insertar; v7 lleva marca de tiempo y es casi secuencial, así que se comporta como un entero
Dineronumeric(12,2) o entero de céntimosNunca float/double: 0.1 + 0.2 <> 0.3 en coma flotante binaria
TextotextEn PostgreSQL text y varchar rinden igual. Usa varchar(n) solo cuando el límite sea una regla de negocio real
Texto sin distinguir mayúsculascitextAlternativa: índice único sobre LOWER(col)
Instante en el tiempotimestamptzGuarda en UTC y convierte según la zona de la sesión. timestamp sin zona es una fuente inagotable de errores
Fecha sin horadateUn cumpleaños o un vencimiento no tienen zona horaria
Duracióninterval o entero de minutosinterval es expresivo; el entero es más fácil de agregar
Conjunto cerrado de valoresenum nativo o text + CHECKEl enum es compacto y validado, pero añadir valores requiere ALTER TYPE y quitarlos es doloroso. text + CHECK es más flexible
Datos flexiblesjsonbNunca json (guarda el texto literal, sin indexar). Y no lo uses para huir del modelado
Lista corta de escalarestext[]Cómodo, pero sin integridad referencial: si necesitas relacionar, usa una tabla
preciosINCORRECTO
CREATE TABLE invoices (
  id      serial PRIMARY KEY,
  amount  double precision NOT NULL,  -- coma flotante para dinero
  created timestamp NOT NULL          -- sin zona horaria
);

SELECT 0.1::float8 + 0.2::float8 = 0.3::float8;
-- false  ← y tu factura descuadra un céntimo cada mil líneas
preciosCORRECTO
CREATE TABLE invoices (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  amount     numeric(12,2) NOT NULL,
  currency   char(3) NOT NULL DEFAULT 'EUR',
  created_at timestamptz NOT NULL DEFAULT now()
);

SELECT 0.1::numeric + 0.2::numeric = 0.3::numeric;
-- true   ← aritmética decimal exacta

19.6 Normalización paso a paso

Normalizar es eliminar redundancia para que cada hecho se almacene una sola vez. La redundancia no molesta por ocupar espacio, sino porque permite que dos copias del mismo hecho se contradigan.

19.6.1 El punto de partida: una tabla mal diseñada

tabla sin normalizarINCORRECTO
CREATE TABLE pedidos_mal (
  pedido_id      int,
  cliente_email  text,
  cliente_nombre text,
  cliente_ciudad text,
  productos      text,          -- 'teclado, ratón, monitor'  ← varios valores en una celda
  precios        text,          -- '25.00, 12.50, 199.00'
  fecha          date
);

Esta tabla sufre las tres anomalías clásicas:

19.6.2 Las formas normales aplicadas

FormaReglaQué corregimos aquí
1FNValores atómicos, sin grupos repetidosproductos y precios pasan a ser filas de una tabla lineas_pedido
2FN1FN y ningún atributo depende de parte de una clave compuestaEn lineas_pedido(pedido_id, producto_id), el nombre del producto depende solo de producto_id: se va a productos
3FN2FN y ningún atributo depende de otro no clavecliente_nombre y cliente_ciudad dependen de cliente_email, no del pedido: se van a clientes
BCNFToda dependencia funcional parte de una superclaveCaso raro en la práctica; aparece con varias claves candidatas solapadas
resultado en 3FNCORRECTO
CREATE TABLE clientes (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email  citext NOT NULL UNIQUE,
  nombre text   NOT NULL,
  ciudad text
);

CREATE TABLE productos (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  nombre text NOT NULL,
  precio numeric(10,2) NOT NULL
);

CREATE TABLE pedidos (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cliente_id bigint NOT NULL REFERENCES clientes(id),
  fecha      date   NOT NULL DEFAULT current_date
);

CREATE TABLE lineas_pedido (
  pedido_id       bigint NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
  producto_id     bigint NOT NULL REFERENCES productos(id),
  cantidad        integer NOT NULL CHECK (cantidad > 0),
  -- Excepción deliberada: el precio se COPIA en la línea.
  -- No es redundancia: es un hecho histórico. Si mañana sube el precio del
  -- producto, la factura de ayer debe seguir diciendo lo que se cobró ayer.
  precio_unitario numeric(10,2) NOT NULL,
  PRIMARY KEY (pedido_id, producto_id)
);
Desnormalizar con criterio, no por pereza

Se desnormaliza después de medir, no antes. Los casos legítimos: contadores agregados (projects.task_count) para evitar un COUNT caro en cada listado, campos calculados que se consultan mucho, y copias históricas como el precio de la línea de pedido.

La condición innegociable es que exista un mecanismo que mantenga la copia sincronizada: un trigger, un suscriptor del ORM (capítulo 17) o un recálculo periódico. Un contador desnormalizado sin mantenimiento se convierte en un dato que miente, y un dato que miente es peor que no tener el dato.

19.7 Consultas: del SELECT a las funciones de ventana

19.7.1 El orden lógico de ejecución

Se escribe en un orden y se ejecuta en otro. Entender esto resuelve la mitad de las dudas de sintaxis.

  ORDEN EN QUE SE ESCRIBE        ORDEN EN QUE SE EVALÚA
  ───────────────────────        ──────────────────────
  SELECT   ...                   1. FROM / JOIN     ← se construye el conjunto de filas
  FROM     ...                   2. WHERE           ← se filtran filas individuales
  WHERE    ...                   3. GROUP BY        ← se agrupan
  GROUP BY ...                   4. HAVING          ← se filtran GRUPOS
  HAVING   ...                   5. SELECT          ← se calculan las columnas y los alias
  ORDER BY ...                   6. DISTINCT
  LIMIT    ...                   7. ORDER BY        ← aquí YA existen los alias del SELECT
                                 8. LIMIT / OFFSET

  Consecuencias prácticas:
  · No puedes usar un alias del SELECT en el WHERE (aún no existe).
  · Sí puedes usarlo en el ORDER BY (ya existe).
  · WHERE filtra filas; HAVING filtra grupos. No son intercambiables.
  · WHERE antes de agrupar es más barato que HAVING después: filtra cuanto antes.

19.7.2 Joins

  A = usuarios        B = tareas asignadas

  INNER JOIN            LEFT JOIN             LEFT JOIN ... WHERE b.id IS NULL
  ┌─────┬─────┐         ┌─────┬─────┐         ┌─────┬─────┐
  │  A  │██B██│         │█████│██B██│         │█████│  B  │
  │     │█████│         │█████│█████│         │█████│     │
  └─────┴─────┘         └─────┴─────┘         └─────┴─────┘
  solo coincidencias    todo A + lo que        solo A sin pareja
                        coincida de B          ("anti-join")

  FULL OUTER JOIN       CROSS JOIN
  ┌─────┬─────┐         cada fila de A con cada fila de B
  │█████│█████│         (producto cartesiano: |A| × |B| filas)
  │█████│█████│         Útil para generar combinaciones; peligroso por accidente.
  └─────┴─────┘
joins sobre TaskFlow
-- 1. INNER: solo tareas que tienen responsable
SELECT t.id, t.title, u.full_name
FROM   tasks t
JOIN   users u ON u.id = t.assignee_id;

-- 2. LEFT: todas las tareas, con responsable o sin él
SELECT t.id, t.title, COALESCE(u.full_name, 'Sin asignar') AS responsable
FROM   tasks t
LEFT JOIN users u ON u.id = t.assignee_id;

-- 3. ANTI-JOIN: usuarios que no tienen ninguna tarea abierta
SELECT u.id, u.email
FROM   users u
LEFT JOIN tasks t ON t.assignee_id = u.id AND t.status = 'open'
WHERE  t.id IS NULL;
-- OJO: la condición sobre t va en el ON, no en el WHERE.
-- Si la pones en el WHERE, el LEFT JOIN se degrada a INNER JOIN.

-- 4. SELF JOIN: comentarios con su comentario padre
SELECT c.id, c.body, p.body AS respuesta_a
FROM   comments c
LEFT JOIN comments p ON p.id = c.parent_id;

-- 5. LATERAL: las 3 tareas más recientes DE CADA proyecto.
--    LATERAL permite que la subconsulta referencie la fila exterior.
SELECT p.name, t.title, t.created_at
FROM   projects p
CROSS JOIN LATERAL (
  SELECT t.title, t.created_at
  FROM   tasks t
  WHERE  t.project_id = p.id
  ORDER  BY t.created_at DESC
  LIMIT  3
) t;
El error del LEFT JOIN convertido en INNER Es probablemente el fallo de SQL más frecuente en producción. Al filtrar en el WHERE por una columna de la tabla de la derecha, las filas sin pareja (que tienen NULL en esa columna) se descartan, y con ellas desaparece todo el sentido del LEFT JOIN. La condición que restringe la tabla derecha va en el ON; la que restringe la izquierda, en el WHERE.

19.7.3 Agregación

agregados con matices
-- COUNT(*) cuenta filas; COUNT(col) ignora los NULL. No son lo mismo.
SELECT
  count(*)                                          AS total_tareas,
  count(assignee_id)                                AS con_responsable,
  count(*) - count(assignee_id)                     AS sin_responsable,
  count(DISTINCT assignee_id)                       AS personas_implicadas,
  -- FILTER es más legible que CASE WHEN dentro del agregado
  count(*) FILTER (WHERE status = 'done')           AS completadas,
  count(*) FILTER (WHERE due_date < current_date
                     AND status <> 'done')          AS vencidas,
  round(avg(estimate_h), 2)                         AS estimacion_media,
  -- avg() ignora los NULL: la media es sobre las tareas QUE TIENEN estimación
  sum(estimate_h)                                   AS horas_totales
FROM tasks
WHERE project_id = $1;

-- Resumen por proyecto, quedándonos solo con los proyectos grandes
SELECT   p.name,
         count(t.id)                              AS tareas,
         count(*) FILTER (WHERE t.status = 'done') AS hechas
FROM     projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name          -- agrupa por la clave primaria: p.name va "de regalo"
HAVING   count(t.id) >= 10     -- HAVING filtra GRUPOS
ORDER BY tareas DESC;

19.7.4 CTE y recursividad

árbol de comentarios con una CTE recursiva
WITH RECURSIVE hilo AS (
  -- Caso base: comentarios raíz de la tarea
  SELECT c.id, c.parent_id, c.body, c.author_id, 0 AS nivel,
         ARRAY[c.created_at] AS camino
  FROM   comments c
  WHERE  c.task_id = $1 AND c.parent_id IS NULL

  UNION ALL

  -- Paso recursivo: hijos de lo ya encontrado
  SELECT c.id, c.parent_id, c.body, c.author_id, h.nivel + 1,
         h.camino || c.created_at
  FROM   comments c
  JOIN   hilo h ON c.parent_id = h.id
  WHERE  h.nivel < 10          -- cortafuegos: evita una recursión infinita si hay un ciclo
)
SELECT repeat('    ', nivel) || body AS comentario, nivel
FROM   hilo
ORDER  BY camino;              -- ordena manteniendo la estructura del hilo

19.7.5 Funciones de ventana

Una función de ventana calcula un valor sobre un conjunto de filas relacionadas sin colapsarlas. Es la diferencia esencial con GROUP BY: conservas todas las filas y añades el cálculo.

ventanas en casos reales
-- 1. La tarea más reciente de cada proyecto (patrón "top-N por grupo")
SELECT *
FROM (
  SELECT t.*,
         row_number() OVER (PARTITION BY t.project_id ORDER BY t.created_at DESC) AS rn
  FROM   tasks t
) x
WHERE rn = 1;

-- Alternativa específica de PostgreSQL, más corta y a menudo más rápida:
SELECT DISTINCT ON (project_id) *
FROM   tasks
ORDER  BY project_id, created_at DESC;

-- 2. Ranking de personas por tareas completadas, con desempate
SELECT u.full_name,
       count(*) AS completadas,
       rank()       OVER (ORDER BY count(*) DESC) AS puesto,
       dense_rank() OVER (ORDER BY count(*) DESC) AS puesto_sin_huecos
FROM   tasks t
JOIN   users u ON u.id = t.assignee_id
WHERE  t.status = 'done'
GROUP  BY u.id, u.full_name;
-- rank() deja huecos tras un empate (1,1,3); dense_rank() no (1,1,2)

-- 3. Acumulado y comparación con el periodo anterior
SELECT dia,
       cerradas,
       sum(cerradas) OVER (ORDER BY dia)               AS acumulado,
       lag(cerradas) OVER (ORDER BY dia)               AS dia_anterior,
       cerradas - lag(cerradas, 1, 0) OVER (ORDER BY dia) AS variacion,
       round(avg(cerradas) OVER (ORDER BY dia
             ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 1) AS media_movil_7d
FROM (
  SELECT date_trunc('day', completed_at)::date AS dia, count(*) AS cerradas
  FROM   tasks
  WHERE  completed_at >= current_date - interval '90 days'
  GROUP  BY 1
) d
ORDER BY dia;

19.7.6 Escritura: ON CONFLICT, RETURNING y actualizaciones con join

DML avanzado
-- UPSERT: inserta o actualiza en una sola operación atómica.
-- Sin ON CONFLICT harían falta un SELECT y luego un INSERT o UPDATE,
-- con una condición de carrera entre ambos.
INSERT INTO tags (name, color)
VALUES ('urgente', '#ff5c7a')
ON CONFLICT (name) DO UPDATE
  SET color = EXCLUDED.color        -- EXCLUDED = la fila que se intentaba insertar
RETURNING id, name;

-- Ignorar duplicados sin error (inserción masiva idempotente)
INSERT INTO task_tags (task_id, tag_id)
SELECT $1, unnest($2::int[])
ON CONFLICT DO NOTHING;

-- UPDATE ... FROM: actualizar usando datos de otra tabla
UPDATE tasks t
SET    status = 'cancelled', updated_at = now()
FROM   projects p
WHERE  p.id = t.project_id
  AND  p.archived_at IS NOT NULL
  AND  t.status IN ('open', 'in_progress')
RETURNING t.id;                     -- devuelve lo modificado: útil para auditar

-- DELETE con subconsulta correlacionada
DELETE FROM comments c
WHERE NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = c.task_id);

19.8 Índices

19.8.1 Cómo funciona un B-tree

  Índice B-tree sobre tasks(due_date). Altura 3 para millones de filas:
  bastan 3 o 4 lecturas de página para localizar cualquier valor.

                        ┌──────────────────────┐
            raíz        │  2026-03  │  2026-09 │
                        └─────┬──────────┬─────┘
                  ┌───────────┘          └───────────┐
            ┌─────▼─────┐                      ┌─────▼─────┐
  internas  │ 01 │ 02 │…│                      │ 10 │ 11 │…│
            └──┬────────┘                      └────────┬──┘
               │                                        │
        ┌──────▼───────┐                        ┌───────▼──────┐
  hojas │fecha → ctid  │◄──── enlazadas ───────►│fecha → ctid  │
        │2026-01-03→…  │      entre sí          │2026-10-02→…  │
        └──────────────┘   (permite recorrer     └──────────────┘
                            rangos y ORDER BY
                            sin ordenar nada)

  Por eso un B-tree sirve para: =, <, <=, >, >=, BETWEEN, IN,
  ORDER BY, y LIKE 'prefijo%' (pero NO LIKE '%sufijo').

19.8.2 Tipos de índice y cuándo usarlos

TipoPara quéEjemplo en TaskFlow
B-treeIgualdad, rangos y ordenación. El 95 % de los casostasks(project_id, status)
GINValores que contienen muchos elementos: jsonb, arrays, texto completo, trigramasBúsqueda de texto en tasks.title
GiSTDatos geométricos, rangos, vecino más próximoSolapamiento de rangos de fechas
BRINTablas enormes con datos físicamente ordenados (series temporales). Índice diminutoTabla de auditoría por created_at
HashSolo igualdad. Rara vez mejor que un B-treeCasi nunca

19.8.3 Índices compuestos y la regla del prefijo izquierdo

índices bien diseñados
CREATE INDEX idx_tasks_project_status_due
  ON tasks (project_id, status, due_date);

-- Consultas que APROVECHAN el índice:
--   WHERE project_id = ?                              ← prefijo (1 columna)
--   WHERE project_id = ? AND status = ?               ← prefijo (2 columnas)
--   WHERE project_id = ? AND status = ? AND due_date < ?   ← completo
--   WHERE project_id = ? ORDER BY status, due_date    ← orden servido por el índice
--
-- Consultas que NO lo aprovechan (o solo parcialmente):
--   WHERE status = ?                     ← salta la primera columna: no hay prefijo
--   WHERE due_date < ?                   ← idem

-- REGLA DE ORDENACIÓN DE COLUMNAS:
--   1) columnas de igualdad (=) primero, de mayor a menor selectividad
--   2) después una única columna de rango (<, >, BETWEEN)
--   3) al final, columnas que solo se usan para ORDER BY
-- Tras una columna de rango, las siguientes ya no filtran en el índice.

-- ÍNDICE PARCIAL: mucho más pequeño y rápido si solo consultas un subconjunto
CREATE INDEX idx_tasks_activas
  ON tasks (project_id, due_date)
  WHERE status IN ('open', 'in_progress');

-- ÍNDICE SOBRE EXPRESIÓN: necesario si consultas con una función
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- Ahora sí usa índice:  WHERE lower(email) = lower($1)

-- ÍNDICE ÚNICO PARCIAL: la solución al soft delete (capítulo 17).
-- Permite reutilizar un email si la fila anterior está borrada lógicamente.
CREATE UNIQUE INDEX uq_users_email_vivos
  ON users (email) WHERE deleted_at IS NULL;

-- INCLUDE: permite un "index only scan" sin ampliar la clave de búsqueda
CREATE INDEX idx_tasks_lista
  ON tasks (project_id, created_at DESC) INCLUDE (title, status);

-- EN PRODUCCIÓN, siempre CONCURRENTLY: no bloquea las escrituras.
-- No puede ejecutarse dentro de una transacción (importante para las migraciones).
CREATE INDEX CONCURRENTLY idx_comments_task ON comments (task_id);
PostgreSQL no indexa automáticamente las claves foráneas Crea un índice en la columna de la clave primaria, pero no en las columnas que referencian a otras tablas. El resultado es que un DELETE en la tabla padre tiene que recorrer la tabla hija entera para comprobar la integridad, y que tus joins van por escaneo secuencial. Revisa que todas tus columnas *_id tengan índice. Es la optimización con mejor relación entre esfuerzo y resultado que existe.

19.8.4 Por qué el motor ignora tu índice

consultas que no pueden usar índiceINCORRECTO
-- Función sobre la columna indexada
WHERE lower(email) = 'ana@x.com'      -- sin índice de expresión
WHERE date(created_at) = '2026-05-01'
WHERE extract(year FROM due_date) = 2026

-- Comodín inicial
WHERE title LIKE '%informe%'

-- Tipos distintos: obliga a convertir cada fila
WHERE project_id = '123'              -- uuid comparado con texto

-- Negación y OR sobre columnas distintas
WHERE status <> 'done'
WHERE assignee_id = $1 OR project_id = $2
versiones indexablesCORRECTO
-- Índice de expresión, o citext en la columna
WHERE email = 'ana@x.com'::citext
-- Rango en lugar de función sobre la columna
WHERE created_at >= '2026-05-01'
  AND created_at <  '2026-05-02'
WHERE due_date BETWEEN '2026-01-01' AND '2026-12-31'

-- Trigramas con índice GIN para el comodín inicial
CREATE INDEX ON tasks USING gin (title gin_trgm_ops);

-- Tipos coherentes (parámetro tipado en el driver)
WHERE project_id = $1::uuid

-- Lista positiva; y para el OR, UNION de dos consultas indexadas
WHERE status IN ('open', 'in_progress', 'cancelled')

19.9 Leer un plan de ejecución

diagnóstico de una consulta lenta
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT t.id, t.title, u.full_name
FROM   tasks t
LEFT JOIN users u ON u.id = t.assignee_id
WHERE  t.project_id = '3f2a...'::uuid
  AND  t.status = 'open'
ORDER  BY t.due_date
LIMIT  50;
ANTES (sin índice adecuado)
────────────────────────────────────────────────────────────────────────
Limit  (cost=48210.5..48210.6 rows=50) (actual time=842.113..842.140 rows=50)
  ->  Sort  (cost=48210.5..48284.2 rows=29480) (actual time=842.110..842.124)
        Sort Key: t.due_date
        Sort Method: top-N heapsort  Memory: 41kB
        ->  Hash Left Join  (actual time=12.4..790.2 rows=29480)
              ->  Seq Scan on tasks t  (actual time=0.05..701.8 rows=29480)
                    Filter: (project_id = '3f2a...' AND status = 'open')
                    Rows Removed by Filter: 1970520      ← descarta 2 millones
                    Buffers: shared hit=1024 read=38112  ← 38k páginas de disco
Planning Time: 0.312 ms
Execution Time: 842.301 ms

DESPUÉS de: CREATE INDEX idx_tasks_activas ON tasks (project_id, status, due_date);
────────────────────────────────────────────────────────────────────────
Limit  (cost=0.56..38.20 rows=50) (actual time=0.041..0.212 rows=50)
  ->  Nested Loop Left Join  (actual time=0.039..0.198 rows=50)
        ->  Index Scan using idx_tasks_activas on tasks t
              (actual time=0.028..0.061 rows=50)
              Index Cond: (project_id = '3f2a...' AND status = 'open')
              Buffers: shared hit=8                      ← 8 páginas, todas en caché
        ->  Index Scan using users_pkey on users u  (actual time=0.002..0.002)
Planning Time: 0.284 ms
Execution Time: 0.267 ms                                 ← 3.150 veces más rápido

Nota: ya no aparece el nodo Sort. El índice entrega las filas ordenadas por
due_date, así que el LIMIT 50 puede parar en cuanto tiene 50 filas.
Lo que vesQué significaQué hacer
Seq Scan en tabla grandeLee la tabla enteraFalta índice, o el filtro es poco selectivo
Rows Removed by Filter enormeLee mucho y tira casi todoÍndice que incluya ese filtro
rows estimadas muy lejos de las realesEstadísticas obsoletas o correlación entre columnasANALYZE tabla; ajustar default_statistics_target
Sort Method: external merge Disk: …La ordenación no cupo en memoriaSubir work_mem o servir el orden con un índice
Nested Loop con muchas filasBucle sobre un conjunto grandeSuele indicar mala estimación; revisa estadísticas
read= alto frente a hit=Va a disco en lugar de a cachéMenos datos leídos (índice, proyección) o más memoria
Index Only ScanTodo lo necesario está en el índiceEs el mejor caso; se logra con INCLUDE
Cómo leer un plan sin agobiarse Se lee de dentro hacia fuera y de abajo arriba: el nodo más indentado se ejecuta primero. Ignora cost (son unidades arbitrarias del planificador) y mira actual time y rows. Busca el nodo que consume la mayor parte del tiempo y pregúntate por qué toca tantas filas. El 90 % de los problemas son un Seq Scan evitable o un Sort que un índice podría haber servido gratis.

19.10 Transacciones, MVCC y concurrencia

19.10.1 ACID, sin recitarlo de memoria

PropiedadQué garantizaEjemplo en TaskFlow
AtomicidadTodo o nadaCrear un proyecto con sus tres miembros iniciales: o se crean los cuatro registros o ninguno
ConsistenciaLas restricciones se cumplen al terminarNinguna tarea queda apuntando a un proyecto inexistente
AislamientoLas transacciones concurrentes no se estorbanDos usuarios cerrando tareas a la vez no ven estados intermedios del otro
DurabilidadLo confirmado sobrevive a una caídaTras el COMMIT, un corte de luz no pierde el dato (WAL)

19.10.2 MVCC: por qué en PostgreSQL leer no bloquea

  Control de concurrencia multiversión: cada UPDATE crea una VERSIÓN NUEVA
  de la fila en lugar de sobrescribirla.

  fila id=7   ┌──────────────────────────────────────────────┐
              │ v1  xmin=100  xmax=140   status='open'       │  ← versión antigua
              │ v2  xmin=140  xmax=null  status='done'       │  ← versión vigente
              └──────────────────────────────────────────────┘
                         ▲                      ▲
             Tx 120 (empezó antes)      Tx 150 (empezó después)
             ve v1: 'open'              ve v2: 'done'

  Consecuencias:
  · Los lectores NUNCA bloquean a los escritores, ni al revés.
  · Las versiones muertas ocupan espacio: de ahí VACUUM y el "bloat".
  · Una transacción abierta durante horas impide limpiar versiones muertas
    de TODA la base de datos. Por eso las transacciones largas son dañinas
    incluso si solo leen.

19.10.3 Niveles de aislamiento y anomalías

NivelLectura suciaLectura no repetibleFantasmasAnomalía de serialización
READ UNCOMMITTEDPosible (en PostgreSQL, no)PosiblePosiblePosible
READ COMMITTED (por defecto)NoPosiblePosiblePosible
REPEATABLE READNoNoNo (en PostgreSQL)Posible
SERIALIZABLENoNoNoNo
  ACTUALIZACIÓN PERDIDA con READ COMMITTED (el caso que más daño hace)

  Tiempo  Transacción A                    Transacción B
  ──────  ─────────────────────────────    ─────────────────────────────
   t1     BEGIN
   t2     SELECT saldo FROM cuentas        BEGIN
          WHERE id=1;      → 100
   t3                                      SELECT saldo FROM cuentas
                                           WHERE id=1;      → 100
   t4     UPDATE cuentas SET saldo=90
          WHERE id=1;
   t5     COMMIT
   t6                                      UPDATE cuentas SET saldo=80
                                           WHERE id=1;      ← pisa el cambio de A
   t7                                      COMMIT

  Resultado: 80. Se han gastado 10 + 20 = 30, pero el saldo dice que solo 20.
  El cargo de A se ha perdido y ninguna transacción ha fallado.

  SOLUCIONES
  1) Actualización atómica en la propia base de datos (la más simple):
       UPDATE cuentas SET saldo = saldo - 10 WHERE id=1 AND saldo >= 10;
  2) Bloqueo pesimista:  SELECT ... FOR UPDATE  (B espera a que A confirme)
  3) Bloqueo optimista:  columna version + WHERE version = ?  (capítulo 17)
  4) Aislamiento SERIALIZABLE + reintento al recibir error de serialización
bloqueos explícitos
BEGIN;
-- Bloquea las filas seleccionadas hasta el final de la transacción.
-- Otras transacciones que hagan FOR UPDATE sobre ellas esperarán.
SELECT * FROM tasks WHERE id = $1 FOR UPDATE;
UPDATE tasks SET status = 'in_progress' WHERE id = $1;
COMMIT;

-- Patrón de cola de trabajos dentro de la base de datos:
-- SKIP LOCKED hace que cada worker tome trabajos DISTINTOS sin esperar.
BEGIN;
WITH siguiente AS (
  SELECT id FROM jobs
  WHERE  status = 'pending'
  ORDER  BY created_at
  FOR UPDATE SKIP LOCKED
  LIMIT  1
)
UPDATE jobs j SET status = 'processing', started_at = now()
FROM   siguiente s WHERE j.id = s.id
RETURNING j.*;
COMMIT;
Interbloqueos (deadlocks) Ocurren cuando A bloquea la fila 1 y espera la 2 mientras B bloquea la 2 y espera la 1. PostgreSQL los detecta y aborta una de las dos transacciones con el error 40P01. Prevención: bloquear siempre los recursos en el mismo orden (por ejemplo, por identificador ascendente), mantener las transacciones cortas y reintentar la operación cuando el error sea de interbloqueo o de serialización.

19.11 Rendimiento y operación

19.12 Búsqueda de texto

tres niveles de búsqueda
-- Nivel 1: LIKE. Solo sirve con prefijo si quieres usar índice.
SELECT * FROM tasks WHERE title ILIKE 'informe%';

-- Nivel 2: trigramas. Tolera errores tipográficos y comodín inicial.
CREATE INDEX idx_tasks_title_trgm ON tasks USING gin (title gin_trgm_ops);
SELECT title, similarity(title, 'infrome mensal') AS parecido
FROM   tasks
WHERE  title % 'infrome mensal'        -- operador de similitud
ORDER  BY parecido DESC
LIMIT  10;

-- Nivel 3: búsqueda de texto completo con lematización en español.
ALTER TABLE tasks ADD COLUMN busqueda tsvector
  GENERATED ALWAYS AS (
    to_tsvector('spanish', coalesce(title,'') || ' ' || coalesce(description,''))
  ) STORED;
CREATE INDEX idx_tasks_busqueda ON tasks USING gin (busqueda);

SELECT id, title, ts_rank(busqueda, q) AS relevancia
FROM   tasks, plainto_tsquery('spanish', 'informes mensuales') q
WHERE  busqueda @@ q
ORDER  BY relevancia DESC
LIMIT  20;
-- 'informes mensuales' encuentra 'informe mensual': la lematización
-- reduce cada palabra a su raíz.

19.13 Datos semiestructurados con jsonb

jsonb con cabeza
ALTER TABLE tasks ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb;

-- Operadores esenciales
SELECT metadata -> 'origen'        AS origen_json,   -- devuelve jsonb
       metadata ->> 'origen'       AS origen_texto,  -- devuelve text
       metadata #>> '{cliente,id}' AS cliente_id     -- ruta anidada
FROM tasks;

-- Índice GIN para consultas de contención
CREATE INDEX idx_tasks_metadata ON tasks USING gin (metadata);
SELECT * FROM tasks WHERE metadata @> '{"origen": "email"}';

-- Si SIEMPRE consultas por el mismo campo, un índice de expresión B-tree
-- es más pequeño y rápido que el GIN completo:
CREATE INDEX idx_tasks_origen ON tasks ((metadata ->> 'origen'));
Cuándo jsonb es una trampa Es excelente para datos genuinamente variables: la carga útil de un webhook, preferencias de usuario, un formulario definido por el cliente. Es una mala idea cuando lo usas para no decidir el esquema: dentro de un jsonb no hay claves foráneas, ni tipos, ni NOT NULL, ni restricciones, las consultas son más lentas y las migraciones se convierten en scripts de transformación. La regla: si un campo lo consultas o lo filtras a menudo, merece ser una columna.

19.14 Seguridad de la base de datos

orden dinámicoINCORRECTO
// El parámetro llega del cliente y se concatena.
// ?orden=id; DROP TABLE tasks; --
const sql = `SELECT * FROM tasks ORDER BY ${orden}`;
await em.getConnection().execute(sql);
orden dinámicoCORRECTO
// Los identificadores no se pueden parametrizar:
// hay que validarlos contra una lista blanca.
const COLUMNAS = { titulo: 'title', creada: 'created_at' } as const;
const col = COLUMNAS[orden as keyof typeof COLUMNAS] ?? 'created_at';
const dir = dirParam === 'desc' ? 'DESC' : 'ASC';
await em.getConnection().execute(
  `SELECT * FROM tasks WHERE project_id = ? ORDER BY ${col} ${dir}`,
  [projectId],       // los VALORES sí van parametrizados
);

19.14.1 Row Level Security para multi-tenancy

rls.sql
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY projects_por_equipo ON projects
  USING (team_id = current_setting('app.team_id', true)::uuid);

-- En cada petición, la aplicación fija la variable de sesión
-- (con RequestContext de MikroORM, capítulo 14):
SET LOCAL app.team_id = '3f2a...';
-- A partir de aquí, cualquier consulta a projects solo ve las filas de ese equipo,
-- aunque el desarrollador olvide el WHERE.

19.15 Cargas masivas y migración de datos

importar millones de filas
-- COPY es un orden de magnitud más rápido que INSERT fila a fila
COPY tasks (project_id, title, status, created_at)
FROM '/tmp/tareas.csv' WITH (FORMAT csv, HEADER true);

-- Estrategia para una carga inicial grande:
-- 1. Crear la tabla SIN índices secundarios ni claves foráneas
-- 2. COPY de los datos
-- 3. Crear los índices (una sola pasada ordenada, mucho más rápido que
--    mantenerlos actualizados fila a fila)
-- 4. Añadir las claves foráneas
-- 5. ANALYZE para actualizar las estadísticas

-- Actualizaciones masivas: por lotes, no de golpe.
-- Un UPDATE de 10 millones de filas mantiene bloqueos y genera un WAL enorme.
UPDATE tasks SET status = 'cancelled'
WHERE  id IN (SELECT id FROM tasks WHERE status = 'open'
                AND created_at < '2020-01-01' LIMIT 10000);
-- Repetir hasta que no afecte a ninguna fila.

19.16 Correspondencia con MikroORM

Concepto del ORMQué ocurre en la base de datosRiesgo a vigilar
em.find(Task, {})SELECT con WHERESin limit, trae la tabla entera
populate: ['tags']Segundo SELECT ... WHERE id IN (...) o un JOINSin populate: N+1
flush()BEGIN + inserts/updates agrupados + COMMITOrden de operaciones y bloqueos
em.transactional()BEGINCOMMIT/ROLLBACKTransacciones largas
@Property({ version: true })UPDATE … WHERE version = ?Hay que gestionar el 409
@Unique()CREATE UNIQUE INDEXColisión con el soft delete
findAndCountDos consultas: datos y COUNT(*)El COUNT es caro en tablas enormes
em.nativeUpdateUPDATE directoNo pasa por el Unit of Work ni por los hooks
@Filter (soft delete)AND deleted_at IS NULL añadidoNo se aplica al SQL nativo
La costumbre que más te hará mejorar Activa el registro de SQL en desarrollo (debug: true en la configuración de MikroORM) y mira lo que ejecuta tu código. Descubrirás N+1, consultas sin LIMIT y joins inesperados en la primera tarde. Es el hábito que separa a quien usa un ORM de quien lo domina.

19.17 Modelado de casos difíciles

Los esquemas de los tutoriales siempre modelan un blog con usuarios y artículos. Estos cuatro casos son los que aparecen de verdad en un producto y los que separan un modelo que aguanta de uno que hay que rehacer al año.

19.17.1 Jerarquías y árboles

TécnicaCómo funcionaBuena paraCoste
Lista de adyacenciaColumna parent_idEscrituras y movimientos; es lo más simpleLeer el árbol completo exige recursión
Enumeración de caminosColumna con la ruta: /1/7/23/Leer todos los descendientes con un LIKE 'ruta%'Mover una rama obliga a reescribir todas sus rutas
Conjuntos anidadosColumnas izquierda y derechaLecturas muy rápidas de subárbolesCualquier inserción reordena media tabla
Tabla de cierreTabla con todos los pares ancestro-descendienteConsultas en cualquier dirección y a cualquier profundidadEspacio y mantenimiento de la tabla auxiliar

El consejo práctico: empieza siempre por la lista de adyacencia, que es lo que hace el hilo de comentarios de TaskFlow, y resuelve las lecturas con una CTE recursiva. Solo si mides que esas consultas son un problema real merece la pena pasar a una tabla de cierre, que es la alternativa más equilibrada de las tres restantes.

19.17.2 Historial y datos temporales

tabla de auditoría con trigger
-- Pregunta previa que decide el diseño: ¿necesitas saber cómo estaba
-- un registro en una fecha pasada, o solo quién cambió qué y cuándo?

-- Opción A: bitácora de cambios (responde "quién y cuándo")
CREATE TABLE tasks_audit (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  task_id    bigint NOT NULL,
  operacion  char(1) NOT NULL,            -- I, U, D
  cambios    jsonb   NOT NULL,            -- solo lo que cambió
  actor_id   uuid,
  ocurrido   timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION auditar_tasks() RETURNS trigger AS $$
BEGIN
  INSERT INTO tasks_audit (task_id, operacion, cambios, actor_id)
  VALUES (
    COALESCE(NEW.id, OLD.id),
    left(TG_OP, 1),
    CASE WHEN TG_OP = 'UPDATE'
         -- solo los campos que realmente cambiaron
         THEN to_jsonb(NEW) - (SELECT array_agg(key)
                                 FROM jsonb_each(to_jsonb(NEW)) n
                                 JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
                                WHERE n.value = o.value)::text[]
         ELSE to_jsonb(COALESCE(NEW, OLD)) END,
    current_setting('app.user_id', true)::uuid
  );
  RETURN COALESCE(NEW, OLD);
END; $$ LANGUAGE plpgsql;

CREATE TRIGGER trg_auditar_tasks
AFTER INSERT OR UPDATE OR DELETE ON tasks
FOR EACH ROW EXECUTE FUNCTION auditar_tasks();

-- Opción B: versionado temporal (responde "cómo estaba el 3 de marzo").
-- Cada fila tiene un intervalo de validez y solo una está vigente.
CREATE TABLE precios (
  producto_id bigint NOT NULL,
  importe     numeric(10,2) NOT NULL,
  validez     tstzrange NOT NULL,
  EXCLUDE USING gist (producto_id WITH =, validez WITH &&)
  -- ↑ la restricción de exclusión impide que dos periodos se solapen
);
SELECT importe FROM precios
 WHERE producto_id = 1 AND validez @> '2026-03-03'::timestamptz;

19.17.3 Multi-tenencia

Ya vimos Row Level Security como mecanismo de aislamiento. Lo que hay que decidir antes es el modelo, y la decisión es difícil de revertir:

19.17.4 Campos definidos por el usuario

El patrón entidad-atributo-valor y por qué evitarlo Cuando el cliente pide «que cada empresa pueda definir sus propios campos», la tentación es una tabla (entidad_id, atributo, valor). Funciona, pero pierdes tipos, restricciones e integridad referencial, y cualquier consulta que combine tres atributos se convierte en tres autojoins ilegibles y lentos. Hoy la alternativa razonable en PostgreSQL es una columna jsonb con un índice GIN, más una tabla que describa los campos permitidos por inquilino para poder validar en la aplicación. Mantiene la flexibilidad sin destrozar el modelo relacional del resto del sistema.

19.18 Herramientas del día a día

consultas de diagnóstico que conviene tener a mano
-- 1. Las consultas que más tiempo total consumen (requiere pg_stat_statements).
--    Ordenar por tiempo TOTAL y no por tiempo medio: una consulta de 5 ms
--    ejecutada un millón de veces duele más que una de 2 s ejecutada una vez.
SELECT substr(query, 1, 80) AS consulta,
       calls,
       round(total_exec_time::numeric, 1)      AS ms_total,
       round(mean_exec_time::numeric, 2)       AS ms_medio,
       rows
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
LIMIT  20;

-- 2. Índices que nunca se usan: coste puro en escrituras y espacio.
SELECT relname AS tabla, indexrelname AS indice,
       pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
FROM   pg_stat_user_indexes
WHERE  idx_scan = 0
  AND  indexrelid NOT IN (SELECT conindid FROM pg_constraint)
ORDER  BY pg_relation_size(indexrelid) DESC;

-- 3. Tablas que necesitan índice: muchas lecturas secuenciales.
SELECT relname, seq_scan, seq_tup_read, idx_scan,
       seq_tup_read / GREATEST(seq_scan, 1) AS filas_por_escaneo
FROM   pg_stat_user_tables
WHERE  seq_scan > 1000
ORDER  BY seq_tup_read DESC;

-- 4. Transacciones abiertas demasiado tiempo: el enemigo silencioso
--    que impide limpiar versiones muertas en toda la base de datos.
SELECT pid, state, now() - xact_start AS duracion,
       substr(query, 1, 60) AS consulta
FROM   pg_stat_activity
WHERE  state <> 'idle' AND xact_start < now() - interval '1 minute'
ORDER  BY xact_start;

-- 5. Bloqueos: quién espera a quién.
SELECT bloqueada.pid AS espera, bloqueante.pid AS bloquea,
       substr(bloqueada.query, 1, 50) AS consulta_en_espera
FROM   pg_stat_activity bloqueada
JOIN   pg_stat_activity bloqueante
       ON bloqueante.pid = ANY(pg_blocking_pids(bloqueada.pid))
WHERE  cardinality(pg_blocking_pids(bloqueada.pid)) > 0;

-- 6. Tamaño real de cada tabla, con sus índices.
SELECT relname,
       pg_size_pretty(pg_total_relation_size(relid)) AS total,
       pg_size_pretty(pg_relation_size(relid))       AS solo_datos,
       n_live_tup AS filas_vivas, n_dead_tup AS filas_muertas
FROM   pg_stat_user_tables
ORDER  BY pg_total_relation_size(relid) DESC
LIMIT  15;
El hábito que más rendimiento te va a dar Activa pg_stat_statements el primer día del proyecto y míralo una vez al mes. En diez minutos sabrás exactamente en qué se va el tiempo de tu base de datos, y casi siempre serán dos o tres consultas que ni sospechabas, normalmente generadas por el ORM en un listado que alguien añadió sin pensar. Optimizar a ciegas es perder el tiempo; esta tabla te dice dónde mirar.

19.19 Errores comunes y cómo solucionarlos

ErrorCausaSolución
Listado lento que empeora con el tiempoFalta índice; escaneo secuencialEXPLAIN ANALYZE e índice compuesto acorde al WHERE y al ORDER BY
LEFT JOIN que se comporta como INNERFiltro de la tabla derecha en el WHEREMover la condición al ON
Resultados duplicadosJOIN con una relación 1:NEXISTS si solo filtras; agregación si necesitas datos
NOT IN que no devuelve nadaLa subconsulta contiene NULLNOT EXISTS, o excluir los nulos
Paginación que repite o salta filasORDER BY no deterministaAñadir un desempate único (, id)
OFFSET 100000 tarda segundosEl motor genera y descarta 100.000 filasPaginación por cursor (capítulo 16)
Descuadres de céntimosDinero en coma flotantenumeric o enteros de céntimos
Fechas desplazadas una horatimestamp sin zonatimestamptz y almacenar en UTC
could not obtain lock / interbloqueosOrden de bloqueo incoherenteOrdenar los bloqueos y acortar las transacciones
La base de datos crece sin borrar nadaBloat por versiones muertas y transacciones abiertasRevisar pg_stat_activity, ajustar autovacuum
too many connectionsPool × réplicas por encima del límiteReducir el pool, PgBouncer
El índice existe pero no se usaFunción sobre la columna, tipos distintos o baja selectividadÍndice de expresión, tipos coherentes, o aceptar el escaneo

19.20 Buenas y malas prácticas

Haz esto

  • Restricciones en el esquema, no solo en la aplicación: NOT NULL, CHECK, UNIQUE y claves foráneas.
  • Índice en toda columna de clave foránea. PostgreSQL no lo crea por ti.
  • timestamptz y UTC en todo lo que sea un instante.
  • Nombra las restricciones explícitamente: así el error dice algo útil y puedes mapearlo a un mensaje de negocio.
  • Lee el plan de las cinco consultas más frecuentes de tu aplicación al menos una vez.
  • Pagina siempre, con un orden estable y determinista.
  • Transacciones cortas y sin llamadas de red dentro.
  • Migraciones compatibles hacia atrás (expand/contract, capítulo 17).

Evita esto

  • SELECT * en producción: trae columnas que no usas e impide los index-only scans.
  • Concatenar entradas del usuario en SQL, ni siquiera en un ORDER BY.
  • Coma flotante para dinero.
  • Índices "por si acaso": cada uno ralentiza las escrituras y ocupa espacio.
  • Lógica de negocio en triggers como norma: es invisible desde el código y difícil de depurar.
  • jsonb para huir del modelado.
  • Borrar físicamente sin pensarlo cuando hay obligaciones de auditoría.
  • Confiar en una copia de seguridad que nunca has restaurado.

19.21 Preguntas frecuentes

¿WHERE o HAVING?
WHERE filtra filas antes de agrupar; HAVING filtra grupos después. Si la condición no involucra un agregado, va en el WHERE: filtrar antes es más barato porque hay menos filas que agrupar. HAVING count(*) > 5 solo puede ir en el HAVING.
¿EXISTS, IN o JOIN?
Si solo quieres filtrar la tabla de la izquierda, usa EXISTS: no duplica filas y el motor puede parar en la primera coincidencia. Usa JOIN cuando necesites columnas de la otra tabla. IN con una lista literal corta está bien; NOT IN con una subconsulta es peligroso porque devuelve conjunto vacío si aparece un NULL: usa NOT EXISTS.
¿Clave natural o subrogada?
Subrogada (un bigint o un uuid sin significado) casi siempre: las claves naturales cambian (el email de un usuario, el NIF de una empresa) y arrastran el cambio a todas las tablas que las referencian. Eso no impide poner además una restricción UNIQUE sobre la clave natural para garantizar la integridad. La excepción razonable son las tablas puente, donde la clave compuesta de las dos claves foráneas es natural y estable.
¿uuid o entero autoincremental?
El entero es más compacto (8 bytes frente a 16), secuencial y amable con el índice. El UUID no revela cuántos registros tienes (los identificadores secuenciales son enumerables por un atacante), se puede generar en el cliente y no colisiona entre sistemas distribuidos. Si eliges UUID, prefiere la versión 7: incorpora una marca de tiempo, por lo que es casi monótono y evita la fragmentación del B-tree que causa la v4 puramente aleatoria.
¿Por qué mi consulta va rápida en desarrollo y lenta en producción?
Casi siempre por el volumen: con mil filas un escaneo secuencial es instantáneo; con diez millones, no. También influyen las estadísticas (un plan distinto), la caché (en desarrollo todo está en memoria), la concurrencia y la configuración del servidor. Reproduce el problema con datos de tamaño realista y compara los planes de ambos entornos.
¿Cuántos índices son demasiados?
No hay número mágico, pero cada índice se actualiza en cada INSERT, UPDATE y DELETE que toque sus columnas, y ocupa espacio en disco y en caché. Una tabla con más de seis u ocho índices merece revisión. Consulta pg_stat_user_indexes para encontrar los que nunca se usan (idx_scan = 0) y elimínalos: son coste puro.
¿Debo usar vistas?
Las vistas normales son azúcar sintáctico: encapsulan una consulta pero no guardan datos, y anidarlas en varios niveles produce planes difíciles de optimizar. Las vistas materializadas sí almacenan el resultado y son excelentes para informes que se consultan mucho y toleran datos con minutos de retraso, siempre que planifiques su REFRESH (preferiblemente REFRESH MATERIALIZED VIEW CONCURRENTLY).
¿Los triggers son buena idea?
Para invariantes de datos que deben cumplirse pase lo que pase (auditoría, marcas de tiempo, mantener un contador desnormalizado), sí: se ejecutan aunque alguien modifique la tabla desde psql. Para lógica de negocio, no: es código invisible desde la aplicación, difícil de testear, de versionar y de depurar; alguien pasará una tarde entera buscando por qué un campo cambia solo.
¿Qué es exactamente el bloat y por qué debería importarme?
Con MVCC, cada actualización deja una versión muerta de la fila. VACUUM recupera ese espacio para reutilizarlo. Si el autovacuum no da abasto (tablas con muchísimas actualizaciones) o si una transacción lleva horas abierta impidiendo la limpieza, la tabla y sus índices crecen con espacio inútil: se leen más páginas para los mismos datos y todo se ralentiza. Vigila las transacciones largas antes que cualquier otra cosa.
¿Merece la pena SERIALIZABLE?
Da la garantía más fuerte (el resultado equivale a ejecutar las transacciones una tras otra) a cambio de que algunas aborten con un error de serialización que tu código debe reintentar. Es la opción correcta para operaciones financieras o de inventario con reglas complejas que involucran varias filas. Para el CRUD habitual, READ COMMITTED más una actualización atómica o un bloqueo optimista resuelve el problema con menos coste.
¿Cómo cuento filas de forma eficiente en una tabla enorme?
COUNT(*) exacto obliga a recorrer todas las filas visibles: en tablas de decenas de millones puede tardar segundos. Alternativas: una estimación con reltuples de pg_class (suficiente para «unos 2,3 millones de resultados»), un contador desnormalizado mantenido por trigger, o directamente no mostrar el total y usar paginación por cursor con un «cargar más».
¿Necesito saber esto si el ORM me lo hace todo?
El ORM escribe el SQL, pero no decide por ti el modelo de datos, los índices, el nivel de aislamiento ni la estrategia de paginación, y no te avisa de que acabas de lanzar 400 consultas. Todos los problemas graves de rendimiento y de integridad que verás en tu carrera se diagnostican y se resuelven en esta capa. El ORM es la comodidad; el SQL es el entendimiento.

19.22 Ejercicios

Nivel 1 · consultas básicas

SQL-01 Lista el título y el nombre del responsable de todas las tareas abiertas del proyecto $1, incluidas las que no tienen responsable, ordenadas por fecha de vencimiento con los nulos al final. (Pista: NULLS LAST.)

SQL-02 Cuenta cuántas tareas hay en cada estado para un equipo, mostrando también los estados sin ninguna tarea.

SQL-03 Encuentra los usuarios que no han comentado nunca. Resuélvelo de dos formas distintas (NOT EXISTS y anti-join) y compara los planes con EXPLAIN.

SQL-04 Muestra los cinco proyectos con más tareas vencidas (fecha de vencimiento pasada y estado distinto de done).

Nivel 2 · agregación y ventanas

SQL-05 Para cada proyecto, calcula el porcentaje de tareas completadas con un decimal, evitando la división por cero.

SQL-06 Obtén la última tarea creada de cada proyecto usando DISTINCT ON y también con row_number(). ¿Cuál es más rápida sobre tu esquema y por qué?

SQL-07 Calcula, por día de los últimos 30, cuántas tareas se cerraron, el acumulado del mes y la media móvil de 7 días. Incluye los días sin cierres (pista: generate_series).

SQL-08 Escribe una CTE recursiva que devuelva el hilo completo de comentarios de una tarea con su nivel de anidamiento y ordenado de forma que los hijos aparezcan bajo su padre.

Nivel 3 · rendimiento y modelado

SQL-09 Dada la consulta del listado principal de TaskFlow (filtro por proyecto y estado, orden por fecha de vencimiento, límite 50), diseña el índice óptimo. Justifica el orden de las columnas y demuestra con EXPLAIN ANALYZE que desaparece el nodo Sort.

SQL-10 Añade un contador task_count a projects y mantenlo con un trigger. Escribe también la consulta de reconciliación que detectaría un contador desviado.

SQL-11 Implementa el email único compatible con el borrado lógico: dos usuarios borrados pueden compartir email, pero solo puede haber uno activo por dirección.

SQL-12 Simula una actualización perdida abriendo dos sesiones psql. Después resuélvela de tres formas (actualización atómica, FOR UPDATE y columna de versión) y explica cuál elegirías para un contador de visitas y cuál para un saldo bancario.

Soluciones comentadas (SQL-01, SQL-05, SQL-07, SQL-09 y SQL-11)
-- SQL-01
SELECT t.title,
       COALESCE(u.full_name, '(sin asignar)') AS responsable,
       t.due_date
FROM   tasks t
LEFT JOIN users u ON u.id = t.assignee_id
WHERE  t.project_id = $1
  AND  t.status IN ('open', 'in_progress')
ORDER  BY t.due_date ASC NULLS LAST, t.id;   -- el id desempata: orden determinista

-- SQL-05: NULLIF evita la división por cero devolviendo NULL,
-- y COALESCE lo convierte en 0 para presentarlo.
SELECT p.name,
       count(t.id)                                        AS total,
       count(*) FILTER (WHERE t.status = 'done')          AS hechas,
       COALESCE(round(
         100.0 * count(*) FILTER (WHERE t.status = 'done')
              / NULLIF(count(t.id), 0), 1), 0)            AS porcentaje
FROM   projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP  BY p.id, p.name
ORDER  BY porcentaje DESC;

-- SQL-07: generate_series produce el calendario completo;
-- el LEFT JOIN rellena con ceros los días sin actividad.
WITH dias AS (
  SELECT generate_series(current_date - 29, current_date, interval '1 day')::date AS dia
),
cierres AS (
  SELECT completed_at::date AS dia, count(*) AS n
  FROM   tasks
  WHERE  completed_at >= current_date - 29
  GROUP  BY 1
)
SELECT d.dia,
       COALESCE(c.n, 0)                                          AS cerradas,
       sum(COALESCE(c.n, 0)) OVER (ORDER BY d.dia)               AS acumulado,
       round(avg(COALESCE(c.n, 0)) OVER (ORDER BY d.dia
             ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2)       AS media_7d
FROM   dias d
LEFT JOIN cierres c ON c.dia = d.dia
ORDER  BY d.dia;

-- SQL-09: igualdad primero (project_id, status), y la columna de
-- ordenación al final para que el índice entregue las filas ya ordenadas.
CREATE INDEX idx_tasks_listado ON tasks (project_id, status, due_date);
-- Con LIMIT 50 el motor recorre solo las 50 primeras entradas del índice:
-- desaparece el nodo Sort y el tiempo pasa de cientos de ms a fracciones.
-- Si el listado excluye siempre las tareas cerradas, un índice parcial es aún mejor:
CREATE INDEX idx_tasks_listado_activas ON tasks (project_id, due_date)
  WHERE status IN ('open', 'in_progress');

-- SQL-11: índice único PARCIAL. La restricción solo se aplica a las filas vivas.
DROP INDEX IF EXISTS users_email_key;
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
CREATE UNIQUE INDEX uq_users_email_activos
  ON users (email) WHERE deleted_at IS NULL;
-- Alternativa si tu motor no admite índices parciales: una columna generada
-- que valga el id cuando está borrado y un valor fijo cuando está activo,
-- incluida en la clave única.

19.23 Resumen del capítulo

  • El esquema es el contrato más duradero. Las restricciones (NOT NULL, CHECK, UNIQUE, claves foráneas) son validaciones que nunca se olvidan.
  • Los tipos importan: numeric para dinero, timestamptz para instantes, bigint para identificadores, y UUID v7 si necesitas identificadores opacos.
  • Normaliza hasta 3FN y desnormaliza solo después de medir, siempre con un mecanismo que mantenga la copia sincronizada.
  • El orden lógico de ejecución explica casi todas las dudas de sintaxis: WHERE filtra filas, HAVING filtra grupos.
  • Un índice compuesto se usa por su prefijo izquierdo: igualdad primero, rango después, ordenación al final. Y las claves foráneas no se indexan solas.
  • EXPLAIN (ANALYZE, BUFFERS) convierte «esto va lento» en un diagnóstico concreto. Busca escaneos secuenciales evitables y ordenaciones que un índice podría servir gratis.
  • MVCC hace que leer no bloquee, a cambio de generar versiones muertas: las transacciones largas son el enemigo silencioso.
  • La actualización perdida es real y no lanza ningún error: resuélvela con actualización atómica, bloqueo pesimista o bloqueo optimista.
  • El ORM escribe el SQL, pero tú decides el modelo, los índices y las transacciones.

19.24 Recursos adicionales

Siguiente paso Con el modelo de datos bajo control, el capítulo 20 sube un nivel de abstracción: cómo organizar el código que rodea a todo esto con arquitectura en capas, SOLID, patrones y Clean Code.