SQL es la habilidad más duradera en tecnología — ha sobrevivido a cada framework de los últimos 50 años y sobrevivirá al siguiente lote. También se aprende en una tarde, porque solo le dices a la base de datos qué quieres, nunca cómo conseguirlo. Esta página: el modelo mental, la sección de JOIN que por fin los hace clic, y los errores que todos cometen exactamente una vez.
🎙️ Publicado y grabado:
Una base de datos es un conjunto de tablas — hojas de cálculo con reglas. Cada fila es una cosa, cada columna es un dato sobre ella, y las tablas se refieren entre sí por ID. La gran idea de SQL: tú declaras lo que quieres, y la base de datos descubre cómo. Nunca escribes un bucle.
-- users -- orders
-- id | name | country -- id | user_id | amount
-- 1 | Ada | UK -- 1 | 1 | 19.99
-- 2 | Lin | SG -- 2 | 1 | 5.00
-- 3 | Sam | UK -- 3 | 2 | 42.50
-- "orders.user_id apunta a users.id" — ese enlace es el
-- secreto entero de las bases de datos relacionales. guarda eso.
Cuatro cláusulas cubren la mayoría de queries que escribirás: qué columnas, qué tabla, qué filas, qué orden. Un hábito que vale la pena formar desde el día uno: nombra tus columnas en vez de SELECT * — las queries con asterisco se rompen silenciosamente cuando las tablas cambian, y arrastran megabytes que no necesitabas.
SELECT name, country -- qué datos
FROM users -- qué tabla
WHERE country = 'UK' -- qué filas
ORDER BY name -- qué orden
LIMIT 10; -- cuántas (¡SIEMPRE LIMIT al explorar!)
SELECT * FROM t LIMIT 10 está bien — para eso existe el asterisco. Es en código de aplicación y reportes donde * se convierte en bomba de tiempo. Y pon LIMIT en cada query exploratoria: la diferencia entre "ups" y "ups, 40 millones de filas imprimiéndose".
WHERE filtra filas con operadores familiares — más LIKE para patrones e IN para listas. Y luego está NULL, el marcador de valor ausente que rompe la lógica normal: NULL no es igual a nada, ni siquiera a sí mismo. Todo programador SQL pierde una hora con esto exactamente una vez. La tuya queda reembolsada aquí.
WHERE amount > 20
WHERE country IN ('UK', 'SG', 'DE')
WHERE name LIKE 'A%' -- empieza con A (% = cualquier cosa)
-- la trampa:
WHERE email = NULL -- ⚠ no devuelve NADA. siempre. en silencio.
WHERE email IS NULL -- ✓ la única forma de preguntar
WHERE email IS NOT NULL -- ✓ y su opuesto
= NULL no es un error de sintaxis — se ejecuta bien y devuelve cero filas, porque en la lógica SQL NULL = NULL no es ni verdadero ni falso, es desconocido. Ningún mensaje de error te señalará aquí. Cuando una query misteriosamente no devuelve nada, busca un = NULL antes de buscar cualquier otra cosa.
Olvida los diagramas de Venn — aquí está el modelo que funciona. Un JOIN construye una tabla más ancha emparejando filas: para cada fila de la izquierda, encuentra filas de la derecha donde la condición ON sea verdadera, y pégalas lado a lado. La única pregunta real es: ¿qué pasa con las filas de la izquierda sin pareja? INNER las descarta. LEFT las conserva (con NULLs rellenando el lado derecho).
-- "cada pedido, con el nombre del cliente adjunto"
SELECT orders.id, users.name, orders.amount
FROM orders
JOIN users ON orders.user_id = users.id;
-- "cada USUARIO y sus pedidos — incluyendo usuarios
-- que nunca compraron nada" → LEFT JOIN
SELECT users.name, orders.amount
FROM users
LEFT JOIN orders ON orders.user_id = users.id;
-- Sam no compró nada → una fila: Sam | NULL
WHERE orders.amount > 0 y se pregunta dónde fueron las filas NULL. Filtrar la tabla derecha en WHERE silenciosamente convierte tu LEFT JOIN de vuelta en un INNER. La solución: filtra por la ausencia de match — WHERE orders.id IS NULL.
GROUP BY colapsa filas en grupos — una fila de salida por grupo — y las funciones de agregación (COUNT, SUM, AVG, MAX) resumen cada grupo. Esta es la cláusula que convierte una tabla de eventos crudos en un reporte real.
-- "ingresos y número de pedidos por usuario"
SELECT user_id,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM orders
GROUP BY user_id;
-- user_id | orders | revenue
-- 1 | 2 | 24.99
-- 2 | 1 | 42.50
ERROR: column "users.name" must appear in the GROUP BY clause or be used in an aggregate functionAmbos filtran — la diferencia es cuándo. WHERE filtra filas antes de agruparse; HAVING filtra grupos después de la agregación. La pista: si tu condición contiene COUNT() o SUM(), no puede ir en WHERE — esos números no existen todavía cuando WHERE se ejecuta.
-- "clientes que gastan mucho: solo contar pedidos de 2026,
-- y solo mostrar clientes que gastaron 100+"
SELECT user_id, SUM(amount) AS total
FROM orders
WHERE created_at >= '2026-01-01' -- filtrar FILAS primero
GROUP BY user_id
HAVING SUM(amount) >= 100; -- luego filtrar GRUPOS
Vale la pena interiorizar: la base de datos ejecuta las cláusulas en este orden — FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Por eso tampoco puedes usar un alias de SELECT dentro de WHERE: el alias todavía no ha nacido.
Escribir datos son tres verbos. Dos de ellos — UPDATE y DELETE — comparten el disparo al pie más infame en bases de datos: sin cláusula WHERE, afectan a todas las filas de la tabla. Sin confirmación. Sin "¿estás seguro?". Todo DBA tiene una historia; los buenos la convirtieron en un hábito en vez de un recuerdo.
INSERT INTO users (name, country) VALUES ('Kai', 'JP');
UPDATE users SET country = 'DE' WHERE id = 2; -- una fila ✓
UPDATE users SET country = 'DE'; -- ⚠ TODAS las filas. se fueron.
-- el hábito profesional: envolver escrituras arriesgadas en una transacción
BEGIN;
DELETE FROM orders WHERE created_at < '2020-01-01';
-- verificar: SELECT COUNT(*) FROM orders; ¿contento?
COMMIT; -- o ROLLBACK; para deshacer todo
SELECT primero con el mismo WHERE, verifica el conteo de filas, y luego cambia el verbo. Treinta segundos de ritual contra un evento que actualiza tu currículum. Y en producción, hazlo dentro de BEGIN … COMMIT para tener botón de deshacer.
Sin un índice, WHERE email = '…' lee cada fila de la tabla — un full scan. Un índice es una guía telefónica ordenada para una columna: la base de datos salta directo a la entrada. Cuando una query es misteriosamente lenta, la respuesta es un índice el 90% de las veces.
CREATE INDEX idx_users_email ON users (email);
-- antes: WHERE email = '[email protected]' → escanea 10,000,000 filas
-- después: misma query → ~3 búsquedas
-- ver lo que la base de datos REALMENTE hace:
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';
-- "Seq Scan" = lectura completa, añade un índice. "Index Scan" = ✓
user_id, email, created_at), y deja que EXPLAIN te diga cuándo te equivocas.
Las queries que realmente reutilizarás, organizadas por intención.
-- explorar una tabla desconocida
SELECT * FROM t LIMIT 10;
-- top 10 por ingresos
SELECT user_id, SUM(amount) AS total
FROM orders GROUP BY user_id
ORDER BY total DESC LIMIT 10;
-- filas en A sin pareja en B
SELECT a.* FROM a LEFT JOIN b ON b.a_id = a.id
WHERE b.id IS NULL;
-- encontrar duplicados
SELECT email, COUNT(*) FROM users
GROUP BY email HAVING COUNT(*) > 1;
-- escritura destructiva segura
BEGIN; -- ...UPDATE/DELETE con WHERE... COMMIT; -- o ROLLBACK;
-- ¿por qué es lento?
EXPLAIN <tu query>; -- "Seq Scan" en tabla grande = índice faltante
Eso es SQL funcional. Los dialectos (Postgres, MySQL, SQLite) difieren en las esquinas, pero todo en esta página funciona en todos lados. Siguiente en el stack: Git si no lo has hecho, y la línea de comandos, donde ambos viven.