Saltar al contenido principal
SQL29 de julio de 202615 min de lectura
Principiante15 min Full Stack Developer Backend Developer Frontend Developer

SQL desde cero

Aprende SQL desde lo más básico hasta técnicas avanzadas: SELECT, JOINs, GROUP BY, subconsultas, CTEs y ejercicios prácticos con solución.

SQLSELECTJOINGROUP BYCTETutorial

Lo que aprenderás

  • Escribir consultas SELECT con filtros y ordenación
  • Usar JOINs para combinar tablas
  • Aplicar funciones de agregación con GROUP BY
  • Crear subconsultas y CTEs
  • Resolver 8 ejercicios prácticos
Tutorial20 min

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ñadir ON DELETE CASCADE para 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.
La 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.

Insertar múltiples filas en una sola sentencia es mucho más eficiente que hacer INSERTs individuales en bucle. La mayoría de motores tienen un límite práctico (~1000 filas por INSERT).

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
Usa 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;
Siempre usa 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)
El 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 diferencia

Componentes 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.

Las window functions son muy potentes para rankings, diferencias entre filas consecutivas, totales acumulados, y promedios móviles. Son una alternativa elegante a las subconsultas correlacionadas.

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.