¿Qué es SQL?
SQL, pronunciado “sequel” o “S-Q-L”, es el Structured Query Language (Lenguaje de Consulta Estructurado) utilizado para leer y escribir datos en bases de datos relacionales. Fue estandarizado en 1986, lo que lo hace más antiguo que la web, y sigue siendo la forma en que casi todas las aplicaciones se comunican con sus datos.
SQL se divide en dos partes. El DDL (lenguaje de definición de datos) crea y modifica la estructura: CREATE TABLE, ALTER TABLE, DROP INDEX. El DML (lenguaje de manipulación de datos) trabaja con los datos en sí: SELECT, INSERT, UPDATE, DELETE. Pasarás la mayor parte del tiempo usando DML, siendo SELECT la sentencia más común con diferencia.
Algo importante que debes entender desde el principio es que SQL no es un lenguaje de programación en el sentido habitual. No existen los bucles en el núcleo del lenguaje ni variables en una consulta simple. Tú describes el resultado que deseas y la base de datos determina cómo generarlo.
Declarativo y basado en conjuntos
En un lenguaje como JavaScript o Python, calcularías un reporte iterando:
const result = [];
for (const author of authors) {
let count = 0;
for (const post of posts) {
if (post.author_id === author.id) count++;
}
if (count > 0) result.push({ author, count });
}
En SQL, expresas la misma intención en una sola sentencia:
SELECT a.name, COUNT(p.id) AS posts
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
GROUP BY a.id, a.name
HAVING COUNT(p.id) > 0;
No hay bucles. Declaraste que quieres cada autor con su recuento de posts. La base de datos puede resolver esto mediante un index scan, un hash join o cualquier otra técnica; y si mañana añades un índice, la misma consulta puede volverse más rápida sin cambiar un solo carácter.
Esta es la mentalidad basada en conjuntos (set-based): las sentencias operan sobre conjuntos completos de filas a la vez. Una vez que comprendes este concepto, SQL se vuelve conciso de una manera que los bucles rara vez logran.
SELECT: eligiendo columnas
Cada lectura comienza con SELECT, donde se enumeran las columnas que deseas obtener.
SELECT id, title, published_at
FROM posts;
SELECT * devuelve todas las columnas y es útil mientras exploras los datos, pero especifica el nombre de tus columnas en las consultas de la aplicación. Las listas explícitas son estables cuando el esquema cambia, evitan la transferencia de columnas grandes que no se utilizan y hacen que la intención sea obvia. Puedes calcular nuevas columnas y renombrarlas:
SELECT title,
LENGTH(body) AS body_length,
COALESCE(published_at, created_at) AS visible_at
FROM posts;
AS le asigna un alias a una columna. Úsalo para que los resultados sean legibles y para dar nombre a las columnas calculadas, lo cual es importante cuando una librería de cliente mapea las filas a objetos.
Filtrado con WHERE
WHERE mantiene únicamente las filas que cumplen una condición. Soporta las comparaciones habituales además de AND, OR, NOT, IN, BETWEEN y LIKE.
SELECT id, title
FROM posts
WHERE author_id = 7
AND published_at IS NOT NULL
AND title ILIKE '%sql%';
Hay dos detalles que vale la pena recordar. Primero, LIKE distingue entre mayúsculas y minúsculas en muchas bases de datos, mientras que ILIKE (Postgres) no lo hace; el LIKE de MySQL tampoco distingue entre mayúsculas y minúsculas con las collations habituales. Segundo, un comodín al inicio como '%sql' impide que un índice ordinario sea de ayuda, ya que no hay un prefijo al cual buscar. Para búsquedas de texto reales, utiliza en su lugar un índice de texto completo (full-text index).
Ordenación y paginación
ORDER BY ordena el resultado, y LIMIT junto con OFFSET lo recorta.
SELECT id, title
FROM posts
WHERE published_at IS NOT NULL
ORDER BY published_at DESC
LIMIT 20 OFFSET 40;
Sin ORDER BY, la base de datos no garantiza el orden de las filas. Una consulta que “casualmente” devuelve los resultados ordenados hoy puede cambiar cuando el planificador elija un plan diferente, por lo que siempre debes ordenar explícitamente cuando el orden sea importante.
OFFSET omite filas, lo cual se vuelve costoso en tablas grandes cuando se avanza mucho en los resultados, ya que la base de datos sigue teniendo que recorrerlas. La paginación basada en claves (keyset pagination) evita este coste recordando la última fila vista:
SELECT id, title
FROM posts
WHERE published_at < :last_published_at
ORDER BY published_at DESC
LIMIT 20;
NULL y la lógica trivalente
NULL no significa cero ni una cadena vacía. Significa desconocido, y SQL utiliza una lógica trivalente: cada condición se evalúa como verdadera, falsa o desconocida. Las filas solo pasan una cláusula WHERE cuando la condición es verdadera, por lo que el valor desconocido se trata como falso para el filtrado.
SELECT id FROM posts WHERE published_at = NULL; -- never matches
SELECT id FROM posts WHERE published_at IS NULL; -- correct
SELECT id FROM posts WHERE published_at IS NOT NULL;
Consecuencias a tener en cuenta:
NULL = NULLes desconocido, no verdadero. UsaIS NULLoIS NOT DISTINCT FROM.NULL + 1esNULL; usaCOALESCE(x, 0)para sustituirlo por un valor predeterminado.NOT IN (1, 2, NULL)nunca es verdadero, ya que comparar con un elemento desconocido da como resultado desconocido. PrefiereNOT EXISTScuando la lista pueda contenerNULL.- Los agregados ignoran los
NULL:COUNT(column)cuenta los valores que no son nulos, mientras queCOUNT(*)cuenta las filas.
La nulidad es una decisión de diseño. Declara las columnas como NOT NULL a menos que los valores ausentes tengan un significado real.
JOINs, con un diagrama
Un join combina filas de dos tablas basándose en una columna relacionada. Supongamos que authors tiene a Ada, Grace y Linus, y posts tiene dos filas de Ada y una de Grace. Los tipos de join producen resultados diferentes.
authors posts
------- ----------------------------
id name id author_id title
1 Ada 10 1 Joins
2 Grace 11 1 Indexes
3 Linus 12 2 NULLs
INNER JOIN -> posts 10, 11, 12 (only matches)
LEFT JOIN -> posts 10, 11, 12, Linus (all authors, NULL post)
RIGHT JOIN -> posts 10, 11, 12 (all posts)
FULL JOIN -> posts 10, 11, 12, Linus (both sides)
-- Every author, including those with no posts.
SELECT a.name, p.title
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
ORDER BY a.name;
La condición del join se define en ON. En el caso de los outer joins, añadir un filtro de la tabla derecha en WHERE convierte silenciosamente el outer join en un inner join, ya que las filas NULL no pasan el filtro. Coloca dichas condiciones en la cláusula ON cuando quieras que las filas sin coincidencia se mantengan.
-- Keeps authors with no published posts.
SELECT a.name, p.title
FROM authors a
LEFT JOIN posts p
ON p.author_id = a.id
AND p.published_at IS NOT NULL;
Un self join utiliza la misma tabla dos veces con diferentes alias, lo cual es útil para jerarquías. Un cross join empareja cada fila con todas las demás y rara vez es lo que buscas por accidente.
GROUP BY y HAVING
Los agregados colapsan muchas filas en una sola: COUNT, SUM, AVG, MIN, MAX. GROUP BY define los grupos.
SELECT a.name AS author,
COUNT(p.id) AS post_count,
MAX(p.created_at) AS latest
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
GROUP BY a.id, a.name
HAVING COUNT(p.id) > 0
ORDER BY post_count DESC;
La regla es que cada columna seleccionada debe estar en el GROUP BY o envuelta en un agregado. Las bases de datos que aplican esto (Postgres, y MySQL 8 por defecto) te protegen de obtener resultados arbitrarios.
WHERE filtra las filas antes del agrupamiento, y HAVING filtra los grupos después. Por lo tanto, una condición sobre un agregado pertenece en HAVING, y una condición sobre una columna simple generalmente pertenece en WHERE por eficiencia.
SELECT author_id, COUNT(*) AS posts
FROM posts
WHERE published_at IS NOT NULL -- filter rows
GROUP BY author_id
HAVING COUNT(*) > 5; -- filter groups
Subconsultas y CTEs
Una subconsulta es una consulta anidada dentro de otra. Puede aparecer en SELECT, FROM o WHERE.
SELECT title
FROM posts
WHERE author_id IN (
SELECT id FROM authors WHERE name = 'Ada'
);
Una common table expression (CTE) le asigna un nombre a una subconsulta mediante WITH, lo cual suele leerse mejor que el anidamiento y permite reutilizar el resultado.
WITH published AS (
SELECT id, author_id, title
FROM posts
WHERE published_at IS NOT NULL
)
SELECT a.name, COUNT(p.id) AS posts
FROM authors a
LEFT JOIN published p ON p.author_id = a.id
GROUP BY a.id, a.name;
Las CTEs pueden encadenarse, y una recursive CTE se referencia a sí misma para recorrer un árbol, como categorías o hilos de comentarios. Opta por una CTE siempre que una consulta comience a anidarse a más de un nivel de profundidad; la legibilidad también es una característica de rendimiento.
Escritura de filas
INSERT añade filas, UPDATE las modifica y DELETE las elimina.
INSERT INTO posts (author_id, title, body)
VALUES (7, 'Learning SQL', 'SQL is declarative.');
UPDATE posts
SET published_at = now()
WHERE id = 42;
DELETE FROM posts
WHERE id = 42;
Asigna siempre una cláusula WHERE a UPDATE y DELETE. Sin ella, todas las filas se verán afectadas. Un buen hábito es ejecutar primero el SELECT correspondiente y confirmar el número de filas. Un UPDATE también puede hacer referencia a valores existentes:
UPDATE posts
SET title = title || ' (updated)'
WHERE author_id = 7;
INSERT puede añadir varias filas a la vez y puede alimentarse a partir de una consulta:
INSERT INTO posts (author_id, title)
SELECT id, 'Welcome' FROM authors;
Transacciones
Una transacción agrupa sentencias para que todas tengan éxito o todas fallen. Esto es lo que mantiene la consistencia en un cambio de varios pasos.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Si una sentencia falla, o decides abortar, ROLLBACK deshace cada cambio realizado desde BEGIN. Sin una transacción, un fallo entre las dos actualizaciones provocaría que faltara dinero. Agrupa las escrituras relacionadas, mantén la transacción corta y nunca esperes la respuesta de un usuario o una llamada de red mientras tengas una abierta.
Claves y restricciones
Las restricciones son reglas que la base de datos aplica por ti:
PRIMARY KEY— identifica de forma única cada fila; también crea un índice e implicaNOT NULL.FOREIGN KEY— requiere que el valor exista en otra tabla, preservando la integridad referencial.UNIQUE— prohíbe duplicados en una columna o combinación de columnas.NOT NULL— requiere un valor.CHECK— requiere que se cumpla una condición, comoprice >= 0.DEFAULT— proporciona un valor cuando no se indica ninguno.
CREATE TABLE posts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_id bigint NOT NULL REFERENCES authors (id) ON DELETE CASCADE,
title text NOT NULL,
body text NOT NULL,
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
Una clave primaria subrogada, como una columna de identidad, es estable y compacta. Una clave natural, como una dirección de correo electrónico, tiene un significado pero puede cambiar, por lo que es preferible usar una clave subrogada con una restricción UNIQUE en la clave natural.
Los índices a nivel conceptual
Un índice es una estructura separada y ordenada que mapea los valores de las columnas con la ubicación de las filas. Sin uno, encontrar filas implica escanear la tabla; con uno, la base de datos puede saltar directamente a las coincidencias.
CREATE INDEX posts_author_published_idx
ON posts (author_id, published_at DESC);
Conceptualmente, piensa en una guía telefónica ordenada por apellido. Buscar un nombre es rápido porque el libro está ordenado; filtrar por una columna que no es la clave de ordenación implica leer cada página. Los índices sacrifican velocidad de escritura y almacenamiento a cambio de velocidad de lectura, ya que cada insert y update debe mantenerlos.
Crea índices en las columnas que utilizas para filtrar y hacer join, prefiere los índices compuestos que coincidan con la estructura real de tus consultas, y verifica con EXPLAIN si el planificador realmente los está utilizando. Tener más índices no es mejor; lo importante es tener los índices correctos.
Funciones de ventana en una sola sesión
Una función de ventana realiza cálculos sobre un conjunto de filas relacionadas con la fila actual, sin colapsarlas como lo hace GROUP BY. Esto facilita la creación de totales acumulados, rankings y comparaciones por grupo.
SELECT author_id,
title,
published_at,
ROW_NUMBER() OVER (
PARTITION BY author_id
ORDER BY published_at DESC
) AS rank_in_author
FROM posts
WHERE published_at IS NOT NULL;
La cláusula OVER define la ventana: PARTITION BY divide las filas en grupos y ORDER BY las ordena dentro de cada grupo. Algunas funciones comunes incluyen ROW_NUMBER, RANK, LAG, LEAD y SUM(...) OVER (...). La diferencia clave con la agregación es que cada fila original se mantiene.
Normalización: 1NF, 2NF, 3NF
La normalización es el proceso de eliminar la redundancia para que cada dato se almacene una sola vez. Las primeras tres formas normales son las que más utilizarás:
Primera forma normal (1NF) — sin grupos repetidos ni columnas con múltiples valores. Una columna tags que contenga "sql,indexes" la incumple; una tabla post_tags separada lo soluciona.
Segunda forma normal (2NF) — 1NF más la ausencia de dependencias parciales de parte de una clave compuesta. Si una línea de pedido tiene como clave (order_id, product_id) y almacena product_name, ese nombre depende solo de product_id, por lo que pertenece a products.
Tercera forma normal (3NF) — 2NF más la ausencia de dependencias transitivas entre columnas que no son claves. Si posts almacenara tanto author_id como author_email, el email dependería del autor y no del post, por lo que pertenece a authors.
Imagina una tabla que repite el email del autor en cada post. Si cambias el email, debes actualizar muchas filas; si olvidas una, los datos se vuelven contradictorios. Divídela en authors y posts y el dato existirá una sola vez. Desnormaliza más adelante, de forma deliberada, cuando un problema de rendimiento medible lo justifique.
Mejores prácticas
- Nombra las columnas explícitamente en lugar de usar
SELECT *en las consultas de la aplicación. - Usa siempre
ORDER BYcuando el orden de los resultados sea importante. - Declara
NOT NULLpor defecto y gestiona los valores ausentes conCOALESCE. - Utiliza
IS NULLen lugar de= NULL, y prefiereNOT EXISTSsobreNOT INcon listas que admitan nulos. - Filtra las filas en
WHEREy los grupos enHAVING. - Usa CTEs para mantener la legibilidad de las consultas complejas.
- Asigna a
UPDATEyDELETEuna cláusulaWHEREy confirma las filas objetivo primero. - Envuelve las escrituras relacionadas en una transacción y mantenla breve.
- Añade índices para patrones de consulta reales y verifícalos con
EXPLAIN. - Almacena cada dato una sola vez y busca la tercera forma normal antes de desnormalizar.
Errores comunes
- Escribir
WHERE column = NULLy obtener cero filas. - Usar
NOT INcontra una lista que contieneNULLy que no devuelva nada silenciosamente. - Olvidar
WHEREen unUPDATEoDELETEy modificar todas las filas. - Seleccionar columnas no agrupadas y obtener valores arbitrarios.
- Asumir que las filas regresan en un orden útil sin
ORDER BY. - Usar
LIMIT ... OFFSETpara paginación profunda y pagar un costo creciente. - Filtrar la tabla correcta en
WHEREy convertir accidentalmente unLEFT JOINen un inner join. - Indexar cada columna y ralentizar las escrituras sin obtener ningún beneficio en las lecturas.
- Concatenar la entrada del usuario en cadenas SQL en lugar de usar parámetros, lo que invita a inyecciones.
- Almacenar datos repetidos en una sola tabla ancha en lugar de normalizarlos.
Próximos pasos
SQL es la base de cualquier base de datos relacional, por lo que el siguiente paso es elegir un motor y aprender su dialecto y particularidades. Comienza con PostgreSQL por su cumplimiento de los estándares y sus tipos enriquecidos, o con MySQL por su ubicuidad y su sistema de replicación. A partir de ahí, descubre cómo los resultados de las consultas se convierten en respuestas HTTP en la guía de REST, y cómo un servicio de Node.js envía esas consultas mediante una conexión agrupada (pooled connection) en Node.js basics.