SQL desde cero
SQL (Structured Query Language) es el lenguaje estándar para comunicarse con bases de datos relacionales. Aprende desde las consultas más básicas hasta técnicas avanzadas con ejercicios prácticos.
CREATE TABLE y tipos de datos
CREATE TABLE define la estructura de una nueva tabla. Especificas el nombre de la tabla, las columnas, sus tipos de datos y las restricciones (constraints). Es el equivalente a diseñar el plano antes de construir.
CREATE TABLE usuarios (
id SERIAL PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
edad INT CHECK (edad > 0),
creado_en TIMESTAMP DEFAULT NOW()
);Tipos de datos principales
- INT / INTEGER — números enteros (-2^31 a 2^31-1). Para rangos mayores usa
BIGINT. - SERIAL / BIGSERIAL — auto-incrementales. PostgreSQL los implementa como
INTEGER GENERATED BY DEFAULT AS IDENTITY. - VARCHAR(n) — texto de longitud variable con límite. Ej:
VARCHAR(255). Sin límite:TEXT. - NUMERIC(p, s) — decimal exacto.
p= dígitos totales,s= decimales. Ej:NUMERIC(10,2)= 99999999.99. - DATE — solo fecha (2026-07-29).
TIMESTAMP— fecha + hora.TIMESTAMPTZ— con zona horaria. - BOOLEAN — true / false / NULL.
- UUID — identificador universal. Almacena 16 bytes, se genera con funciones como
gen_random_uuid(). - JSON / JSONB — datos JSON. JSONB es binario, indexable y más eficiente (PostgreSQL).
Constraints (restricciones)
Las constraints garantizan la integridad de los datos. Cada una tiene un propósito específico:
- PRIMARY KEY — identifica cada fila de forma única. Combina NOT NULL + UNIQUE. Una tabla solo puede tener una.
- FOREIGN KEY — referencia a otra tabla. Garantiza integridad referencial. Ej:
usuario_id INT REFERENCES usuarios(id). Puedes añadirON DELETE CASCADEpara borrar en cascada. - UNIQUE — todos los valores en la columna deben ser distintos. Permite NULL (y los NULLs se consideran distintos entre sí).
- NOT NULL — la columna no puede tener valores nulos.
- CHECK — valida que los datos cumplan una condición booleana. Ej:
CHECK (edad >= 0 AND edad < 150). - DEFAULT — valor por defecto cuando no se especifica. Ej:
DEFAULT NOW(),DEFAULT 0.
SERIAL no es un tipo de dato real, sino un atajo que crea una columna INTEGER con DEFAULT que toma el siguiente valor de una secuencia. En PostgreSQL moderno se prefiere GENERATED AS IDENTITY.ALTER TABLE y DROP TABLE
Una vez creada, puedes modificar la estructura con ALTER TABLE:
-- Añadir columna
ALTER TABLE usuarios ADD COLUMN telefono VARCHAR(20);
-- Eliminar columna
ALTER TABLE usuarios DROP COLUMN telefono;
-- Cambiar tipo
ALTER TABLE usuarios ALTER COLUMN edad TYPE SMALLINT;
-- Añadir constraint
ALTER TABLE usuarios ADD CONSTRAINT chk_edad CHECK (edad >= 0);
-- Renombrar tabla
ALTER TABLE usuarios RENAME TO clientes;
-- Eliminar tabla (con datos)
DROP TABLE usuarios;
-- Vaciar tabla (sin estructura)
TRUNCATE TABLE usuarios;DROP TABLE elimina la tabla y sus datos permanentemente. TRUNCATE solo vacía los datos pero mantiene la estructura — es más rápido que DELETE FROM porque no escanea filas ni dispara triggers.
INSERT
INSERT INTO añade nuevas filas a una tabla. Puedes insertar una fila, varias, o incluso el resultado de una consulta:
-- Insertar una fila
INSERT INTO usuarios (nombre, email, edad) VALUES ('Ana', 'ana@email.com', 28);
-- Insertar múltiples filas en una sentencia
INSERT INTO usuarios (nombre, email, edad) VALUES
('Luis', 'luis@email.com', 35),
('María', 'maria@email.com', 42);
-- Insertar con SELECT (copia entre tablas)
INSERT INTO usuarios_backup (nombre, email, edad)
SELECT nombre, email, edad FROM usuarios WHERE activo = true;
-- INSERT con RETURNING (PostgreSQL)
INSERT INTO usuarios (nombre, email) VALUES ('Carlos', 'carlos@email.com')
RETURNING id, creado_en;El RETURNING devuelve los valores insertados (o cualquier expresión) — muy útil para obtener el ID generado sin hacer una segunda consulta.
SELECT
SELECT es el comando más usado de SQL. Recupera datos de una o más tablas. Su estructura básica es: SELECT columnas FROM tabla WHERE condiciones ORDER BY columna LIMIT n.
-- Seleccionar todo
SELECT * FROM usuarios;
-- Columnas específicas con alias
SELECT nombre AS "Nombre completo", email AS Correo FROM usuarios;
-- Filtrar con WHERE
SELECT * FROM usuarios WHERE edad > 30;
-- Ordenar y limitar
SELECT * FROM usuarios ORDER BY edad DESC LIMIT 5;
-- Filtros combinados
SELECT * FROM usuarios
WHERE edad BETWEEN 25 AND 40
AND email LIKE '%@gmail.com'
AND ciudad IN ('Madrid', 'Barcelona')
ORDER BY nombre ASC;
-- DISTINCT: valores únicos
SELECT DISTINCT ciudad FROM usuarios;
-- CASE: lógica condicional en consultas
SELECT nombre,
CASE
WHEN edad < 18 THEN 'Menor'
WHEN edad BETWEEN 18 AND 65 THEN 'Adulto'
ELSE 'Jubilado'
END AS grupo_edad
FROM usuarios;
-- COALESCE: valor por defecto si es NULL
SELECT nombre, COALESCE(telefono, 'No disponible') AS telefono FROM usuarios;Cláusula WHERE a fondo
Los operadores más usados en WHERE:
- =, >, <, >=, <=, <> — comparación (<> significa "distinto de")
- BETWEEN — rango inclusivo:
edad BETWEEN 18 AND 65 - LIKE — patrones:
%(cualquier secuencia),_(un carácter). Ej:nombre LIKE 'A%'(empieza con A) - IN — conjunto:
ciudad IN ('Madrid', 'Bcn') - IS NULL / IS NOT NULL — comparación con nulos (no se usa = NULL ya que NULL no es igual a nada)
- AND / OR / NOT — lógica booleana. AND se evalúa antes que OR, usa paréntesis para agrupar
EXPLAIN ANALYZE delante de cualquier SELECT para ver cómo el motor ejecuta la consulta (índices, joins, escaneos) y detectar cuellos de botella. Ej: EXPLAIN ANALYZE SELECT * FROM usuarios WHERE email = 'ana@email.com';UPDATE y DELETE
UPDATE modifica filas existentes. DELETE las elimina. Ambos requieren WHERE para seleccionar qué filas afectar:
-- UPDATE básico
UPDATE usuarios SET edad = 29 WHERE nombre = 'Ana';
-- Múltiples columnas
UPDATE usuarios
SET edad = 30, ciudad = 'Madrid'
WHERE email = 'ana@email.com';
-- UPDATE con expresión
UPDATE productos SET precio = precio * 1.10 WHERE categoria = 'electrónica';
-- UPDATE con JOIN (PostgreSQL, MySQL)
UPDATE usuarios u
SET total_gastado = (SELECT SUM(total) FROM pedidos p WHERE p.usuario_id = u.id)
WHERE u.activo = true;
-- DELETE básico
DELETE FROM usuarios WHERE email IS NULL;
-- DELETE con subconsulta
DELETE FROM usuarios WHERE id NOT IN (SELECT usuario_id FROM pedidos);
-- RETURNING (PostgreSQL)
DELETE FROM usuarios WHERE edad < 18 RETURNING id, nombre;
-- TRUNCATE (vacía toda la tabla, más rápido que DELETE)
TRUNCATE TABLE usuarios;WHERE en UPDATE y DELETE. Sin WHERE, la operación afectará a TODAS las filas de la tabla. Si realmente quieres vaciar la tabla, usa TRUNCATE que es más rápido y seguro.RETURNING (PostgreSQL) devuelve las filas modificadas o eliminadas. Es útil para auditoría, logs, o devolver datos al cliente sin una consulta adicional. Las filas se devuelven antes de aplicar DELETE, y después de aplicar UPDATE.
JOINs
Los JOINs combinan filas de dos o más tablas basándose en una condición relacionada. Primero creamos una tabla de pedidos para los ejemplos:
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
usuario_id INT REFERENCES usuarios(id),
producto VARCHAR(150) NOT NULL,
total DECIMAL(10,2) NOT NULL,
fecha DATE DEFAULT CURRENT_DATE
);Tipos de JOIN
-- INNER JOIN: solo filas que coinciden en ambas tablas
SELECT u.nombre, p.producto, p.total
FROM usuarios u
INNER JOIN pedidos p ON u.id = p.usuario_id;
-- LEFT JOIN: todas las filas de la izquierda + coincidencias de la derecha
SELECT u.nombre, COUNT(p.id) AS pedidos
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre;
-- RIGHT JOIN: todas las filas de la derecha + coincidencias
SELECT u.nombre, p.producto
FROM usuarios u
RIGHT JOIN pedidos p ON u.id = p.usuario_id;
-- FULL OUTER JOIN: todas las filas de ambas tablas
SELECT u.nombre, p.producto
FROM usuarios u
FULL OUTER JOIN pedidos p ON u.id = p.usuario_id;
-- CROSS JOIN: producto cartesiano (cada fila de A con cada fila de B)
SELECT u.nombre, p.producto
FROM usuarios u
CROSS JOIN productos p; -- 3 usuarios × 10 productos = 30 filas
-- SELF JOIN: una tabla con sí misma (ej: empleados y jefes)
SELECT e.nombre AS empleado, j.nombre AS jefe
FROM empleados e
LEFT JOIN empleados j ON e.jefe_id = j.id;Visualmente, los JOINs se entienden como diagramas de Venn:
- INNER JOIN — la intersección (solo lo que existe en ambas)
- LEFT JOIN — todo el círculo izquierdo + intersección
- RIGHT JOIN — todo el círculo derecho + intersección
- FULL OUTER JOIN — ambos círculos completos, con o sin match
- CROSS JOIN — cada elemento de A combinado con cada elemento de B (sin condición)
ON define la condición de unión. Puedes filtrar también con WHERE después del JOIN. La diferencia es que ON filtra antes de la unión (afecta a qué filas se unen) y WHERE filtra después (afecta al resultado final).GROUP BY y agregación
Las funciones de agregación resumen múltiples filas en un solo resultado. Combinadas con GROUP BY agrupan filas con valores comunes:
SELECT
u.nombre,
COUNT(p.id) AS total_pedidos,
SUM(p.total) AS gasto_total,
AVG(p.total) AS ticket_medio,
MIN(p.total) AS pedido_minimo,
MAX(p.total) AS pedido_maximo
FROM usuarios u
LEFT JOIN pedidos p ON u.id = p.usuario_id
GROUP BY u.id, u.nombre
HAVING COUNT(p.id) > 0
ORDER BY gasto_total DESC;Funciones de agregación
- COUNT(*) — cuenta todas las filas del grupo.
COUNT(columna)cuenta solo las no NULL.COUNT(DISTINCT columna)cuenta valores únicos. - SUM(columna) — suma de valores numéricos. Ignora NULLs.
- AVG(columna) — promedio (media aritmética). Ignora NULLs.
- MIN / MAX — valor mínimo / máximo. Funciona con números, fechas y texto (orden alfabético).
- STRING_AGG(columna, delimitador) — concatena valores (PostgreSQL). Ej:
STRING_AGG(producto, ', ').
HAVING
HAVING es como WHERE pero para grupos. WHERE filtra filas individuales antes de agrupar; HAVING filtra grupos después de la agregación:
-- Válido: HAVING con agregación
SELECT usuario_id, COUNT(*) AS pedidos
FROM pedidos
GROUP BY usuario_id
HAVING COUNT(*) > 5;
-- Inválido (no se puede usar alias en HAVING en algunos motores):
-- HAVING pedidos > 5;
-- FILTER (PostgreSQL): agregación condicional
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE total > 100) AS pedidos_grandes
FROM pedidos;ROLLUP, CUBE, GROUPING SETS
Extensiones de GROUP BY para subtotales y totales:
-- ROLLUP: subtotales jerárquicos (categoría → total)
SELECT categoria, SUM(precio) AS total
FROM productos
GROUP BY ROLLUP(categoria);
-- CUBE: todas las combinaciones
SELECT categoria, color, SUM(precio)
FROM productos
GROUP BY CUBE(categoria, color);Window Functions
Las funciones de ventana (window functions) realizan cálculos a través de un conjunto de filas relacionadas sin agruparlas en una sola salida. A diferencia de GROUP BY, cada fila conserva su identidad:
-- ROW_NUMBER: numeración dentro de cada grupo
SELECT nombre, departamento, salario,
ROW_NUMBER() OVER (PARTITION BY departamento ORDER BY salario DESC) AS posicion
FROM empleados;
-- RANK: igual que ROW_NUMBER pero empata en valores iguales
SELECT producto, total,
RANK() OVER (ORDER BY total DESC) AS ranking
FROM pedidos;
-- LAG / LEAD: acceder a filas anterior/siguiente
SELECT fecha, total,
LAG(total) OVER (ORDER BY fecha) AS total_anterior,
LEAD(total) OVER (ORDER BY fecha) AS total_siguiente
FROM pedidos;
-- Diferencia entre pedido actual y anterior
-- Puedes hacer: total - LAG(total) OVER (ORDER BY fecha) AS diferenciaComponentes de una window function: FUNCIÓN() OVER (PARTITION BY columna ORDER BY columna). PARTITION BY divide en grupos (opcional), ORDER BY define el orden dentro de cada grupo.
Subconsultas y CTEs
Subconsultas
Una subconsulta es un SELECT dentro de otro SELECT. Puede estar en WHERE, FROM, SELECT, o HAVING:
-- Subconsulta en WHERE (con IN)
SELECT * FROM usuarios
WHERE id IN (SELECT usuario_id FROM pedidos WHERE total > 100);
-- Subconsulta correlacionada (referencia a la consulta externa)
SELECT u.nombre, u.email,
(SELECT COUNT(*) FROM pedidos p WHERE p.usuario_id = u.id) AS total_pedidos
FROM usuarios u;
-- Subconsulta en FROM (tabla derivada)
SELECT AVG(gasto) AS gasto_medio
FROM (
SELECT usuario_id, SUM(total) AS gasto
FROM pedidos
GROUP BY usuario_id
) AS resumen;
-- EXISTS (más eficiente que IN para correlacionadas)
SELECT * FROM usuarios u
WHERE EXISTS (SELECT 1 FROM pedidos p WHERE p.usuario_id = u.id AND p.total > 100);EXISTS vs IN: EXISTS es más rápido cuando la subconsulta puede devolver muchas filas, porque para en cuanto encuentra la primera coincidencia. IN es más legible para conjuntos pequeños.
CTEs (Common Table Expressions)
Las CTEs con WITH son como "variables temporales" para consultas. Hacen el SQL más legible y permiten la recursividad:
-- CTE básica
WITH clientes_top AS (
SELECT usuario_id, SUM(total) AS gasto
FROM pedidos
GROUP BY usuario_id
ORDER BY gasto DESC
LIMIT 3
)
SELECT u.nombre, ct.gasto
FROM clientes_top ct
JOIN usuarios u ON u.id = ct.usuario_id;
-- CTE recursiva (ej: jerarquía de categorías)
WITH RECURSIVE categorias_tree AS (
-- Caso base: categorías raíz
SELECT id, nombre, padre_id, 1 AS nivel
FROM categorias WHERE padre_id IS NULL
UNION ALL
-- Paso recursivo: hijos
SELECT c.id, c.nombre, c.padre_id, ct.nivel + 1
FROM categorias c
JOIN categorias_tree ct ON c.padre_id = ct.id
)
SELECT * FROM categorias_tree ORDER BY nivel, nombre;Las CTES recursivas son ideales para estructuras jerárquicas: árboles de categorías, organigramas, foros de comentarios, rutas de navegación, y cualquier dato con auto-referencia.
Operadores de conjunto
SQL también permite operaciones de conjunto (como en matemáticas) entre los resultados de dos consultas:
-- UNION: combina resultados, elimina duplicados
SELECT nombre, email FROM usuarios_activos
UNION
SELECT nombre, email FROM usuarios_inactivos;
-- UNION ALL: combina resultados, conserva duplicados
SELECT ciudad FROM usuarios_madrid
UNION ALL
SELECT ciudad FROM usuarios_barcelona;
-- INTERSECT: filas comunes en ambas consultas
SELECT producto FROM pedidos_2025
INTERSECT
SELECT producto FROM pedidos_2026;
-- EXCEPT: filas en la primera pero no en la segunda
SELECT email FROM usuarios
EXCEPT
SELECT email FROM usuarios_verificados;Para que funcionen, ambas consultas deben tener el mismo número de columnas y tipos de datos compatibles. UNION ALL es más rápido que UNION porque evita el paso de eliminar duplicados.
Ejercicios
Practica lo aprendido con estos ejercicios. Intenta resolverlos antes de mirar la solución.
SQL es una habilidad fundamental para cualquier desarrollador. Dedica tiempo a practicar estas queries y se convertirán en algo natural. Para profundizar, explora los tutoriales de MySQL y PostgreSQL donde verás las particularidades de cada motor.