🎯 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
pedidosconusuario_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):
docker run --name pg -e POSTGRES_PASSWORD=secreto -p 5432:5432 -d postgres:18Y te conectas con el cliente psql:
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 salir1.3 · DDL — Crear la estructura (tablas)
DDL = Data Definition Language: define cómo son los datos.
-- 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:
| Elemento | Qué hace |
|---|---|
BIGINT GENERATED ALWAYS AS IDENTITY | Entero grande autoincremental (1, 2, 3…). La forma moderna (mejor que SERIAL). |
PRIMARY KEY | Identificador único de la fila |
VARCHAR(100) | Texto de máximo 100 caracteres |
NOT NULL | Obligatorio (no puede quedar vacío) |
UNIQUE | No se puede repetir (dos usuarios no pueden tener el mismo email) |
DEFAULT true | Valor por defecto si no se especifica |
TIMESTAMPTZ | Fecha 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
| Tipo | Para qué | Ejemplo |
|---|---|---|
BIGINT / INTEGER | Números enteros | id, edad, cantidad |
NUMERIC(10,2) | Decimales exactos (¡dinero!) | precio: 99.90 |
VARCHAR(n) / TEXT | Texto | nombre, descripción |
BOOLEAN | Verdadero/falso | activo |
TIMESTAMPTZ | Fecha y hora con zona | creado_en |
DATE | Solo fecha | fecha_nacimiento |
UUID | Identificador único universal | claves públicas |
JSONB | JSON indexable | metadatos flexibles |
⚠️ Nunca uses
FLOAT/REALpara dinero. Los decimales de coma flotante tienen errores de redondeo (0.1 + 0.2 != 0.3). Para dinero usa siempreNUMERIC(10,2). Este error ha costado millones en sistemas reales.
La clave foránea (relación)
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
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
-- 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:
= != < > <= >= 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 nuloUPSERT — 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:
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 CONFLICTnecesita una restricciónUNIQUEoPRIMARY KEYsobre la columna que declaras (aquí,
UPDATE → UPDATE
UPDATE usuarios
SET nombre = 'Ana María López', activo = false
WHERE id = 1;⚠️ El error más caro del mundo SQL: olvidar el
WHEREen unUPDATEoDELETE.UPDATE usuarios SET activo = false;(sin WHERE) desactiva a TODOS los usuarios.Tip de supervivencia: escribe primero elWHEREcomoSELECT, comprueba que devuelve las filas correctas, y solo entonces cámbialo porUPDATE/DELETE.
DELETE → DELETE
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 TIMESTAMPTZy 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.
INSERT INTO usuarios (nombre, email, telefono) VALUES ('Ana', 'ana@x.com', NULL);
-- └─ no sabemos su teléfonoLa regla que rompe todo: NULL no es igual a nada, ni siquiera a sí mismo
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 contrueofalse. Esto se llama lógica de tres valores: en SQL una condición puede ser verdadera, falsa o desconocida, yWHEREsolo deja pasar las verdaderas.
La trampa que se lleva por delante a todo el mundo: NOT IN
-- 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.
-- ✅ 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 EXISTSen lugar deNOT INsiempre que la subconsulta pueda contenerNULL. 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
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(*)yCOUNT(columna)es un truco útil, no solo una trampa:COUNT(fecha_cancelacion)te da directamente cuántos pedidos fueron cancelados, sin ningúnWHERE.
Funciones para trabajar con NULL
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ón | Decisió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 texto | NOT NULL DEFAULT '' — decide uno de los dos y sé coherente |
| Números que representan cantidad | NOT NULL DEFAULT 0 |
⚠️ El peor escenario es permitir a la vez
NULLy cadena vacía en la misma columna de texto. Acabas conWHERE 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 conNOT NULLo unCHECK.
💡 Diseño por defecto: pon
NOT NULLen todo, y quítalo solo cuando puedas justificar qué significa elNULLen esa columna. CadaNULLpermitido 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.
-- "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
●● ●●● ●● ●●●
●██● ●██●● ●●██● ●██●●
●● ●●● ●● ●●●| JOIN | Qué devuelve |
|---|---|
INNER JOIN | Solo filas que coinciden en ambas tablas |
LEFT JOIN | Todas las de la izquierda + las que coincidan de la derecha (NULL si no) |
RIGHT JOIN | Todas las de la derecha + coincidencias de la izquierda |
FULL JOIN | Todas de ambas tablas |
El más usado es INNER JOIN. El segundo, LEFT JOIN (ej: "todos los usuarios, tengan pedidos o no"):
-- 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:
-- "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.nombrefrente aproductos.nombredeja de ser una cuestión de estilo.
LEFT JOIN + WHERE: el error que lo convierte en INNER JOIN
-- ❌ 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. 💀-- ✅ 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 enWHERE; las condiciones sobre la tabla derecha (la opcional) van en elON. Si van en elWHERE, eliminan las filas sin coincidencia y pierdes el sentido delLEFT JOIN.
SELF JOIN: unir una tabla consigo misma
Útil cuando una fila se relaciona con otra de la misma tabla (jerarquías, referidos, respuestas):
-- 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 jefe1.7 · Agregación — Resumir datos
Las funciones de agregación calculan un valor a partir de muchas filas:
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ínimoCon GROUP BY agrupas antes de agregar ("total gastado por cada usuario"):
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;🧠
WHEREvsHAVING:WHEREfiltra filas antes de agrupar;HAVINGfiltra grupos después de agregar. No puedes usarSUM()en unWHERE, pero sí en unHAVING.
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 filasTres consecuencias prácticas que se deducen de ese orden:
1. No puedes usar un alias del SELECT dentro del WHERE.
-- ❌ 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 sí puedes usarlo en ORDER BY (paso 8), porque para entonces el SELECT ya se ejecutó:
SELECT precio * 1.21 AS total_con_iva FROM productos ORDER BY total_con_iva DESC; -- ✅2. WHERE filtra filas; HAVING filtra grupos.
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ÉSPoner WHERE SUM(total) > 1000 es un error: en el paso 3 los grupos todavía no existen.
💡 Y filtrar en
WHEREes más rápido que filtrar enHAVING, porque descarta filas antes de hacer el trabajo de agrupar. Si una condición se puede expresar enWHERE, 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-- Í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.
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
-- ❌ 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
| Tipo | Para qué | Ejemplo |
|---|---|---|
| B-tree (por defecto) | Igualdad, rangos, orden | = < > BETWEEN ORDER BY |
| GIN | Contenido dentro de un valor | JSONB, arrays, texto completo |
| GiST | Datos geométricos, rangos, solapamientos | mapas, reservas horarias |
| BRIN | Tablas enormes con datos correlacionados por orden físico | logs por fecha |
| Hash | Solo igualdad exacta | raro; B-tree suele bastar |
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)
-- 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
| Ganas | Pagas |
|---|---|
| Lecturas mucho más rápidas | Cada INSERT/UPDATE/DELETE actualiza también el índice |
ORDER BY sin ordenar | Espacio en disco (a veces más que la propia tabla) |
UNIQUE garantiza unicidad | Mantenimiento 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,JOINyORDER BYde consultas que realmente ejecutas, y revisa periódicamente cuáles no se usan:sqlSELECT 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 INDEXsinCONCURRENTLYbloquea 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 siempreCREATE 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.
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 msCómo se lee: de dentro hacia fuera y de abajo hacia arriba. Las líneas más indentadas ocurren primero.
| Qué ves | Qué significa |
|---|---|
Seq Scan | Lee la tabla entera. Bien en tablas pequeñas; alarma en grandes |
Index Scan | Usa un índice y va a buscar la fila. 👍 |
Index Only Scan | Resuelve todo dentro del índice, sin tocar la tabla. 🏆 |
Bitmap Heap Scan | Muchas coincidencias: recoge posiciones y luego lee en bloque |
Nested Loop | Por cada fila de A busca en B. Excelente si A es pequeño, fatal si es grande |
Hash Join | Construye una tabla hash. Bueno para volúmenes grandes |
Merge Join | Ambas entradas ordenadas. Bueno si ya venían ordenadas |
Sort | Está 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) yactual 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:sqlANALYZE 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 ANALYZEantes 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".
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 pasadoLas transacciones garantizan las propiedades ACID:
| Letra | Propiedad | Significado |
|---|---|---|
| A | Atomicidad | Todo o nada |
| C | Consistencia | La BD pasa de un estado válido a otro válido |
| I | Aislamiento | Las transacciones concurrentes no se pisan |
| D | Durabilidad | Una 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) oasync with db.transaction():(FastAPI). Por debajo es exactamente esteBEGIN/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_idy el nombre vive solo en la tablausuarios.
Las 3 formas normales, en simple:
- 1FN: cada celda tiene un solo valor (nada de
telefonos: "111, 222, 333"en una columna). - 2FN: cada columna depende de toda la clave primaria.
- 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:
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"🧠
CHECKes la misma idea queNOT 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 unestadoinventado no puede colarse ni por un bug en tu app, ni por alguien que edite datos directamente enpsql.
Una subconsulta (subquery) es un SELECT dentro de otra consulta. Útil cuando necesitas el resultado de una consulta para filtrar otra:
-- 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.iddesde 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
capacidadde 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
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
COUNTal vuelo o con un contador desnormalizado? Con decenas de reservas por sesión,COUNTes correcto y simple. Un contador es más rápido pero puede desincronizarse. Empieza porCOUNT. - ¿Y la condición de carrera al reservar la última plaza? El
UNIQUEevita reservas duplicadas, pero no el exceso de aforo. Eso necesitaSELECT … FOR UPDATEo una restricción adicional — es exactamente el caso 1 del capítulo 26. - ¿Cancelar una sesión borra las reservas? No: se marca
cancelada = truey 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
| # | Error | Por qué duele | Solución |
|---|---|---|---|
| 1 | Concatenar variables en la consulta | Inyección SQL: acceso total a tus datos | Consultas parametrizadas, siempre |
| 2 | SELECT * en producción | Trae columnas que no usas, se rompe al añadir campos, impide Index Only Scan | Enumera las columnas |
| 3 | Problema N+1 | 1 consulta + una por cada resultado: 1 ms se convierte en 300 ms | JOIN o carga ansiosa del ORM |
| 4 | FLOAT para dinero | 0.1 + 0.2 ≠ 0.3: descuadres contables | NUMERIC(10,2) |
| 5 | OFFSET 100000 para paginar | La BD lee y descarta 100.000 filas en cada página | Paginación por cursor: WHERE id < ultimo_id LIMIT 20 |
| 6 | NOT IN con posibles NULL | Devuelve cero filas en silencio | NOT EXISTS |
| 7 | Función sobre la columna del WHERE | Anula el índice | Índice sobre la expresión, o normalizar al guardar |
| 8 | UPDATE/DELETE sin WHERE | Modificas la tabla entera | BEGIN; → comprueba → COMMIT; |
| 9 | CREATE INDEX sin CONCURRENTLY | Bloquea la tabla varios minutos en producción | CONCURRENTLY siempre |
| 10 | Transacciones larguísimas | Bloqueos, conexiones agotadas, VACUUM bloqueado | Transacciones cortas; nada de llamadas de red dentro |
El truco que te salvará de un DELETE catastrófico
-- ✅ 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 mismoWHERE. Si el número cuadra, cambia elSELECT COUNT(*)por elDELETE. Es el mismo hábito de mirar antes de destruir que aplicabas army afind -deleteen el capítulo T3.
Paginación por cursor, que deberías usar por defecto
-- ❌ 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 anteriorEl 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
- Diseña el esquema siguiendo el proceso de la sección 1.14:
autores,libros,usuarios,prestamosygeneros(un libro puede tener varios géneros). - Todas las columnas deben ser
NOT NULLsalvo las que puedas justificar. Escribe en un comentario qué significa elNULLen cada columna que lo permita. - Añade al menos tres
CHECKque impidan datos imposibles. prestamosdebe 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
- Inserta 4 autores, 10 libros, 5 usuarios y 12 préstamos (algunos ya devueltos).
- Título del libro + nombre del autor (
JOIN). - Cuántos libros tiene cada autor, incluidos los que no tienen ninguno (piensa qué
JOIN). - Libros que nunca se han prestado. Hazlo de dos formas distintas (
NOT EXISTSyLEFT JOIN … IS NULL) y compara los planes conEXPLAIN. - Los 3 usuarios con más préstamos, con su recuento.
- Préstamos actualmente activos (sin fecha de devolución) con más de 15 días de retraso.
- Para cada género, cuántos libros hay y cuál es el más prestado.
Parte 3 — NULL y sus trampas
- Escribe una consulta que parezca correcta pero devuelva 0 filas por culpa de
NOT INconNULL. Reprodúcelo de verdad y explica en un comentario por qué ocurre. - Muestra la diferencia entre
COUNT(*)yCOUNT(fecha_devolucion)sobreprestamos. ¿Qué significa cada número en términos del negocio? - Lista todos los préstamos ordenados por fecha de devolución, con los no devueltos primero.
Parte 4 — Índices y rendimiento
- 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; - Ejecuta
EXPLAIN ANALYZEde "préstamos de un usuario concreto ordenados por fecha descendente". Anota el tiempo real y el tipo de nodo. - Crea el índice adecuado. Vuelve a medir. Anota la mejora exacta.
- 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é. - Crea un índice parcial solo para los préstamos no devueltos y compara su tamaño con el índice completo (
\di+en psql). - Escribe una consulta con
OFFSET 400000y la equivalente con cursor. Compara los tiempos.
Parte 5 — Transacciones
- 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
SELECTque no quedó nada a medias. - Escribe el
DELETEde los préstamos anteriores a 2024 usando el patrón seguroBEGIN→ 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 7 —
LEFT JOINdesdeautores, yCOUNT(libros.id)(noCOUNT(*)):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 escribesfecha_devolucion > …no obtienes nada, porqueNULLno compara. - Punto 14 —
ORDER BY fecha_devolucion ASC NULLS FIRST. - Punto 16-17 — Antes del índice verás
Seq Scan+Sorty decenas o cientos de milisegundos. ConCREATE INDEX ON prestamos (usuario_id, prestado_en DESC)pasarás aIndex Scany a una fracción de milisegundo, sin nodoSort: el índice ya entrega las filas ordenadas. Ver desaparecer elSortes la mejor forma de entender para qué sirve elDESCdentro de la definición del índice. - Punto 18 —
WHERE prestado_en > '2026-01-01'sin filtrar porusuario_idno puede usar ese índice: falta la primera columna del prefijo. - Punto 21 — Provoca el fallo con algo que viole un
CHECKo una clave foránea dentro de la transacción. Verás que PostgreSQL aborta la transacción entera y que ni siquiera elINSERTque 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.