Skip to content

🎯 Meta: que puedas modelar datos, crear tablas y hacer consultas SQL con soltura. Todo backend termina hablando con una base de datos. Domina esto y los frameworks serán fáciles.

Usamos PostgreSQL 18 (la mejor base de datos relacional open source de 2026), pero el 90% del SQL que verás es estándar y funciona igual en MySQL, SQLite, etc.


1.1 · ¿Qué es una base de datos relacional?

Es una forma de guardar datos en tablas (como hojas de Excel) que se relacionan entre sí.

Tabla: usuarios                         Tabla: pedidos
┌────┬─────────┬──────────────┐         ┌────┬──────────┬────────┬─────────┐
│ id │ nombre  │ email        │         │ id │ usuario_id│ total  │ fecha   │
├────┼─────────┼──────────────┤         ├────┼──────────┼────────┼─────────┤
│ 1  │ Ana     │ ana@x.com    │◄───┐    │ 10 │    1     │ 99.90  │ 2026... │
│ 2  │ Beto    │ beto@x.com   │    └────┤ 11 │    1     │ 12.00  │ 2026... │
│ 3  │ Carla   │ carla@x.com  │         │ 12 │    2     │ 45.50  │ 2026... │
└────┴─────────┴──────────────┘         └────┴──────────┴────────┴─────────┘
        ▲                                          │
        └──── la columna usuario_id "apunta" ──────┘
              a la columna id de usuarios (clave foránea)
  • Tabla: una colección de datos del mismo tipo (usuarios, pedidos…).
  • Fila / registro: una entrada (un usuario concreto).
  • Columna / campo: un atributo (nombre, email…).
  • Clave primaria (PK): columna que identifica de forma única cada fila (normalmente id).
  • Clave foránea (FK): columna que apunta a la PK de otra tabla → crea la relación.

🧠 Relacional = las tablas se conectan por relaciones. Ana (id=1) tiene dos pedidos porque hay dos filas en pedidos con usuario_id = 1. Esa es toda la magia.


1.2 · Instalar PostgreSQL (rápido, con Docker)

La forma más limpia de tener PostgreSQL sin ensuciar tu sistema es con Docker (cap. 12):

bash
docker run --name pg -e POSTGRES_PASSWORD=secreto -p 5432:5432 -d postgres:18

Y te conectas con el cliente psql:

bash
docker exec -it pg psql -U postgres

💡 Tip: para explorar visualmente instala DBeaver (gratis) o TablePlus. Ver los datos en una tabla ayuda muchísimo a aprender. Pero aprende también psql: en un servidor de producción a las 3 AM no tendrás interfaz gráfica.

Comandos útiles dentro de psql:

\l              lista bases de datos
\c mibase       conéctate a "mibase"
\dt             lista tablas
\d usuarios     describe la tabla usuarios
\q              salir

1.3 · DDL — Crear la estructura (tablas)

DDL = Data Definition Language: define cómo son los datos.

sql
-- Crear la base de datos
CREATE DATABASE tienda;

-- Crear una tabla
CREATE TABLE usuarios (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nombre      VARCHAR(100)    NOT NULL,
    email       VARCHAR(255)    NOT NULL UNIQUE,
    activo      BOOLEAN         NOT NULL DEFAULT true,
    creado_en   TIMESTAMPTZ     NOT NULL DEFAULT now()
);

Desglose de cada parte:

ElementoQué hace
BIGINT GENERATED ALWAYS AS IDENTITYEntero grande autoincremental (1, 2, 3…). La forma moderna (mejor que SERIAL).
PRIMARY KEYIdentificador único de la fila
VARCHAR(100)Texto de máximo 100 caracteres
NOT NULLObligatorio (no puede quedar vacío)
UNIQUENo se puede repetir (dos usuarios no pueden tener el mismo email)
DEFAULT trueValor por defecto si no se especifica
TIMESTAMPTZFecha y hora con zona horaria (usa SIEMPRE esta, no TIMESTAMP)
now()Función que devuelve la fecha/hora actual

Tipos de datos que más usarás en PostgreSQL

TipoPara quéEjemplo
BIGINT / INTEGERNúmeros enterosid, edad, cantidad
NUMERIC(10,2)Decimales exactos (¡dinero!)precio: 99.90
VARCHAR(n) / TEXTTextonombre, descripción
BOOLEANVerdadero/falsoactivo
TIMESTAMPTZFecha y hora con zonacreado_en
DATESolo fechafecha_nacimiento
UUIDIdentificador único universalclaves públicas
JSONBJSON indexablemetadatos flexibles

⚠️ Nunca uses FLOAT/REAL para dinero. Los decimales de coma flotante tienen errores de redondeo (0.1 + 0.2 != 0.3). Para dinero usa siempre NUMERIC(10,2). Este error ha costado millones en sistemas reales.

La clave foránea (relación)

sql
CREATE TABLE pedidos (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    usuario_id  BIGINT NOT NULL,
    total       NUMERIC(10,2) NOT NULL,
    creado_en   TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- Esta línea crea la relación:
    CONSTRAINT fk_usuario
        FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
        ON DELETE CASCADE
);

ON DELETE CASCADE = si borras un usuario, se borran automáticamente sus pedidos. Otras opciones: ON DELETE RESTRICT (impide borrar si tiene pedidos) o ON DELETE SET NULL.


1.4 · DML — Manipular los datos (CRUD en SQL)

DML = Data Manipulation Language. Aquí está el famoso CRUD:

CREATE → INSERT

sql
INSERT INTO usuarios (nombre, email)
VALUES ('Ana López', 'ana@x.com');

-- Insertar varios a la vez y devolver el id generado:
INSERT INTO usuarios (nombre, email) VALUES
    ('Beto Ruiz', 'beto@x.com'),
    ('Carla Díaz', 'carla@x.com')
RETURNING id, nombre;

READ → SELECT

sql
-- Todos los usuarios
SELECT * FROM usuarios;

-- Solo algunas columnas
SELECT nombre, email FROM usuarios;

-- Con condición (WHERE)
SELECT * FROM usuarios WHERE activo = true;

-- Ordenar y limitar
SELECT * FROM usuarios
ORDER BY creado_en DESC
LIMIT 10;

-- Filtros combinados
SELECT * FROM usuarios
WHERE activo = true
  AND email LIKE '%@x.com'
  AND creado_en > '2026-01-01';

Operadores útiles en WHERE:

sql
=  !=  <  >  <=  >=            comparación
AND  OR  NOT                   lógicos
BETWEEN 10 AND 20              rango
IN ('admin', 'editor')         está en la lista
LIKE 'Ana%'                    empieza por "Ana"  (% = cualquier cosa)
ILIKE 'ana%'                   igual pero sin distinguir mayúsculas
IS NULL / IS NOT NULL          nulo o no nulo

UPSERT — insertar o actualizar en un solo paso

Un caso frecuente: "si ya existe, actualízalo; si no, créalo". Hacerlo con un SELECT + IF desde la app tiene una condición de carrera (dos peticiones simultáneas pueden ambas ver "no existe" e intentar insertar). PostgreSQL lo resuelve atómicamente con ON CONFLICT:

sql
INSERT INTO usuarios (email, nombre)
VALUES ('ana@x.com', 'Ana López')
ON CONFLICT (email)                        -- si ya existe una fila con ese email...
DO UPDATE SET nombre = EXCLUDED.nombre      -- ...actualízala (EXCLUDED = la fila que intentabas insertar)
RETURNING id;

-- Variante: si ya existe, no hagas nada (ignorar duplicados)
INSERT INTO usuarios (email, nombre) VALUES ('ana@x.com', 'Ana López')
ON CONFLICT (email) DO NOTHING;

🧠 ON CONFLICT necesita una restricción UNIQUE o PRIMARY KEY sobre la columna que declaras (aquí, email) — es esa restricción la que define qué cuenta como "conflicto". Sin índice único, PostgreSQL no tiene forma de saber qué fila es "la misma".

UPDATE → UPDATE

sql
UPDATE usuarios
SET nombre = 'Ana María López', activo = false
WHERE id = 1;

⚠️ El error más caro del mundo SQL: olvidar el WHERE en un UPDATE o DELETE. UPDATE usuarios SET activo = false; (sin WHERE) desactiva a TODOS los usuarios.Tip de supervivencia: escribe primero el WHERE como SELECT, comprueba que devuelve las filas correctas, y solo entonces cámbialo por UPDATE/DELETE.

DELETE → DELETE

sql
DELETE FROM usuarios WHERE id = 3;

💡 Tip pro: en producción muchas veces no se borra de verdad (soft delete): se añade una columna borrado_en TIMESTAMPTZ y se marca la fecha en vez de eliminar la fila. Así puedes recuperar datos y auditar. Frameworks como Laravel lo hacen automático (SoftDeletes).


1.5 · NULL — el valor que no es un valor

NULL no significa "cero" ni "cadena vacía". Significa "desconocido". Y esa diferencia genera más bugs de SQL que cualquier otra cosa.

sql
INSERT INTO usuarios (nombre, email, telefono) VALUES ('Ana', 'ana@x.com', NULL);
--                                                                        └─ no sabemos su teléfono

La regla que rompe todo: NULL no es igual a nada, ni siquiera a sí mismo

sql
SELECT NULL = NULL;      -- → NULL  (¡no es true!)
SELECT NULL <> 5;        -- → NULL
SELECT NULL + 10;        -- → NULL  (cualquier operación con NULL da NULL)

-- ❌ Esto NUNCA devuelve filas, aunque haya teléfonos vacíos:
SELECT * FROM usuarios WHERE telefono = NULL;

-- ✅ Se usa IS NULL / IS NOT NULL:
SELECT * FROM usuarios WHERE telefono IS NULL;
SELECT * FROM usuarios WHERE telefono IS NOT NULL;

🧠 Por qué es así, y por qué tiene sentido: si no sabes el teléfono de Ana ni el de Beto, ¿son iguales? No lo sabes. SQL responde NULL ("desconocido") en vez de mentir con true o false. Esto se llama lógica de tres valores: en SQL una condición puede ser verdadera, falsa o desconocida, y WHERE solo deja pasar las verdaderas.

La trampa que se lleva por delante a todo el mundo: NOT IN

sql
-- Queremos "los usuarios que no han hecho ningún pedido"
SELECT * FROM usuarios
 WHERE id NOT IN (SELECT usuario_id FROM pedidos);
-- ⚠️ Si UNA sola fila de pedidos tiene usuario_id NULL,
--    esta consulta devuelve CERO filas. Siempre. Sin error ni aviso.

¿Por qué? id NOT IN (1, 2, NULL) equivale a id <> 1 AND id <> 2 AND id <> NULL. Ese último término es NULL, así que la condición entera nunca puede ser verdadera.

sql
-- ✅ Opción 1: NOT EXISTS (la recomendada — además suele ser más rápida)
SELECT * FROM usuarios u
 WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.usuario_id = u.id);

-- ✅ Opción 2: LEFT JOIN buscando la ausencia
SELECT u.* FROM usuarios u
  LEFT JOIN pedidos p ON p.usuario_id = u.id
 WHERE p.id IS NULL;

⚠️ Regla práctica: usa NOT EXISTS en lugar de NOT IN siempre que la subconsulta pueda contener NULL. Es el bug de SQL más silencioso que existe: no falla, no avisa, simplemente devuelve un resultado vacío que parece legítimo.

NULL en agregaciones y en COUNT

sql
SELECT COUNT(*)        FROM usuarios;   -- 100 → cuenta TODAS las filas
SELECT COUNT(telefono) FROM usuarios;   --  73 → ignora los NULL
SELECT AVG(descuento)  FROM pedidos;    -- ignora los NULL: el promedio es sobre los que TIENEN valor

💡 Esa diferencia entre COUNT(*) y COUNT(columna) es un truco útil, no solo una trampa: COUNT(fecha_cancelacion) te da directamente cuántos pedidos fueron cancelados, sin ningún WHERE.

Funciones para trabajar con NULL

sql
COALESCE(telefono, 'sin teléfono')          -- el primer valor NO nulo de la lista
COALESCE(apodo, nombre, 'Anónimo')          -- funciona en cadena
NULLIF(division, 0)                          -- devuelve NULL si es 0 → evita dividir entre cero
SELECT total / NULLIF(unidades, 0) FROM-- da NULL en vez de reventar

-- Ordenar controlando dónde van los nulos
SELECT * FROM tareas ORDER BY fecha_limite ASC NULLS LAST;

¿Debería permitir NULL en esta columna?

SituaciónDecisión
El dato siempre existe (email, precio)NOT NULL
El dato es genuinamente opcional (segundo apellido)NULL permitido
"Aún no ha ocurrido" (fecha_entrega, borrado_en)NULL permitido y significativo
Quieres "vacío" en un textoNOT NULL DEFAULT '' — decide uno de los dos y sé coherente
Números que representan cantidadNOT NULL DEFAULT 0

⚠️ El peor escenario es permitir a la vez NULL y cadena vacía en la misma columna de texto. Acabas con WHERE nombre IS NULL OR nombre = '' repetido por todo el código, y tarde o temprano alguien olvida la mitad. Elige una representación para "sin valor" y prohíbe la otra con NOT NULL o un CHECK.

💡 Diseño por defecto: pon NOT NULL en todo, y quítalo solo cuando puedas justificar qué significa el NULL en esa columna. Cada NULL permitido es una rama más que tu código debe tratar, y un caso más que tus tests deben cubrir.


1.6 · JOINs — El corazón de lo relacional

Un JOIN combina filas de dos tablas usando su relación. Es lo que más te costará al principio y lo más importante que aprenderás.

sql
-- "Dame cada pedido junto con el nombre del usuario que lo hizo"
SELECT
    pedidos.id,
    pedidos.total,
    usuarios.nombre AS cliente
FROM pedidos
INNER JOIN usuarios ON usuarios.id = pedidos.usuario_id;

Los 4 tipos de JOIN

INNER JOIN         LEFT JOIN          RIGHT JOIN         FULL JOIN
  A ∩ B            todo A + coincid.  todo B + coincid.  todo A + todo B

  ●●                ●●●                 ●●                ●●●
 ●██●              ●██●●              ●●██●              ●██●●
  ●●                ●●●                 ●●                ●●●
JOINQué devuelve
INNER JOINSolo filas que coinciden en ambas tablas
LEFT JOINTodas las de la izquierda + las que coincidan de la derecha (NULL si no)
RIGHT JOINTodas las de la derecha + coincidencias de la izquierda
FULL JOINTodas de ambas tablas

El más usado es INNER JOIN. El segundo, LEFT JOIN (ej: "todos los usuarios, tengan pedidos o no"):

sql
-- Usuarios y cuántos pedidos tiene cada uno (incluso los que tienen 0)
SELECT
    usuarios.nombre,
    COUNT(pedidos.id) AS num_pedidos
FROM usuarios
LEFT JOIN pedidos ON pedidos.usuario_id = usuarios.id
GROUP BY usuarios.id, usuarios.nombre
ORDER BY num_pedidos DESC;

Encadenar varios JOINs

Las consultas reales cruzan tres, cuatro o cinco tablas. Se leen de arriba abajo, como una cadena:

sql
-- "Qué productos compró cada cliente de Madrid este año"
SELECT
    u.nombre           AS cliente,
    pr.nombre          AS producto,
    lp.cantidad,
    p.creado_en::date  AS fecha
FROM usuarios u
INNER JOIN pedidos        p  ON p.usuario_id  = u.id
INNER JOIN lineas_pedido  lp ON lp.pedido_id  = p.id
INNER JOIN productos      pr ON pr.id         = lp.producto_id
WHERE u.ciudad = 'Madrid'
  AND p.creado_en >= '2026-01-01'
ORDER BY u.nombre, p.creado_en;

💡 Usa alias cortos (u, p, lp) y ponlos siempre, incluso cuando no haya ambigüedad. Con cuatro tablas, usuarios.nombre frente a productos.nombre deja de ser una cuestión de estilo.

LEFT JOIN + WHERE: el error que lo convierte en INNER JOIN

sql
-- ❌ Queríamos "todos los usuarios, con sus pedidos de 2026 si los tienen"
SELECT u.nombre, p.total
  FROM usuarios u
  LEFT JOIN pedidos p ON p.usuario_id = u.id
 WHERE p.creado_en >= '2026-01-01';
-- Los usuarios SIN pedidos tienen p.creado_en = NULL.
-- NULL >= '2026-01-01' es NULL → el WHERE los descarta.
-- Resultado: acabas de convertir tu LEFT JOIN en un INNER JOIN. 💀
sql
-- ✅ La condición del lado opcional va en el ON, no en el WHERE
SELECT u.nombre, p.total
  FROM usuarios u
  LEFT JOIN pedidos p
         ON p.usuario_id = u.id
        AND p.creado_en >= '2026-01-01';

🧠 La regla: en un LEFT JOIN, las condiciones sobre la tabla izquierda van en WHERE; las condiciones sobre la tabla derecha (la opcional) van en el ON. Si van en el WHERE, eliminan las filas sin coincidencia y pierdes el sentido del LEFT JOIN.

SELF JOIN: unir una tabla consigo misma

Útil cuando una fila se relaciona con otra de la misma tabla (jerarquías, referidos, respuestas):

sql
-- Cada empleado con el nombre de su jefe
SELECT e.nombre AS empleado, j.nombre AS jefe
  FROM empleados e
  LEFT JOIN empleados j ON j.id = e.jefe_id;   -- LEFT: el director general no tiene jefe

1.7 · Agregación — Resumir datos

Las funciones de agregación calculan un valor a partir de muchas filas:

sql
SELECT COUNT(*)        FROM usuarios;              -- cuántos hay
SELECT SUM(total)      FROM pedidos;               -- suma total
SELECT AVG(total)      FROM pedidos;               -- promedio
SELECT MAX(total), MIN(total) FROM pedidos;        -- máximo y mínimo

Con GROUP BY agrupas antes de agregar ("total gastado por cada usuario"):

sql
SELECT
    usuario_id,
    COUNT(*)      AS num_pedidos,
    SUM(total)    AS gastado_total
FROM pedidos
GROUP BY usuario_id
HAVING SUM(total) > 100        -- HAVING filtra grupos (como WHERE pero tras agrupar)
ORDER BY gastado_total DESC;

🧠 WHERE vs HAVING: WHERE filtra filas antes de agrupar; HAVING filtra grupos después de agregar. No puedes usar SUM() en un WHERE, pero sí en un HAVING.


1.8 · El orden real en que se ejecuta una consulta

Escribes SELECT primero, pero la base de datos no lo ejecuta primero. Entender el orden real explica de golpe la mitad de los errores de SQL que verás.

      ORDEN EN QUE LO ESCRIBES        ORDEN EN QUE SE EJECUTA
      ─────────────────────────       ────────────────────────────────────
      SELECT    …                     1. FROM      ← de dónde salen las filas
      FROM      …                     2. JOIN      ← se combinan las tablas
      JOIN      …                     3. WHERE     ← se descartan filas
      WHERE     …                     4. GROUP BY  ← se agrupan las que quedan
      GROUP BY  …                     5. HAVING    ← se descartan GRUPOS
      HAVING    …                     6. SELECT    ← se calculan las columnas
      ORDER BY  …                     7. DISTINCT
      LIMIT     …                     8. ORDER BY  ← se ordena el resultado
                                      9. LIMIT     ← se recortan las filas

Tres consecuencias prácticas que se deducen de ese orden:

1. No puedes usar un alias del SELECT dentro del WHERE.

sql
-- ❌ Error: "column «total_con_iva» does not exist"
SELECT precio * 1.21 AS total_con_iva FROM productos WHERE total_con_iva > 100;
--     └ el WHERE (paso 3) se ejecuta ANTES que el SELECT (paso 6): el alias aún no existe

-- ✅ Repite la expresión…
SELECT precio * 1.21 AS total_con_iva FROM productos WHERE precio * 1.21 > 100;
-- ✅ …o usa una subconsulta / CTE, que sí termina el SELECT antes
WITH calculados AS (SELECT *, precio * 1.21 AS total_con_iva FROM productos)
SELECT * FROM calculados WHERE total_con_iva > 100;

Pero puedes usarlo en ORDER BY (paso 8), porque para entonces el SELECT ya se ejecutó:

sql
SELECT precio * 1.21 AS total_con_iva FROM productos ORDER BY total_con_iva DESC;  -- ✅

2. WHERE filtra filas; HAVING filtra grupos.

sql
SELECT usuario_id, SUM(total) AS gastado
  FROM pedidos
 WHERE creado_en >= '2026-01-01'    -- 3. descarta pedidos viejos ANTES de agrupar
 GROUP BY usuario_id
HAVING SUM(total) > 1000;           -- 5. descarta usuarios que gastaron poco DESPUÉS

Poner WHERE SUM(total) > 1000 es un error: en el paso 3 los grupos todavía no existen.

💡 Y filtrar en WHERE es más rápido que filtrar en HAVING, porque descarta filas antes de hacer el trabajo de agrupar. Si una condición se puede expresar en WHERE, ponla ahí.

3. LIMIT se aplica al final, así que no ahorra trabajo de agregación.

SELECT … GROUP BY … ORDER BY … LIMIT 10 calcula todos los grupos y los ordena todos; solo al final se queda con diez. Si eso es lento, el problema no es el LIMIT: es que hay demasiadas filas entrando por el FROM.


1.9 · Índices — Que las consultas vuelen

Un índice es una estructura auxiliar ordenada que la base de datos mantiene aparte para encontrar filas sin leer la tabla entera. Como el índice alfabético al final de un libro: en vez de hojear las 800 páginas, vas a la entrada y saltas directo.

SIN ÍNDICE (Seq Scan)                CON ÍNDICE (Index Scan)
lee las 1.000.000 de filas           baja por un árbol: ~20 comparaciones
y compara una a una                  y salta a la fila exacta
≈ 800 ms                             ≈ 0,3 ms
sql
-- Índice simple
CREATE INDEX idx_usuarios_email ON usuarios (email);

-- Índice compuesto (dos o más columnas)
CREATE INDEX idx_pedidos_usuario_fecha ON pedidos (usuario_id, creado_en DESC);

-- Sin bloquear la tabla (OBLIGATORIO en producción — ver el aviso más abajo)
CREATE INDEX CONCURRENTLY idx_pedidos_estado ON pedidos (estado);

DROP INDEX idx_que_no_uso;

La regla del prefijo izquierdo (lo que hay que entender de los índices compuestos)

Un índice sobre (a, b, c) está ordenado primero por a, luego por b, luego por c — como una guía telefónica ordenada por provincia, después ciudad y después apellido.

sql
CREATE INDEX idx ON pedidos (usuario_id, estado, creado_en);
Consulta¿Usa el índice?
WHERE usuario_id = 5✅ Sí
WHERE usuario_id = 5 AND estado = 'nuevo'✅ Sí, completo
WHERE usuario_id = 5 AND estado = 'nuevo' AND creado_en > …✅ Sí, perfecto
WHERE usuario_id = 5 AND creado_en > …🟡 Parcial (solo por usuario_id)
WHERE estado = 'nuevo'No — falta el prefijo
WHERE creado_en > …No

🧠 Por eso el orden de las columnas en un índice compuesto no es un detalle: es la decisión. Puedes buscar por provincia, o por provincia+ciudad, pero no puedes buscar solo por ciudad en una guía ordenada por provincia. Pon primero la columna que aparece en igualdad (=) en más consultas, y deja los rangos (>, <, BETWEEN) para el final.

Los casos en que tu índice existe pero NO se usa

sql
-- ❌ Una función sobre la columna anula el índice
SELECT * FROM usuarios WHERE LOWER(email) = 'ana@x.com';
-- ✅ Solución A: índice sobre la expresión
CREATE INDEX idx_email_lower ON usuarios (LOWER(email));
-- ✅ Solución B: guardar el email ya normalizado en minúsculas al insertar

-- ❌ Un LIKE que empieza por comodín no puede aprovechar el orden
SELECT * FROM productos WHERE nombre LIKE '%camisa%';
-- ✅ Para eso está la búsqueda de texto completo o un índice trigram (pg_trgm)

-- ❌ Comparar tipos distintos fuerza una conversión
SELECT * FROM pedidos WHERE usuario_id = '42';   -- texto contra BIGINT

-- 🟡 Si la consulta va a devolver gran parte de la tabla, el planificador
--    prefiere leerla entera: recorrer el índice y saltar a cada fila sería MÁS lento.
--    Esto no es un fallo: es la decisión correcta.

Tipos de índice en PostgreSQL

TipoPara quéEjemplo
B-tree (por defecto)Igualdad, rangos, orden= < > BETWEEN ORDER BY
GINContenido dentro de un valorJSONB, arrays, texto completo
GiSTDatos geométricos, rangos, solapamientosmapas, reservas horarias
BRINTablas enormes con datos correlacionados por orden físicologs por fecha
HashSolo igualdad exactararo; B-tree suele bastar
sql
CREATE INDEX idx_metadatos ON productos USING GIN (metadatos);           -- JSONB
CREATE INDEX idx_busqueda  ON articulos USING GIN (to_tsvector('spanish', cuerpo));

Índices parciales y de cobertura (los dos trucos que más rinden)

sql
-- PARCIAL: indexa solo las filas que consultas de verdad.
-- Si el 98% de los pedidos están 'completados' y siempre buscas los pendientes,
-- este índice ocupa el 2% y es muchísimo más rápido.
CREATE INDEX idx_pedidos_pendientes ON pedidos (creado_en)
    WHERE estado = 'pendiente';

-- DE COBERTURA: incluye columnas extra para que la consulta se resuelva
-- ENTERA dentro del índice, sin tocar la tabla (Index Only Scan).
CREATE INDEX idx_pedidos_cobertura ON pedidos (usuario_id) INCLUDE (total, creado_en);

El coste de los índices

GanasPagas
Lecturas mucho más rápidasCada INSERT/UPDATE/DELETE actualiza también el índice
ORDER BY sin ordenarEspacio en disco (a veces más que la propia tabla)
UNIQUE garantiza unicidadMantenimiento y memoria

⚠️ No indexes "por si acaso". Una tabla con doce índices puede ser más lenta escribiendo que leyendo. Indexa lo que aparece en WHERE, JOIN y ORDER BY de consultas que realmente ejecutas, y revisa periódicamente cuáles no se usan:

sql
SELECT relname, indexrelname, idx_scan
  FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY relname;
-- idx_scan = 0 → ese índice no lo ha usado nadie. Candidato a borrar.

⚠️⚠️ En producción, CREATE INDEX sin CONCURRENTLY bloquea la tabla entera para escrituras mientras se construye. Sobre una tabla de millones de filas eso son minutos de aplicación caída, y es una de las causas más habituales de incidentes en despliegues. Usa siempre CREATE INDEX CONCURRENTLY (tarda más y no puede ir dentro de una transacción, pero no bloquea).


1.10 · Leer un plan de ejecución (EXPLAIN)

EXPLAIN te dice qué piensa hacer PostgreSQL. EXPLAIN ANALYZE lo ejecuta de verdad y te dice lo que pasó. Es la herramienta que convierte "va lento" en un diagnóstico.

sql
EXPLAIN ANALYZE
SELECT u.nombre, COUNT(p.id)
  FROM usuarios u
  LEFT JOIN pedidos p ON p.usuario_id = u.id
 WHERE u.creado_en > '2026-01-01'
 GROUP BY u.id, u.nombre;
HashAggregate  (cost=1250.44..1290.44 rows=4000 width=48)
               (actual time=18.2..19.1 rows=3812 loops=1)
  ->  Hash Right Join  (cost=180.00..1150.44 rows=20000 width=40)
                       (actual time=2.1..14.8 rows=19604 loops=1)
        Hash Cond: (p.usuario_id = u.id)
        ->  Seq Scan on pedidos p  (cost=0.00..800.00 rows=40000 width=16)   ← 👀
        ->  Hash  (cost=130.00..130.00 rows=4000 width=32)
              ->  Index Scan using idx_usuarios_creado on usuarios u          ← ✅
                    Index Cond: (creado_en > '2026-01-01')
Planning Time: 0.3 ms
Execution Time: 19.8 ms

Cómo se lee: de dentro hacia fuera y de abajo hacia arriba. Las líneas más indentadas ocurren primero.

Qué vesQué significa
Seq ScanLee la tabla entera. Bien en tablas pequeñas; alarma en grandes
Index ScanUsa un índice y va a buscar la fila. 👍
Index Only ScanResuelve todo dentro del índice, sin tocar la tabla. 🏆
Bitmap Heap ScanMuchas coincidencias: recoge posiciones y luego lee en bloque
Nested LoopPor cada fila de A busca en B. Excelente si A es pequeño, fatal si es grande
Hash JoinConstruye una tabla hash. Bueno para volúmenes grandes
Merge JoinAmbas entradas ordenadas. Bueno si ya venían ordenadas
SortEstá ordenando. Si es enorme, quizá un índice lo evite
rows=X vs actual rows=Y📌 Lo más importante (ver abajo)

💡 El dato que hay que mirar primero: la diferencia entre rows= (estimado) y actual rows= (real). Si el planificador estima 100 filas y salen 2.000.000, ha elegido una estrategia basándose en información falsa, y de ahí viene casi siempre la lentitud. La causa habitual son estadísticas desactualizadas:

sql
ANALYZE pedidos;   -- recalcula las estadísticas de la tabla

El flujo de diagnóstico completo:

1. ¿La consulta es lenta?  → EXPLAIN (ANALYZE, BUFFERS) sobre ella
2. Busca el nodo con más "actual time" acumulado (el cuello real)
3. ¿Es un Seq Scan sobre una tabla grande con un WHERE selectivo?  → falta índice
4. ¿Estimado y real se diferencian en órdenes de magnitud?         → ANALYZE
5. ¿Hay un Sort enorme?                                            → índice que ya dé el orden
6. ¿Un Nested Loop con muchísimas iteraciones?                     → falta índice en el lado interno
7. Crea el índice, vuelve a medir y COMPARA los tiempos reales

🧠 Nunca optimices sin EXPLAIN ANALYZE antes y después. Es tan frecuente añadir un índice que no se usa (y que además ralentiza las escrituras) como acertar a la primera. El plan de ejecución es la única fuente de verdad; tu intuición sobre por qué una consulta es lenta suele estar equivocada.


1.11 · Transacciones — Todo o nada

Una transacción agrupa varias operaciones para que se ejecuten todas o ninguna. El ejemplo clásico: transferir dinero (restar de una cuenta, sumar a otra). Si falla a mitad, no puede quedar el dinero "desaparecido".

sql
BEGIN;                                                    -- empieza la transacción
    UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;  -- restar
    UPDATE cuentas SET saldo = saldo + 100 WHERE id = 2;  -- sumar
COMMIT;                                                    -- confirma todo junto

-- Si algo va mal:
-- ROLLBACK;   ← deshace TODO como si nada hubiera pasado

Las transacciones garantizan las propiedades ACID:

LetraPropiedadSignificado
AAtomicidadTodo o nada
CConsistenciaLa BD pasa de un estado válido a otro válido
IAislamientoLas transacciones concurrentes no se pisan
DDurabilidadUna vez confirmado, no se pierde (aunque se corte la luz)

💡 Tip: en los frameworks esto se hace con un bloque tipo DB::transaction(function () {...}) (Laravel) o async with db.transaction(): (FastAPI). Por debajo es exactamente este BEGIN/COMMIT.


1.12 · Normalización (modelar bien)

Normalizar = organizar las tablas para evitar datos repetidos y errores. La regla práctica:

Cada dato debe estar en un solo sitio. Si copias el nombre del usuario en cada pedido y luego cambia su nombre, tendrás datos inconsistentes. Por eso el pedido solo guarda usuario_id y el nombre vive solo en la tabla usuarios.

Las 3 formas normales, en simple:

  1. 1FN: cada celda tiene un solo valor (nada de telefonos: "111, 222, 333" en una columna).
  2. 2FN: cada columna depende de toda la clave primaria.
  3. 3FN: ninguna columna depende de otra que no sea la clave.

🧠 Regla mnemotécnica: "cada columna debe depender de la clave, de toda la clave, y de nada más que la clave." Si cumples eso, estás en 3FN, que es suficiente para el 99% de los casos.


1.13 · Restricciones CHECK y subconsultas

Además de NOT NULL y UNIQUE, PostgreSQL valida reglas de negocio simples dentro de la propia tabla con CHECK — la fila ni siquiera se guarda si no cumple la condición:

sql
CREATE TABLE productos (
    id      BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nombre  VARCHAR(100) NOT NULL,
    precio  NUMERIC(10,2) NOT NULL CHECK (precio > 0),
    stock   INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    estado  VARCHAR(20) NOT NULL CHECK (estado IN ('borrador', 'publicado', 'archivado'))
);

INSERT INTO productos (nombre, precio, stock, estado)
VALUES ('Teclado', -10, 5, 'publicado');
-- ERROR: new row for relation "productos" violates check constraint "productos_precio_check"

🧠 CHECK es la misma idea que NOT NULL/UNIQUE, más general: una regla que la BD hace cumplir sin depender de que el código de la app la valide siempre. Un precio negativo o un estado inventado no puede colarse ni por un bug en tu app, ni por alguien que edite datos directamente en psql.

Una subconsulta (subquery) es un SELECT dentro de otra consulta. Útil cuando necesitas el resultado de una consulta para filtrar otra:

sql
-- Usuarios que han hecho al menos un pedido de más de 100
SELECT * FROM usuarios
WHERE id IN (
    SELECT usuario_id FROM pedidos WHERE total > 100
);

-- Subconsulta escalar (devuelve un solo valor) en el SELECT
SELECT
    nombre,
    (SELECT COUNT(*) FROM pedidos WHERE pedidos.usuario_id = usuarios.id) AS num_pedidos
FROM usuarios;

⚠️ Una subconsulta correlacionada (la segunda del ejemplo, que referencia usuarios.id desde dentro) se ejecuta una vez por cada fila externa — con pocas filas no lo notas, con millones se vuelve lentísima. El capítulo 02 te da la alternativa que escala: JOINs y CTEs (WITH) resuelven lo mismo en una sola pasada. Usa subconsultas para casos simples y pocas filas; para informes pesados, prefiere JOIN.


1.14 · Modelar desde cero: un caso paso a paso

Saber SQL no es saber modelar. Este es el proceso, aplicado a un enunciado real.

Enunciado: "Quiero una web donde la gente reserve clases en mi gimnasio. Hay varias clases (yoga, spinning), cada una con horarios y un monitor. Las plazas son limitadas. Los socios tienen bonos de X clases al mes."

Paso 1 — Subraya los sustantivos (candidatos a tabla)

socio, clase, horario, monitor, plaza, bono, reserva.

Paso 2 — Distingue entidades de atributos

  • Entidades (tienen identidad propia y vida independiente): socios, monitores, tipos_de_clase, sesiones, reservas, bonos.
  • Atributos (describen a otra cosa): "plaza" no es una entidad, es capacidad de la sesión.

🧠 La distinción clave del enunciado: "clase" significa dos cosas distintas. El tipo de clase ("Yoga suave, 60 min, sala 2") y la sesión concreta ("el yoga del martes 14 a las 19:00 con Marta"). Confundirlos es el error de modelado nº1 aquí: acabarías repitiendo la descripción del yoga en cada fila de horario. Sepáralos.

Paso 3 — Define las relaciones y su cardinalidad

tipos_de_clase  1 ──< N  sesiones      (un tipo tiene muchas sesiones programadas)
monitores       1 ──< N  sesiones      (un monitor imparte muchas sesiones)
socios          1 ──< N  reservas
sesiones        1 ──< N  reservas
socios          1 ──< N  bonos         (histórico: uno por mes)

reservas es una tabla de unión entre socios y sesiones (relación N:M), pero con datos propios (fecha de reserva, estado), así que es una entidad por derecho propio.

Paso 4 — Escribe el esquema, con las restricciones DENTRO

sql
CREATE TABLE tipos_de_clase (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nombre      VARCHAR(80)  NOT NULL UNIQUE,
    duracion_min SMALLINT    NOT NULL CHECK (duracion_min BETWEEN 15 AND 240),
    descripcion TEXT
);

CREATE TABLE sesiones (
    id             BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tipo_clase_id  BIGINT      NOT NULL REFERENCES tipos_de_clase(id) ON DELETE RESTRICT,
    monitor_id     BIGINT      NOT NULL REFERENCES monitores(id)      ON DELETE RESTRICT,
    empieza_en     TIMESTAMPTZ NOT NULL,
    capacidad      SMALLINT    NOT NULL CHECK (capacidad > 0),
    cancelada      BOOLEAN     NOT NULL DEFAULT false,
    -- Un monitor no puede estar en dos sitios a la vez:
    CONSTRAINT monitor_sin_solape UNIQUE (monitor_id, empieza_en)
);

CREATE TABLE reservas (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    socio_id    BIGINT      NOT NULL REFERENCES socios(id)    ON DELETE CASCADE,
    sesion_id   BIGINT      NOT NULL REFERENCES sesiones(id)  ON DELETE CASCADE,
    estado      VARCHAR(20) NOT NULL DEFAULT 'confirmada'
                CHECK (estado IN ('confirmada','cancelada','asistio','no_asistio')),
    creada_en   TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- 📌 La regla de negocio más importante, garantizada por la BD:
    CONSTRAINT una_reserva_por_socio_y_sesion UNIQUE (socio_id, sesion_id)
);

-- Los índices que exigen las consultas reales:
CREATE INDEX idx_sesiones_fecha   ON sesiones (empieza_en) WHERE NOT cancelada;
CREATE INDEX idx_reservas_sesion  ON reservas (sesion_id);
CREATE INDEX idx_reservas_socio   ON reservas (socio_id, creada_en DESC);

Paso 5 — Comprueba el modelo contra las preguntas que tendrás que responder

Este es el paso que casi nadie hace, y el que valida el diseño:

Pregunta del negocio¿La responde el modelo?
"¿Qué clases hay el martes?"WHERE empieza_en::date = … con idx_sesiones_fecha
"¿Quedan plazas en esta sesión?"capacidad - COUNT(reservas confirmadas)
"¿Cuántas clases lleva Ana este mes?"idx_reservas_socio
"¿Qué monitor tiene más asistencia?"✅ JOIN sesiones + reservas con estado='asistio'
"¿Ana ya reservó esta sesión?"✅ La restricción UNIQUE lo impide de raíz

Paso 6 — Las decisiones que quedan abiertas (y hay que tomar a conciencia)

  • ¿Las plazas se controlan con COUNT al vuelo o con un contador desnormalizado? Con decenas de reservas por sesión, COUNT es correcto y simple. Un contador es más rápido pero puede desincronizarse. Empieza por COUNT.
  • ¿Y la condición de carrera al reservar la última plaza? El UNIQUE evita reservas duplicadas, pero no el exceso de aforo. Eso necesita SELECT … FOR UPDATE o una restricción adicional — es exactamente el caso 1 del capítulo 26.
  • ¿Cancelar una sesión borra las reservas? No: se marca cancelada = true y se conservan las reservas para poder avisar a los socios. Borrar datos históricos casi nunca es la respuesta.

💡 Regla general de modelado: las reglas de negocio que NO pueden violarse jamás van en la base de datos (NOT NULL, UNIQUE, CHECK, claves foráneas), no solo en el código de la aplicación. El código lo escriben varias personas durante años y alguien acabará saltándose la validación; la restricción es una sola y no se olvida nunca.


1.15 · Los errores de SQL que más caros salen

#ErrorPor qué dueleSolución
1Concatenar variables en la consultaInyección SQL: acceso total a tus datosConsultas parametrizadas, siempre
2SELECT * en producciónTrae columnas que no usas, se rompe al añadir campos, impide Index Only ScanEnumera las columnas
3Problema N+11 consulta + una por cada resultado: 1 ms se convierte en 300 msJOIN o carga ansiosa del ORM
4FLOAT para dinero0.1 + 0.2 ≠ 0.3: descuadres contablesNUMERIC(10,2)
5OFFSET 100000 para paginarLa BD lee y descarta 100.000 filas en cada páginaPaginación por cursor: WHERE id < ultimo_id LIMIT 20
6NOT IN con posibles NULLDevuelve cero filas en silencioNOT EXISTS
7Función sobre la columna del WHEREAnula el índiceÍndice sobre la expresión, o normalizar al guardar
8UPDATE/DELETE sin WHEREModificas la tabla enteraBEGIN; → comprueba → COMMIT;
9CREATE INDEX sin CONCURRENTLYBloquea la tabla varios minutos en producciónCONCURRENTLY siempre
10Transacciones larguísimasBloqueos, conexiones agotadas, VACUUM bloqueadoTransacciones cortas; nada de llamadas de red dentro

El truco que te salvará de un DELETE catastrófico

sql
-- ✅ SIEMPRE, antes de un DELETE o UPDATE masivo:
BEGIN;
DELETE FROM pedidos WHERE creado_en < '2020-01-01';
-- PostgreSQL responde: DELETE 4821
--  ¿4821 es el número que esperabas? Si sí:
COMMIT;
--  Si no:
ROLLBACK;

💡 Y la versión previa aún más segura: escribe primero la sentencia como SELECT COUNT(*) con el mismo WHERE. Si el número cuadra, cambia el SELECT COUNT(*) por el DELETE. Es el mismo hábito de mirar antes de destruir que aplicabas a rm y a find -delete en el capítulo T3.

Paginación por cursor, que deberías usar por defecto

sql
-- ❌ Página 5000: la base de datos lee 100.000 filas para tirar 99.980
SELECT * FROM pedidos ORDER BY id DESC LIMIT 20 OFFSET 100000;

-- ✅ Cursor: usa el índice y salta directamente, sin importar la profundidad
SELECT * FROM pedidos WHERE id < 4821 ORDER BY id DESC LIMIT 20;
--                            └─ el último id que devolvió la página anterior

El OFFSET se degrada linealmente: la página 1 es instantánea y la 5.000 tarda segundos. El cursor tarda lo mismo en la página 1 que en la 5.000. La contrapartida es que no puedes saltar a "la página 37" — pero en la práctica nadie lo hace: se navega con "cargar más" o "siguiente".


✅ Ejercicio del capítulo

Vas a construir el esquema de una biblioteca y a exprimirlo. Trabaja en psql de verdad, no sobre el papel.

Parte 1 — Modelado

  1. Diseña el esquema siguiendo el proceso de la sección 1.14: autores, libros, usuarios, prestamos y generos (un libro puede tener varios géneros).
  2. Todas las columnas deben ser NOT NULL salvo las que puedas justificar. Escribe en un comentario qué significa el NULL en cada columna que lo permita.
  3. Añade al menos tres CHECK que impidan datos imposibles.
  4. prestamos debe garantizar por diseño que un mismo ejemplar no puede estar prestado dos veces a la vez. Piensa cómo.

Parte 2 — Datos y consultas

  1. Inserta 4 autores, 10 libros, 5 usuarios y 12 préstamos (algunos ya devueltos).
  2. Título del libro + nombre del autor (JOIN).
  3. Cuántos libros tiene cada autor, incluidos los que no tienen ninguno (piensa qué JOIN).
  4. Libros que nunca se han prestado. Hazlo de dos formas distintas (NOT EXISTS y LEFT JOIN … IS NULL) y compara los planes con EXPLAIN.
  5. Los 3 usuarios con más préstamos, con su recuento.
  6. Préstamos actualmente activos (sin fecha de devolución) con más de 15 días de retraso.
  7. Para cada género, cuántos libros hay y cuál es el más prestado.

Parte 3 — NULL y sus trampas

  1. Escribe una consulta que parezca correcta pero devuelva 0 filas por culpa de NOT IN con NULL. Reprodúcelo de verdad y explica en un comentario por qué ocurre.
  2. Muestra la diferencia entre COUNT(*) y COUNT(fecha_devolucion) sobre prestamos. ¿Qué significa cada número en términos del negocio?
  3. Lista todos los préstamos ordenados por fecha de devolución, con los no devueltos primero.

Parte 4 — Índices y rendimiento

  1. Genera 500.000 préstamos falsos:
    sql
    INSERT INTO prestamos (libro_id, usuario_id, prestado_en)
    SELECT (random()*9)::int + 1, (random()*4)::int + 1,
           now() - (random() * interval '730 days')
      FROM generate_series(1, 500000);
    ANALYZE prestamos;
  2. Ejecuta EXPLAIN ANALYZE de "préstamos de un usuario concreto ordenados por fecha descendente". Anota el tiempo real y el tipo de nodo.
  3. Crea el índice adecuado. Vuelve a medir. Anota la mejora exacta.
  4. Demuestra la regla del prefijo izquierdo: con un índice sobre (usuario_id, prestado_en), encuentra una consulta que no lo use y explica por qué.
  5. Crea un índice parcial solo para los préstamos no devueltos y compara su tamaño con el índice completo (\di+ en psql).
  6. Escribe una consulta con OFFSET 400000 y la equivalente con cursor. Compara los tiempos.

Parte 5 — Transacciones

  1. En una sola transacción: registrar un préstamo y marcar el ejemplar como no disponible. Provoca un error a mitad y comprueba con un SELECT que no quedó nada a medias.
  2. Escribe el DELETE de los préstamos anteriores a 2024 usando el patrón seguro BEGIN → comprobar el recuento → COMMIT/ROLLBACK.
💡 Pistas de la solución (abre solo si te atascas)
  • Punto 4 — La forma limpia es un índice único parcial: CREATE UNIQUE INDEX ON prestamos (libro_id) WHERE fecha_devolucion IS NULL; Solo puede haber una fila con ese libro sin devolver. Es un uso precioso de los índices parciales: una regla de negocio expresada como restricción, y la base de datos la impone sola.
  • Punto 7LEFT JOIN desde autores, y COUNT(libros.id) (no COUNT(*)): COUNT(*) contaría la fila del autor y devolvería 1 en vez de 0 para los autores sin libros. Es la diferencia de la sección 1.5 apareciendo en un caso real.
  • Punto 10 — Cuidado con el NULL: fecha_devolucion IS NULL AND prestado_en < now() - interval '15 days'. Si escribes fecha_devolucion > … no obtienes nada, porque NULL no compara.
  • Punto 14ORDER BY fecha_devolucion ASC NULLS FIRST.
  • Punto 16-17 — Antes del índice verás Seq Scan + Sort y decenas o cientos de milisegundos. Con CREATE INDEX ON prestamos (usuario_id, prestado_en DESC) pasarás a Index Scan y a una fracción de milisegundo, sin nodo Sort: el índice ya entrega las filas ordenadas. Ver desaparecer el Sort es la mejor forma de entender para qué sirve el DESC dentro de la definición del índice.
  • Punto 18WHERE prestado_en > '2026-01-01' sin filtrar por usuario_id no puede usar ese índice: falta la primera columna del prefijo.
  • Punto 21 — Provoca el fallo con algo que viole un CHECK o una clave foránea dentro de la transacción. Verás que PostgreSQL aborta la transacción entera y que ni siquiera el INSERT que sí funcionó queda guardado.

Si resuelves esto entero, sabes más SQL que la mayoría de desarrolladores backend en activo. El siguiente capítulo sube de nivel: triggers, procedimientos y funciones dentro de la propia base de datos.

🧠 Autoevaluación

Porque si el autor cambia de nombre, tendrías que actualizar todas las filas de libros que lo mencionan — y si olvidas una, los datos quedan inconsistentes. Guardando solo autor_id en libros, el nombre vive en un único sitio (autores) y cambiarlo se refleja automáticamente en todos los libros relacionados.

INNER JOIN devuelve solo las filas que tienen coincidencia en ambas tablas — si un libro no tiene préstamos, desaparece del resultado. LEFT JOIN conserva TODAS las filas de la tabla izquierda aunque no haya coincidencia (rellenando con NULL) — imprescindible para preguntas tipo "qué libros NUNCA se prestaron".

Sin índice, PostgreSQL tiene que escanear la tabla entera fila por fila (seq scan) para encontrar coincidencias. Un índice mantiene una estructura ordenada (B-tree por defecto) que permite localizar filas en O(log n) en vez de O(n) — la diferencia entre milisegundos y segundos con tablas grandes.

Atomicidad: las dos operaciones (insertar el préstamo, marcar el libro no disponible) se tratan como una sola unidad indivisible. Si el servidor se cae entre ambas, PostgreSQL deshace la primera al reiniciar — nunca queda un préstamo registrado sin su libro marcado, ni viceversa.

Viola la Primera Forma Normal (1FN): cada celda debe tener un solo valor atómico. La solución correcta es una tabla intermedia libro_generos (o generos + tabla puente) que modele la relación muchos-a-muchos entre libros y géneros, no una lista separada por comas en una columna.

Porque la restricción vive en la base de datos y se aplica siempre, sin importar si el dato llega desde tu API, un script de migración, o alguien editando filas a mano en psql. Si solo validas en la app, cualquier otro camino de escritura puede colar datos inválidos.

Una subconsulta correlacionada es más simple de leer para casos puntuales con pocas filas (un contador por usuario en un listado pequeño). Pero se ejecuta una vez por cada fila externa, así que con volúmenes grandes un JOIN (o una CTE) que resuelve todo en una sola pasada escala mucho mejor.

Porque NULL significa "desconocido", y comparar cualquier cosa con un valor desconocido produce NULL, no true ni false. WHERE solo deja pasar las filas cuya condición es verdadera, así que descarta todas. Es la lógica de tres valores de SQL: verdadero, falso y desconocido.

Se usa IS NULL / IS NOT NULL, que son operadores específicos para comprobar la ausencia de valor en lugar de comparar valores. La misma trampa aparece disfrazada en NOT IN (subconsulta) cuando la subconsulta puede devolver algún NULL: la consulta entera devuelve cero filas en silencio, sin error ni aviso. Por eso se prefiere NOT EXISTS.

No. Un índice compuesto está ordenado primero por su primera columna, y solo dentro de cada valor de esa columna se ordena por la segunda, y así sucesivamente. Es como una guía telefónica ordenada por provincia, luego ciudad y luego apellido: puedes buscar por provincia, o por provincia+ciudad, pero no puedes buscar solo por ciudad, porque las ciudades están dispersas por toda la guía.

Esta es la regla del prefijo izquierdo: una consulta puede aprovechar el índice si filtra por un prefijo contiguo empezando por la izquierda. La consecuencia práctica es que el orden de las columnas al crear un índice compuesto es la decisión más importante: pon primero las que aparecen en comparaciones de igualdad, y deja los rangos para el final.

La diferencia entre rows= (lo que el planificador estimó) y actual rows= (lo que salió de verdad). Si estima 100 filas y salen dos millones, ha elegido su estrategia con información falsa — por ejemplo un Nested Loop, que es excelente para 100 filas y catastrófico para dos millones.

Ese desajuste suele deberse a estadísticas desactualizadas, y se corrige con ANALYZE tabla. Sin comprobarlo primero, es fácil perder tiempo añadiendo índices cuando el problema real era que la base de datos tenía una idea equivocada de sus propios datos.

Después de eso, se busca el nodo que acumula más tiempo real, y se comprueba si hay un Seq Scan sobre una tabla grande con un WHERE selectivo (falta un índice) o un Sort costoso que un índice podría evitar.

Porque OFFSET no salta: la base de datos tiene que generar y descartar las 100.000 primeras filas para poder entregar las 20 siguientes. El coste crece linealmente con la profundidad, así que la primera página es instantánea y la página 5.000 tarda segundos.

La alternativa es la paginación por cursor: en lugar de decir "salta 100.000", se dice "dame los que vienen después del último que ya tengo": WHERE id < 4821 ORDER BY id DESC LIMIT 20. Eso usa el índice para posicionarse directamente, y tarda lo mismo en la página 1 que en la 5.000.

El precio es que no puedes saltar a una página arbitraria, solo avanzar y retroceder. En la práctica es un precio irrelevante, porque las interfaces reales usan "cargar más" o "siguiente" y prácticamente nadie salta a la página 37.

Porque el LEFT JOIN genera filas con NULL en todas las columnas de la tabla derecha cuando no hay coincidencia, y el WHERE se evalúa después del join (sección 1.8). Una condición como WHERE p.creado_en >= '2026-01-01' se evalúa como NULL >= '2026-01-01' para esas filas, lo que da NULL, no verdadero — y el WHERE las descarta.

El resultado es que tu LEFT JOIN se comporta exactamente como un INNER JOIN, silenciosamente. Poniendo la condición en el ON, se aplica durante el emparejamiento: las filas de la izquierda sin coincidencia se conservan con NULL, que es justo lo que querías.

La regla se resume así: condiciones sobre la tabla obligatoria (izquierda) van en WHERE; condiciones sobre la tabla opcional (derecha) van en el ON.

Porque la base de datos es el único punto por el que pasan todos los caminos de escritura, y el código de aplicación no lo es. A lo largo de la vida de un proyecto los datos se modifican desde la API, desde scripts de migración, desde tareas programadas, desde un psql abierto a las tres de la madrugada durante un incidente, y desde código escrito por personas que ya no están en el equipo. Cualquiera de esos caminos puede saltarse una validación que vive en una sola capa.

Una restricción declarada (NOT NULL, UNIQUE, CHECK, clave foránea, índice único parcial) se aplica siempre, se escribe una vez y no se puede olvidar. Además falla de forma inmediata y ruidosa en lugar de corromper datos en silencio.

Esto no sustituye a la validación en la aplicación —que existe para dar mensajes de error útiles al usuario—, sino que la respalda: la aplicación valida para ser amable, la base de datos restringe para ser correcta.


Siguiente: 02-sql-avanzado.md — lógica dentro de la base de datos.