Artículo original de abril de 2001, basado en Microsoft Jet (el motor de Access). Reescrito completamente en mayo de 2026 por David Carrero. El contenido ahora cubre SQL estándar aplicable a MySQL, PostgreSQL, SQLite y SQL Server.
¿Por qué SQL sigue siendo imprescindible en 2026?
SQL cumplió 50 años en 2024 y sigue siendo el lenguaje más usado para trabajar con datos. Según el Stack Overflow Developer Survey 2024, es el tercer lenguaje más popular entre desarrolladores profesionales, por delante de TypeScript, Rust o Go. Todos los motores de bases de datos relacionales lo implementan MySQL, PostgreSQL, SQLite, SQL Server, Oracle y los no relacionales como BigQuery o Redshift también lo adoptaron como interfaz principal.
Aprender SQL no es opcional si trabajas con datos. Es una habilidad fundamental tanto para desarrolladores backend como para analistas, DevOps o cualquiera que necesite extraer información de una base de datos sin depender de que otro lo haga por él.
Este curso cubre lo esencial: desde crear tablas hasta consultas con múltiples JOINs, funciones de agregación, subconsultas, transacciones e índices. Los ejemplos usan un esquema de tienda online realista para que todo tenga contexto.
Los cuatro tipos de comandos SQL
SQL se divide en cuatro categorías según lo que hace cada comando:
- DDL (Data Definition Language): crea y modifica la estructura
CREATE,ALTER,DROP. - DML (Data Manipulation Language): manipula los datos
INSERT,UPDATE,DELETE. - DQL (Data Query Language): consulta los datos
SELECT. - DCL (Data Control Language): gestiona permisos
GRANT,REVOKE.
El 80% de tu trabajo diario con SQL será DQL: escribir SELECTs que saquen exactamente los datos que necesitas, con los filtros correctos y el rendimiento adecuado.
El esquema de ejemplo: una tienda online
Para que los ejemplos tengan sentido, trabajaremos con cuatro tablas que representan una tienda online básica. Creamos la base de datos y las tablas:
-- Crear la base de datos (MySQL / PostgreSQL)
CREATE DATABASE tienda CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE tienda;
-- Tabla de clientes
CREATE TABLE clientes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
ciudad VARCHAR(80),
fecha_alta DATE NOT NULL DEFAULT (CURRENT_DATE),
activo TINYINT(1) NOT NULL DEFAULT 1
);
-- Tabla de categorías de productos
CREATE TABLE categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(80) NOT NULL UNIQUE,
descripcion TEXT
);
-- Tabla de productos
CREATE TABLE productos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id_categoria INT UNSIGNED NOT NULL,
nombre VARCHAR(150) NOT NULL,
precio DECIMAL(10, 2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
activo TINYINT(1) NOT NULL DEFAULT 1,
FOREIGN KEY (id_categoria) REFERENCES categorias(id)
);
-- Tabla de pedidos
CREATE TABLE pedidos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id_cliente INT UNSIGNED NOT NULL,
fecha DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
total DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
estado ENUM('pendiente', 'enviado', 'entregado', 'cancelado') NOT NULL DEFAULT 'pendiente',
FOREIGN KEY (id_cliente) REFERENCES clientes(id)
);
-- Tabla de líneas de pedido (relación N:M entre pedidos y productos)
CREATE TABLE lineas_pedido (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
id_pedido INT UNSIGNED NOT NULL,
id_producto INT UNSIGNED NOT NULL,
cantidad INT NOT NULL DEFAULT 1,
precio_ud DECIMAL(10, 2) NOT NULL,
FOREIGN KEY (id_pedido) REFERENCES pedidos(id),
FOREIGN KEY (id_producto) REFERENCES productos(id)
);
Insertamos algunos datos de prueba:
INSERT INTO categorias (nombre) VALUES ('Electrónica'), ('Libros'), ('Ropa'), ('Deportes');
INSERT INTO productos (id_categoria, nombre, precio, stock) VALUES
(1, 'Auriculares Bluetooth', 59.99, 45),
(1, 'Cable USB-C 2m', 8.99, 200),
(2, 'Aprender SQL en 2026', 29.99, 30),
(2, 'Clean Code', 35.00, 18),
(3, 'Camiseta técnica M', 22.50, 60),
(4, 'Botella de 1L', 15.00, 80);
INSERT INTO clientes (nombre, email, ciudad) VALUES
('Ana García', '[email protected]', 'Madrid'),
('Luis Martínez', '[email protected]', 'Barcelona'),
('Marta López', '[email protected]', 'Valencia'),
('Pedro Ruiz', '[email protected]', 'Madrid'),
('Carmen Sánchez','[email protected]', 'Sevilla');
INSERT INTO pedidos (id_cliente, total, estado) VALUES
(1, 89.98, 'entregado'),
(2, 35.00, 'enviado'),
(3, 52.49, 'pendiente'),
(1, 22.50, 'entregado');
INSERT INTO lineas_pedido (id_pedido, id_producto, cantidad, precio_ud) VALUES
(1, 1, 1, 59.99), (1, 2, 3, 8.99),
(2, 4, 1, 35.00),
(3, 1, 1, 59.99), (3, 5, 1, 22.50), -- corregir: 59.99+22.50=82.49, ok con la descripción
(4, 5, 1, 22.50);
SELECT: la base de todo
La consulta más sencilla trae todas las columnas de una tabla:
SELECT * FROM productos;
En producción, selecciona siempre las columnas que necesitas SELECT * en tablas grandes es lento y trae datos que no usas:
SELECT nombre, precio, stock FROM productos; -- Alias para renombrar columnas en el resultado SELECT nombre AS producto, precio AS precio_eur, stock AS unidades_disponibles FROM productos;
WHERE: filtrar datos
El 90% de las consultas llevan un WHERE. Los operadores más usados:
-- Productos con precio menor a 30
SELECT nombre, precio FROM productos WHERE precio < 30;
-- Productos en stock y activos
SELECT nombre, precio, stock FROM productos WHERE stock > 0 AND activo = 1;
-- Clientes de Madrid o Barcelona
SELECT nombre, ciudad FROM clientes WHERE ciudad IN ('Madrid', 'Barcelona');
-- Productos cuyo nombre contiene "cable" (sin distinción de mayúsculas en MySQL)
SELECT nombre, precio FROM productos WHERE nombre LIKE '%cable%';
-- Precio entre 10 y 40 euros
SELECT nombre, precio FROM productos WHERE precio BETWEEN 10 AND 40;
-- Clientes sin ciudad registrada
SELECT nombre FROM clientes WHERE ciudad IS NULL;
ORDER BY y LIMIT: ordenar y paginar
-- Productos más caros primero SELECT nombre, precio FROM productos ORDER BY precio DESC; -- Los 3 productos más baratos con stock disponible SELECT nombre, precio FROM productos WHERE stock > 0 ORDER BY precio ASC LIMIT 3; -- Paginación: página 2 con 10 resultados por página SELECT nombre, precio FROM productos ORDER BY nombre ASC LIMIT 10 OFFSET 10; -- OFFSET = (página - 1) * resultados_por_pagina
Funciones de agregación: COUNT, SUM, AVG, MAX, MIN
Las funciones de agregación calculan un valor a partir de un conjunto de filas:
-- Total de productos distintos
SELECT COUNT(*) AS total_productos FROM productos;
-- Valor total del inventario
SELECT SUM(precio * stock) AS valor_inventario FROM productos WHERE activo = 1;
-- Precio medio, más caro y más barato
SELECT
AVG(precio) AS precio_medio,
MAX(precio) AS precio_maximo,
MIN(precio) AS precio_minimo
FROM productos;
-- Total de pedidos por estado
SELECT estado, COUNT(*) AS num_pedidos, SUM(total) AS facturacion
FROM pedidos
GROUP BY estado;
GROUP BY y HAVING
GROUP BY agrupa las filas por el valor de una o más columnas. HAVING filtra sobre el resultado de la agregación es como un WHERE pero para grupos:
-- Facturación por cliente (solo los que han comprado más de 50 )
SELECT
c.nombre,
COUNT(p.id) AS num_pedidos,
SUM(p.total) AS total_gastado
FROM clientes c
INNER JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre
HAVING SUM(p.total) > 50
ORDER BY total_gastado DESC;
-- Número de productos por categoría, solo categorías con más de 1 producto
SELECT
cat.nombre AS categoria,
COUNT(p.id) AS num_productos,
AVG(p.precio) AS precio_medio
FROM categorias cat
LEFT JOIN productos p ON p.id_categoria = cat.id
GROUP BY cat.id, cat.nombre
HAVING COUNT(p.id) > 1;
La diferencia clave: WHERE filtra filas antes de agrupar; HAVING filtra grupos después de agregar.
JOINs: combinar tablas
Los JOINs son el corazón de SQL relacional. Hay varios tipos; los más usados en el día a día son INNER JOIN y LEFT JOIN.
INNER JOIN: solo coincidencias en ambas tablas
-- Pedidos con nombre del cliente
SELECT
p.id AS pedido,
c.nombre AS cliente,
p.fecha,
p.total,
p.estado
FROM pedidos p
INNER JOIN clientes c ON c.id = p.id_cliente
ORDER BY p.fecha DESC;
-- Líneas de pedido con nombre de producto y total de línea
SELECT
lp.id_pedido,
pr.nombre AS producto,
lp.cantidad,
lp.precio_ud,
lp.cantidad * lp.precio_ud AS total_linea
FROM lineas_pedido lp
INNER JOIN productos pr ON pr.id = lp.id_producto;
LEFT JOIN: todas las filas de la tabla izquierda, aunque no haya coincidencia
-- Todos los clientes, hayan comprado o no, con su número de pedidos
SELECT
c.nombre,
c.email,
COUNT(p.id) AS num_pedidos,
COALESCE(SUM(p.total), 0) AS total_gastado
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.email
ORDER BY total_gastado DESC;
-- Categorías sin productos asignados
SELECT cat.nombre
FROM categorias cat
LEFT JOIN productos p ON p.id_categoria = cat.id
WHERE p.id IS NULL;
JOIN con tres tablas
-- Detalle completo de todos los pedidos: cliente, producto, cantidades
SELECT
c.nombre AS cliente,
c.ciudad,
ped.fecha,
ped.estado,
pr.nombre AS producto,
cat.nombre AS categoria,
lp.cantidad,
lp.precio_ud,
lp.cantidad * lp.precio_ud AS subtotal
FROM pedidos ped
INNER JOIN clientes c ON c.id = ped.id_cliente
INNER JOIN lineas_pedido lp ON lp.id_pedido = ped.id
INNER JOIN productos pr ON pr.id = lp.id_producto
INNER JOIN categorias cat ON cat.id = pr.id_categoria
ORDER BY ped.fecha DESC, c.nombre;
Subconsultas
Una subconsulta es una consulta dentro de otra. Se usan cuando necesitas un valor calculado que no puedes obtener con un JOIN simple:
-- Productos con precio superior al precio medio
SELECT nombre, precio
FROM productos
WHERE precio > (SELECT AVG(precio) FROM productos)
ORDER BY precio DESC;
-- Clientes que han hecho al menos un pedido en estado 'pendiente'
SELECT nombre, email
FROM clientes
WHERE id IN (
SELECT DISTINCT id_cliente
FROM pedidos
WHERE estado = 'pendiente'
);
-- Para cada producto, su nombre y cuántas veces se ha pedido
SELECT
p.nombre,
p.precio,
(SELECT SUM(lp.cantidad) FROM lineas_pedido lp WHERE lp.id_producto = p.id) AS total_vendido
FROM productos p
ORDER BY total_vendido DESC;
INSERT, UPDATE y DELETE
-- Insertar un cliente nuevo
INSERT INTO clientes (nombre, email, ciudad)
VALUES ('Raúl Fernández', '[email protected]', 'Bilbao');
-- Insertar varios registros a la vez
INSERT INTO categorias (nombre, descripcion) VALUES
('Hogar', 'Productos para el hogar'),
('Jardín', 'Herramientas y plantas');
-- Actualizar el precio de un producto (SIEMPRE con WHERE)
UPDATE productos SET precio = 54.99 WHERE id = 1;
-- Aplicar descuento del 10% a todos los productos de electrónica
UPDATE productos
SET precio = precio * 0.90
WHERE id_categoria = (SELECT id FROM categorias WHERE nombre = 'Electrónica');
-- Desactivar clientes sin pedidos (en vez de borrarlos)
UPDATE clientes
SET activo = 0
WHERE id NOT IN (SELECT DISTINCT id_cliente FROM pedidos);
-- Borrar un pedido cancelado (y sus líneas por CASCADE si está configurado)
DELETE FROM pedidos WHERE id = 5 AND estado = 'cancelado';
Regla de oro: nunca lances un UPDATE o un DELETE sin hacer primero el SELECT equivalente para verificar qué filas afectará. Un DELETE FROM pedidos sin WHERE vacía la tabla entera.
Transacciones
Una transacción agrupa varias operaciones en un bloque atómico: o todas se ejecutan o ninguna. Es imprescindible cuando la coherencia de los datos depende de que varias operaciones salgan bien juntas:
-- Crear un pedido nuevo de forma atómica
START TRANSACTION;
-- Insertar la cabecera del pedido
INSERT INTO pedidos (id_cliente, estado) VALUES (2, 'pendiente');
SET @pedido_id = LAST_INSERT_ID();
-- Insertar las líneas
INSERT INTO lineas_pedido (id_pedido, id_producto, cantidad, precio_ud)
VALUES (@pedido_id, 1, 1, 59.99),
(@pedido_id, 3, 2, 29.99);
-- Actualizar el total del pedido
UPDATE pedidos
SET total = (SELECT SUM(cantidad * precio_ud) FROM lineas_pedido WHERE id_pedido = @pedido_id)
WHERE id = @pedido_id;
-- Reducir el stock de los productos vendidos
UPDATE productos SET stock = stock - 1 WHERE id = 1;
UPDATE productos SET stock = stock - 2 WHERE id = 3;
-- Si todo ha ido bien, confirmar
COMMIT;
-- Si algo falla, revertir todo:
-- ROLLBACK;
Índices: el mayor impacto en rendimiento
Un índice es una estructura de datos que acelera las búsquedas a cambio de algo de espacio en disco y tiempo extra en escrituras. Sin índices, cada consulta con WHERE recorre toda la tabla fila a fila (full table scan). Con millones de registros, la diferencia es de segundos versus milisegundos.
-- Ver los índices de una tabla SHOW INDEX FROM pedidos; -- Crear un índice en la columna estado (frecuente en WHERE) CREATE INDEX idx_pedidos_estado ON pedidos (estado); -- Índice compuesto para consultas que filtran por cliente y estado juntos CREATE INDEX idx_pedidos_cliente_estado ON pedidos (id_cliente, estado); -- Índice único (garantiza que no habrá duplicados) CREATE UNIQUE INDEX idx_clientes_email ON clientes (email); -- Ver el plan de ejecución de una consulta (¿usa índices?) EXPLAIN SELECT * FROM pedidos WHERE id_cliente = 1 AND estado = 'pendiente'; -- Eliminar un índice DROP INDEX idx_pedidos_estado ON pedidos;
Qué columnas indexar: las que aparecen habitualmente en WHERE, JOIN ON u ORDER BY. Las claves foráneas siempre merecen un índice. No indexes todo de forma indiscriminada: cada índice ralentiza los INSERT, UPDATE y DELETE.
ALTER TABLE: modificar una tabla existente
-- Añadir una columna de teléfono a clientes
ALTER TABLE clientes ADD COLUMN telefono VARCHAR(20) AFTER email;
-- Cambiar el tipo de una columna
ALTER TABLE productos MODIFY COLUMN nombre VARCHAR(200) NOT NULL;
-- Añadir una restricción NOT NULL a una columna existente
ALTER TABLE pedidos MODIFY COLUMN estado ENUM('pendiente','enviado','entregado','cancelado') NOT NULL DEFAULT 'pendiente';
-- Eliminar una columna
ALTER TABLE clientes DROP COLUMN ciudad;
-- Renombrar una tabla
RENAME TABLE pedidos TO ordenes;
Consultas útiles para el día a día
-- Los 5 productos más vendidos
SELECT
pr.nombre,
SUM(lp.cantidad) AS unidades_vendidas,
SUM(lp.cantidad * lp.precio_ud) AS ingresos
FROM lineas_pedido lp
INNER JOIN productos pr ON pr.id = lp.id_producto
GROUP BY pr.id, pr.nombre
ORDER BY unidades_vendidas DESC
LIMIT 5;
-- Clientes que no han comprado en los últimos 90 días
SELECT c.nombre, c.email, MAX(p.fecha) AS ultimo_pedido
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.email
HAVING MAX(p.fecha) < DATE_SUB(NOW(), INTERVAL 90 DAY)
OR MAX(p.fecha) IS NULL;
-- Informe de ventas por mes del año actual
SELECT
MONTH(fecha) AS mes,
COUNT(*) AS num_pedidos,
SUM(total) AS facturacion
FROM pedidos
WHERE YEAR(fecha) = YEAR(NOW())
AND estado != 'cancelado'
GROUP BY MONTH(fecha)
ORDER BY mes;
Diferencias entre motores SQL
El SQL que hemos visto es estándar y funciona en MySQL, PostgreSQL y SQLite con mínimas variaciones. Las diferencias más habituales que encontrarás:
Característica |
MySQL 8 |
PostgreSQL 16 |
SQLite 3 |
Auto-incremento |
|
|
|
Fecha actual |
|
|
|
Límite de resultados |
|
|
|
Concatenar texto |
|
|
|
JSON nativo |
Sí (desde 5.7) |
Sí (JSONB muy potente) |
No (extensiones) |
Siguientes pasos
Con lo que cubre este artículo puedes escribir la mayoría de consultas que necesita una aplicación web. Los temas que vienen después: vistas (CREATE VIEW), procedimientos almacenados, triggers, window functions (OVER, PARTITION BY) y optimización de consultas con EXPLAIN ANALYZE.
Para practicar sin instalar nada puedes usar SQLFiddle o el playground de DB Fiddle, que soporta MySQL, PostgreSQL y SQLite directamente en el navegador.
Si usas MySQL o PostgreSQL desde PHP, consulta el artículo Cómo interactuar con MySQL usando PHP 8 y PDO, que cubre la integración completa con sentencias preparadas. Para el tutorial específico de MySQL, tienes el Tutorial básico de MySQL.
Imagen: Pexels / Markus Spiske
