SQL básico — consultas
"SQL es el inglés de los datos. Si lo manejás bien, podés trabajar en cualquier compañía del planeta."
Qué vas a aprender en este capítulo
SQL es el lenguaje para hablar con bases relacionales. En este capítulo vas a aprender la consulta básica SELECT, los JOIN que conectan tablas, las funciones de agregación que resumen datos (COUNT, SUM, AVG), la cláusula GROUP BY para agrupar, las subconsultas y al final vas a ver el ataque más famoso contra bases de datos: SQL injection, y cómo prevenirlo.
2.1 La idea: declarar qué, no cómo
💡 Intuición
En Python, si querés filtrar productos con stock > 50 escribirías:
caros = []
for p in productos:
if p["stock"] > 50:
caros.append(p)
Tres líneas, dos para la lógica y una para el resultado. Es imperativo: vos decís paso a paso cómo hacerlo.
En SQL:
SELECT * FROM producto WHERE stock > 50;
Una línea. Y vos no decís cómo: decís qué querés. El DBMS decide cómo. Si la tabla tiene índice por stock, lo usa. Si no, recorre. Si tiene 10 millones de filas, paraleliza. Vos solo describís el resultado.
SQL es declarativo. Esa es la diferencia más profunda con los lenguajes de programación que ya conocés.
Esa propiedad permite que el DBMS optimice consultas que un humano nunca optimizaría a mano. Es por eso que SQL escala a millones y miles de millones de filas, mientras que un loop Python explota.
2.2 SELECT básico
📐 Fundamento
Sintaxis general:
SELECT columnas
FROM tabla
WHERE condicion
ORDER BY columna [ASC|DESC]
LIMIT n;
Todas las columnas
SELECT * FROM producto;
* = "todas las columnas". OK para exploración. Mal en producción — devuelve más datos que los necesarios y se rompe si cambia el esquema.
Columnas específicas
SELECT codigo, nombre, precio FROM producto;
Renombrar (alias)
SELECT nombre AS producto, precio * 1.13 AS precio_con_iva
FROM producto;
Condiciones — WHERE
SELECT * FROM producto WHERE precio > 0.50;
SELECT * FROM producto WHERE stock = 0;
SELECT * FROM producto WHERE nombre LIKE 'Pupusa%'; -- empieza con "Pupusa"
SELECT * FROM producto WHERE codigo IN ('Q', 'R', 'F');
SELECT * FROM producto WHERE precio BETWEEN 0.40 AND 0.60;
SELECT * FROM cliente WHERE email IS NULL;
Operadores:
=, <> (o !=), <, >, <=, >= |
Comparación |
AND, OR, NOT |
Lógicos |
IN (lista) |
Está en el conjunto |
BETWEEN a AND b |
Rango (inclusivo) |
LIKE 'patrón' |
Texto con % (cualquier secuencia) y _ (un carácter) |
IS NULL, IS NOT NULL |
Para nulos |
Ordenar
SELECT * FROM producto ORDER BY precio DESC, nombre ASC;
DESC descendente, ASC ascendente (default).
Limitar
SELECT * FROM producto ORDER BY precio DESC LIMIT 5; -- los 5 más caros
SELECT * FROM producto ORDER BY id LIMIT 10 OFFSET 20; -- página 3 si páginas son de 10
OFFSET salta filas. Combinado con LIMIT da paginación.
DISTINCT
Eliminar duplicados:
SELECT DISTINCT cliente_id FROM pedido;
2.3 JOIN — unir tablas
📐 Fundamento
El JOIN combina filas de tablas distintas que cumplen una condición. Es el corazón del modelo relacional.
INNER JOIN
SELECT p.id, c.nombre AS cliente, p.fecha, p.total
FROM pedido AS p
INNER JOIN cliente AS c ON p.cliente_id = c.id;
Devuelve filas donde AMBAS tablas tienen match. Pedidos sin cliente o clientes sin pedidos quedan fuera.
Regla de oro: siempre usá alias (p, c). Hace el SQL legible y permite columnas con mismo nombre.
LEFT JOIN (LEFT OUTER JOIN)
SELECT c.nombre, p.id AS pedido_id, p.fecha
FROM cliente AS c
LEFT JOIN pedido AS p ON c.id = p.cliente_id;
Devuelve todas las filas de la izquierda (cliente). Si no hay match en la derecha, las columnas derechas son NULL. Ejemplo: clientes que aún no pidieron nada.
RIGHT JOIN
Espejo del anterior. Casi nadie lo usa — siempre podés convertir un RIGHT JOIN a LEFT cambiando el orden de las tablas.
FULL OUTER JOIN
Todas las filas de ambos lados, NULL donde no hay match.
CROSS JOIN
Todas las combinaciones (producto cartesiano):
SELECT * FROM cliente CROSS JOIN producto;
-- N clientes × M productos filas
Útil rara vez. Cuidado: explota rápido.
Diagrama mental
JOIN de varias tablas
SELECT c.nombre AS cliente, pr.nombre AS producto, pl.cantidad
FROM pedido AS p
INNER JOIN cliente AS c ON p.cliente_id = c.id
INNER JOIN pedido_linea AS pl ON pl.pedido_id = p.id
INNER JOIN producto AS pr ON pl.producto = pr.codigo
WHERE p.fecha >= '2026-01-01';
Tres JOINs encadenados. Una consulta así te dice quién compró qué en un período.
2.4 Agregación
📐 Fundamento
Las funciones de agregación resumen muchos valores en uno:
| Función | Qué hace |
|---|---|
COUNT(*) |
Cuenta filas |
COUNT(columna) |
Cuenta no-NULL |
SUM(columna) |
Suma |
AVG(columna) |
Promedio |
MIN, MAX |
Extremos |
SELECT COUNT(*) AS total_clientes FROM cliente;
SELECT AVG(precio), MIN(precio), MAX(precio) FROM producto;
SELECT SUM(total) AS ingreso_total FROM pedido WHERE fecha >= '2026-01-01';
GROUP BY
Para agrupar y agregar por categoría:
SELECT cliente_id, COUNT(*) AS pedidos, SUM(total) AS gastado
FROM pedido
GROUP BY cliente_id;
Devuelve una fila por cliente, con el conteo y la suma.
Regla: las columnas en SELECT deben estar en GROUP BY o ser agregaciones. Sino, error.
-- Mal:
SELECT cliente_id, fecha, COUNT(*) FROM pedido GROUP BY cliente_id;
-- ERROR: fecha no está en GROUP BY ni es agregación
-- Bien:
SELECT cliente_id, COUNT(*), MAX(fecha) AS ultima FROM pedido GROUP BY cliente_id;
HAVING
WHERE filtra antes de agrupar, HAVING filtra después:
SELECT cliente_id, COUNT(*) AS pedidos
FROM pedido
GROUP BY cliente_id
HAVING COUNT(*) > 5; -- solo clientes con más de 5 pedidos
HAVING es para condiciones sobre agregaciones.
Orden de ejecución (lógico)
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
Aunque el SQL se escribe en otro orden, se evalúa así. Por eso podés usar alias del SELECT en ORDER BY pero no en WHERE.
2.5 Subconsultas
📐 Fundamento
Una subconsulta es un SELECT dentro de otro:
-- Productos con precio mayor que el promedio
SELECT * FROM producto
WHERE precio > (SELECT AVG(precio) FROM producto);
-- Clientes que pidieron pupusas de queso
SELECT * FROM cliente
WHERE id IN (
SELECT cliente_id FROM pedido WHERE id IN (
SELECT pedido_id FROM pedido_linea WHERE producto = 'Q'
)
);
Tipos:
- Escalar: devuelve un solo valor.
- De fila: devuelve una fila.
- De tabla: devuelve varias filas.
- Correlacionada: referencia columnas de la consulta exterior — se evalúa por cada fila del exterior.
-- Subquery correlacionada: clientes con su pedido más reciente
SELECT c.nombre, (
SELECT MAX(fecha)
FROM pedido p
WHERE p.cliente_id = c.id
) AS ultima_compra
FROM cliente c;
CTE — Common Table Expression (WITH)
WITH ventas_por_cliente AS (
SELECT cliente_id, COUNT(*) AS n_pedidos, SUM(total) AS gastado
FROM pedido
GROUP BY cliente_id
)
SELECT c.nombre, vc.n_pedidos, vc.gastado
FROM cliente c
JOIN ventas_por_cliente vc ON c.id = vc.cliente_id
WHERE vc.gastado > 100;
WITH te deja nombrar una subconsulta. Hace queries complejas mucho más legibles. Casi todo SQL moderno lo soporta.
2.6 SQL Injection — el ataque que NO querés
⚠️ Trampa común
El error #1 de aplicaciones que usan SQL. Si construís SQL concatenando strings con input del usuario, sos vulnerable.
Mal código (Python):
nombre = input("Nombre: ")
cur.execute(f"SELECT * FROM cliente WHERE nombre = '{nombre}'")
Si el usuario escribe: ' OR '1'='1
El SQL resultante es:
SELECT * FROM cliente WHERE nombre = '' OR '1'='1';
'1'='1' siempre es verdadero — devuelve todos los clientes. El atacante acaba de leer toda la tabla.
Peor: '; DROP TABLE cliente; --
SELECT * FROM cliente WHERE nombre = ''; DROP TABLE cliente; --';
Te dropean la tabla. Game over. (El comic XKCD "Bobby Tables" lo ilustra.)
La solución: parámetros
nombre = input("Nombre: ")
cur.execute("SELECT * FROM cliente WHERE nombre = ?", (nombre,))
? (o %s en MySQL/Postgres con DBAPI) es un placeholder. La biblioteca de la DB escapa el valor — el atacante no puede romper el SQL.
Regla absoluta: NUNCA concates input del usuario en SQL. Siempre parámetros.
En el ORM
Frameworks como SQLAlchemy, Django ORM, Laravel Eloquent generan SQL parametrizado automáticamente. Por eso son la opción default en aplicaciones serias.
# SQLAlchemy
clientes = session.query(Cliente).filter_by(nombre=nombre).all()
A prueba de injection.
2.7 Índices — por qué consultas rápidas
📐 Fundamento
Buscar WHERE codigo = 'Q' en una tabla con 10 millones de filas. Sin índice, el DBMS escanea todas — segundos. Con índice sobre codigo, lo encuentra en microsegundos.
Crear:
CREATE INDEX idx_pedido_cliente ON pedido(cliente_id);
CREATE INDEX idx_pedido_fecha ON pedido(fecha);
CREATE UNIQUE INDEX idx_cliente_email ON cliente(email);
Cuándo crear índices:
- Columnas usadas frecuentemente en
WHERE,JOIN,ORDER BY. - PK e
UNIQUEya están indexadas automáticamente.
Cuándo NO:
- Tabla chica (decenas de filas). El índice no compensa.
- Columna con pocos valores distintos (booleano). Índice no ayuda.
- Tabla con muchísimas escrituras y pocas lecturas — los índices se mantienen al insertar/actualizar.
Cómo el DBMS los usa. El optimizador lee tu query, mira las estadísticas y decide qué índices usar. Para inspeccionar:
EXPLAIN ANALYZE SELECT * FROM pedido WHERE cliente_id = 5;
Te muestra el plan de ejecución: cómo el DBMS va a resolver la consulta. Es la herramienta clave para optimizar.
Tipos de índice:
- B-tree (default): bueno para igualdad y rangos.
- Hash: igualdad pura, rápido.
- GiST, GIN: para búsqueda full-text, geográfica.
Profundizamos más en la materia siguiente.
2.8 Proyecto: consultas a la pupusería
🏗️ Avance del proyecto
Suponiendo el esquema del cap 1 con datos:
Reportes típicos
1. Producto más vendido del mes:
SELECT pr.nombre, SUM(pl.cantidad) AS unidades
FROM pedido_linea pl
JOIN producto pr ON pl.producto = pr.codigo
JOIN pedido p ON pl.pedido_id = p.id
WHERE p.fecha >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY pr.nombre
ORDER BY unidades DESC
LIMIT 5;
2. Top clientes (los 10 que más gastaron):
SELECT c.nombre, COUNT(p.id) AS pedidos, SUM(p.total) AS total_gastado
FROM cliente c
LEFT JOIN pedido p ON c.id = p.cliente_id
GROUP BY c.id, c.nombre
ORDER BY total_gastado DESC NULLS LAST
LIMIT 10;
3. Productos sin stock que se vendieron en el último mes (señal de demanda perdida):
SELECT pr.nombre
FROM producto pr
WHERE pr.stock = 0
AND pr.codigo IN (
SELECT pl.producto
FROM pedido_linea pl
JOIN pedido p ON pl.pedido_id = p.id
WHERE p.fecha >= CURRENT_DATE - INTERVAL '30 days'
);
4. Ingreso total por mes:
SELECT
DATE_TRUNC('month', fecha) AS mes,
COUNT(*) AS pedidos,
SUM(total) AS ingreso,
AVG(total) AS ticket_promedio
FROM pedido
GROUP BY mes
ORDER BY mes DESC;
Estas son consultas reales del tipo que un dueño usa para tomar decisiones: qué producto reponer, qué cliente atender mejor, cómo va el negocio.
2.9 Resumen visual
| Cláusula | Para qué |
|---|---|
SELECT cols FROM tabla |
Especifica qué columnas |
WHERE cond |
Filtra filas |
JOIN ... ON ... |
Une tablas |
GROUP BY cols |
Agrupa |
HAVING cond |
Filtra grupos |
ORDER BY |
Ordena |
LIMIT n OFFSET m |
Pagina |
| Subquery | SELECT dentro de otro |
WITH (CTE) |
Subquery con nombre |
EXPLAIN |
Plan de ejecución |
| JOINs |
|---|
INNER JOIN: solo coincidencias |
LEFT JOIN: todas las de izquierda + match |
RIGHT JOIN: todas las de derecha + match |
FULL OUTER JOIN: ambos lados |
2.10 Ejercicios
✏️ Ejercicio 2.1 — SELECT simple
Escribí consultas para:
a. Productos con precio entre 0.50 y 0.70. b. Productos cuyo nombre contiene "queso". c. Top 3 productos más caros.
Solución
-- a
SELECT * FROM producto WHERE precio BETWEEN 0.50 AND 0.70;
-- b
SELECT * FROM producto WHERE nombre LIKE '%queso%';
-- c
SELECT * FROM producto ORDER BY precio DESC LIMIT 3;
✏️ Ejercicio 2.2 — JOIN
Listá todos los pedidos con el nombre del cliente y el total.
Solución
SELECT p.id, c.nombre, p.fecha, p.total
FROM pedido p
LEFT JOIN cliente c ON p.cliente_id = c.id
ORDER BY p.fecha DESC;
LEFT JOIN para incluir pedidos de clientes anónimos (cliente_id NULL).
✏️ Ejercicio 2.3 — Agregación
¿Cuántos pedidos hizo cada cliente y cuánto gastó? Solo los que hicieron al menos 1.
Solución
SELECT c.nombre, COUNT(p.id) AS pedidos, SUM(p.total) AS total
FROM cliente c
JOIN pedido p ON c.id = p.cliente_id
GROUP BY c.id, c.nombre
ORDER BY total DESC;
✏️ Ejercicio 2.4 — Subconsulta
Productos que nunca se han vendido.
Solución
SELECT * FROM producto
WHERE codigo NOT IN (
SELECT DISTINCT producto FROM pedido_linea
);
Versión con LEFT JOIN (más eficiente):
SELECT pr.* FROM producto pr
LEFT JOIN pedido_linea pl ON pr.codigo = pl.producto
WHERE pl.pedido_id IS NULL;
✏️ Ejercicio 2.5 — SQL Injection
Identificá la vulnerabilidad y arregla el código:
import sqlite3
con = sqlite3.connect("clientes.db")
cur = con.cursor()
email = input("Email: ")
cur.execute(f"DELETE FROM cliente WHERE email = '{email}'")
con.commit()
Solución
Vulnerable a SQL injection. Si el usuario escribe ' OR '1'='1, borra TODA la tabla.
Arreglo:
email = input("Email: ")
cur.execute("DELETE FROM cliente WHERE email = ?", (email,))
con.commit()
Bonus: agregar confirmación y mostrar primero qué se va a borrar.
2.11 Para profundizar
- Ben Forta, Sams Teach Yourself SQL in 10 Minutes. Conciso y práctico.
- Joe Celko, SQL for Smarties. Avanzado, lleno de joyas.
- PostgreSQL tutorial: https://www.postgresqltutorial.com/
- Próximo capítulo: Modelado ER — diseñar el esquema antes de escribir SQL.
Definiciones nuevas: SELECT, WHERE, ORDER BY, LIMIT, JOIN (INNER/LEFT/RIGHT/FULL), GROUP BY, HAVING, agregación, subconsulta, CTE (WITH), índice, optimizador, EXPLAIN, SQL injection, parametrización.