🗄️ SQL práctico
SQL de verdad: SELECT, WHERE, JOINs, agregados, GROUP BY y HAVING, subconsultas, CTEs, window functions y manejo de NULL, con ejemplos reales sobre un esquema de tienda.
SQL práctico
SQL (Structured Query Language) es el lenguaje de las bases de datos relacionales. Este artículo lo cubre como se usa en el trabajo real: no memorizando sintaxis, sino razonando sobre conjuntos de filas. Todos los ejemplos corren contra el mismo esquema de tienda en PostgreSQL.
Esquema de ejemplo
CREATE TABLE usuarios (
id SERIAL PRIMARY KEY,
nombre TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
creado_en TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE productos (
id SERIAL PRIMARY KEY,
nombre TEXT NOT NULL,
categoria TEXT NOT NULL,
precio NUMERIC(10,2) NOT NULL,
stock INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
usuario_id INTEGER REFERENCES usuarios(id),
total NUMERIC(10,2) NOT NULL,
creado_en TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE pedido_items (
pedido_id INTEGER REFERENCES pedidos(id),
producto_id INTEGER REFERENCES productos(id),
cantidad INTEGER NOT NULL,
precio_unit NUMERIC(10,2) NOT NULL
);
💡 En PostgreSQL usa
SERIALoGENERATED ALWAYS AS IDENTITYpara claves autoincrementales. Prueba cada ejemplo conpsqlo con tu cliente favorito: SQL se aprende ejecutando, no leyendo.
SELECT, WHERE, ORDER BY, LIMIT
SELECT nombre, precio
FROM productos
WHERE categoria = 'Libros'
ORDER BY precio DESC
LIMIT 10;
Orden lógico de ejecución (importante para entender errores):
| Orden | Cláusula | Qué hace |
|---|---|---|
| 1 | FROM |
De dónde salen las filas |
| 2 | WHERE |
Filtra filas (no puede usar alias de SELECT) |
| 3 | GROUP BY |
Agrupa |
| 4 | HAVING |
Filtra grupos |
| 5 | SELECT |
Proyecta columnas y aplica alias |
| 6 | ORDER BY |
Ordena (sí puede usar alias) |
| 7 | LIMIT |
Recorta el resultado |
SELECT nombre, precio * 1.21 AS precio_con_iva
FROM productos
WHERE stock > 0
ORDER BY nombre
LIMIT 5;
⚠️
WHEREse ejecuta antes queSELECT, así que no puedes usar un alias del SELECT en el WHERE. Eso es un error de novato típico:WHERE precio_con_iva > 20no compila. Usa la expresión completa o un subquery/CTE.
JOINs
Los JOIN combinan filas de dos tablas según una condición. Imagina la tabla producto cartesiano y el JOIN la filtra:
-- INNER JOIN: solo filas que existen en ambas
SELECT p.nombre, pi.cantidad
FROM pedido_items pi
JOIN productos p ON p.id = pi.producto_id
WHERE pi.pedido_id = 7;
| Tipo | Filas devueltas |
|---|---|
INNER JOIN |
Solo coincidencias en ambas tablas |
LEFT JOIN |
Todas las de la izquierda + coincidencias de la derecha |
RIGHT JOIN |
Todas las de la derecha + coincidencias de la izquierda |
FULL JOIN |
Todas las filas de ambas, rellena con NULL |
-- LEFT JOIN: todos los usuarios, con o sin pedidos
SELECT u.nombre, COUNT(p.id) AS pedidos
FROM usuarios u
LEFT JOIN pedidos p ON p.usuario_id = u.id
GROUP BY u.nombre;
💡
LEFT JOIN+WHERE derecha.id IS NULLes el patrón “filas que NO tienen contraparte”, el anti-join:
-- Productos que nunca se han pedido
SELECT pr.nombre
FROM productos pr
LEFT JOIN pedido_items pi ON pi.producto_id = pr.id
WHERE pi.producto_id IS NULL;
Agregados: COUNT, SUM, AVG, MIN, MAX
Los agregados resumen muchas filas en un valor. La regla de oro: todo agregado convierte N filas en 1; si mezclas una columna normal con un agregado sin GROUP BY, SQL te rellena el resto con NULL o falla.
SELECT
COUNT(*) AS total_productos,
COUNT(DISTINCT categoria) AS categorias,
AVG(precio) AS precio_medio,
MIN(precio) AS mas_barato,
MAX(precio) AS mas_caro
FROM productos;
⚠️
COUNT(*)cuenta filas, incluidos losNULL;COUNT(columna)solo cuenta losNULL-libres. Para un “¿existe algo?” usaEXISTS, noCOUNT(corta antes de contar todo).
GROUP BY y HAVING
GROUP BY agrupa las filas y permite aplicar agregados por grupo. HAVING filtra los grupos (como un WHERE pero post-agregación):
SELECT categoria, COUNT(*) AS n, SUM(stock) AS stock_total
FROM productos
GROUP BY categoria
HAVING COUNT(*) > 3
ORDER BY n DESC;
Pregunta concreta: categorías cuya facturación total supera 1000.
SELECT pr.categoria, SUM(pi.cantidad * pi.precio_unit) AS facturado
FROM pedido_items pi
JOIN productos pr ON pr.id = pi.producto_id
GROUP BY pr.categoria
HAVING SUM(pi.cantidad * pi.precio_unit) > 1000;
💡
WHEREfiltra filas antes de agrupar;HAVINGfiltra grupos después de agregar. Si el filtro no usa un agregado, va enWHERE(más rápido).
Subconsultas
Una subconsulta es un SELECT dentro de otro. En WHERE devuelve un valor o un conjunto:
-- Productos más caros que la media
SELECT nombre, precio
FROM productos
WHERE precio > (SELECT AVG(precio) FROM productos);
-- Pedidos de usuarios creados después de 2025
SELECT p.id, p.total
FROM pedidos p
WHERE p.usuario_id IN (
SELECT id FROM usuarios WHERE creado_en > '2025-01-01'
);
⚠️
IN (SELECT …)funciona pero si la lista puede ser grande, plantéateJOINoEXISTS(que corta al primer match).NOT INconNULLs es una trampa:x NOT IN (1, NULL)nunca es verdadero. PrefiereNOT EXISTS.
CTEs con WITH
Un CTE (Common Table Expression) nombra una consulta para reutilizarla dentro de otra. Hace el SQL legible y componible:
WITH ventas_por_usuario AS (
SELECT u.id, u.nombre, SUM(p.total) AS gastado
FROM usuarios u
JOIN pedidos p ON p.usuario_id = u.id
GROUP BY u.id, u.nombre
),
usuarios_top AS (
SELECT * FROM ventas_por_usuario
WHERE gastado > 500
)
SELECT nombre, gastado
FROM usuarios_top
ORDER BY gastado DESC;
Un CTE puede referenciar otro CTE anterior en el mismo WITH, como en el ejemplo. Los CTEs con INSERT/UPDATE/DELETE ... RETURNING sirven también para encadenar operaciones.
💡 Regla práctica: si tu
WHEREse vuelve ilegible, extrae un CTE. Las window functions y los CTEs son la diferencia entre SQL de 2007 y SQL moderno.
Window functions
Las funciones de ventana calculan valores sobre un grupo de filas sin colapsarlas: cada fila conserva su identidad y añade una columna calculada. Se marcan con OVER.
-- Ranking de productos por precio dentro de su categoría
SELECT nombre, categoria, precio,
ROW_NUMBER() OVER (PARTITION BY categoria ORDER BY precio DESC) AS pos,
RANK() OVER (PARTITION BY categoria ORDER BY precio DESC) AS puesto
FROM productos;
| Función | Qué devuelve |
|---|---|
ROW_NUMBER() |
Índice correlativo único (1, 2, 3…) |
RANK() |
Puesto con huecos para empates (1, 1, 3) |
DENSE_RANK() |
Puesto sin huecos (1, 1, 2) |
LAG(col) / LEAD(col) |
Valor de la fila anterior / siguiente |
SUM(col) OVER (...) |
Total acumulado |
Total acumulado de pedidos por mes:
SELECT
DATE_TRUNC('month', creado_en) AS mes,
total,
SUM(total) OVER (ORDER BY creado_en) AS acumulado
FROM pedidos
ORDER BY creado_en;
Comparar el pedido de hoy con el anterior de cada usuario:
SELECT usuario_id, total, creado_en,
LAG(total) OVER (PARTITION BY usuario_id ORDER BY creado_en) AS pedido_anterior
FROM pedidos;
💡 Pregunta mental para decidir: ¿las filas deben colapsarse (agregado normal con
GROUP BY) o conservarse añadiendo una columna calculada (window function)? Rankings, acumulados y comparaciones entre filas → window functions.
Índices básicos
Un índice acelera la búsqueda de las columnas indexadas a costa de escritura y espacio. Crea uno sobre las columnas que filtran o unen mucho:
CREATE INDEX idx_pedidos_usuario ON pedidos (usuario_id);
CREATE INDEX idx_productos_categoria ON productos (categoria);
CREATE INDEX idx_pedido_items_pedido ON pedido_items (pedido_id);
EXPLAIN ANALYZE
SELECT * FROM pedidos WHERE usuario_id = 42;
💡 Más en profundidad en Índices y EXPLAIN. Regla rápida: indexa las
FOREIGN KEYque usas en losJOIN(PostgreSQL no lo hace automáticamente) y las columnas de losWHEREfrecuentes.
NULL: el valor ausente
NULL no es cero ni cadena vacía: es “desconocido”. Toda comparación con NULL da NULL (ni verdadero ni falso), y NULL en WHERE se descarta:
SELECT * FROM productos WHERE precio = NULL; -- devuelve 0 filas SIEMPRE
SELECT * FROM productos WHERE precio IS NULL; -- así sí
| Operador | Significado |
|---|---|
IS NULL / IS NOT NULL |
Comprobar ausencia |
COALESCE(x, 0) |
Primero valor no nulo de la lista |
NULLIF(a, b) |
NULL si a = b, si no a |
x IS DISTINCT FROM y |
Comparación que trata NULL como valor real |
SELECT nombre, COALESCE(email, 'sin-email') FROM usuarios;
⚠️ En agregados,
SUM/AVGignoran losNULL, pero unSUMde todo-NULL devuelveNULL, no 0. Si el total puede ser nulo, envuélvelo:COALESCE(SUM(total), 0).
Cheatsheet
| Necesito… | Sintaxis |
|---|---|
| Filtrar | WHERE col = x |
| Ordenar | ORDER BY col DESC |
| Limitar | LIMIT n OFFSET m |
| Unir tablas | JOIN tabla ON a.id = b.id |
| Contar grupos | GROUP BY col + COUNT(*) |
| Filtrar grupos | HAVING COUNT(*) > n |
| Reutilizar consulta | WITH cte AS (...) SELECT ... |
| Ranking | ROW_NUMBER() OVER (ORDER BY col) |
| Fila anterior | LAG(col) OVER (ORDER BY col) |
| Nulo seguro | COALESCE(col, default) |
Para profundizar
- PostgreSQL Documentation — SQL: la referencia definitiva.
- Mode SQL Tutorial: el mejor tutorial práctico gratuito que existe.
- SQLBolt: ejercicios interactivos de SQL desde cero.
- PostgreSQL Tutorial: guía estructurada con cientos de ejemplos.
- Use The Index, Luke!: SQL pensado para el rendimiento (siguiente paso tras este artículo).