Gastón Ramos

Blog personal

Repaso de SQL y PostgreSQL

Escrito el 4 de agosto de 2026.

Bueno, la idea de este documento no es aprender SQL otra vez desde cero. Es tener todo lo importante en un solo lugar, volver a leerlo y acordarme de cómo reconstruir una consulta cuando tengo una hoja en blanco.

Los artículos que ya armamos cubren casi todo, pero repartido:

  • SQL a mano: filtros, joins, agregaciones, HAVING, NOT EXISTS, CTEs y window functions.
  • Índices en PostgreSQL: elección de índices, EXPLAIN, EXPLAIN ANALYZE, ANALYZE, VACUUM y VACUUM FULL.
  • Seguridad web: SQL injection y consultas parametrizadas.

Acá está el resumen de las tres cosas juntas, más transacciones, constraints y N+1, que no estaban desarrollados en el artículo de SQL a mano.

Mi mapa para escribir una consulta

Primero decido qué representa cada fila del resultado. Después pienso de dónde salen los datos y recién ahí escribo.

FROM / JOIN
    ↓
WHERE
    ↓
GROUP BY
    ↓
HAVING
    ↓
SELECT
    ↓
ORDER BY
    ↓
LIMIT

La consulta se escribe empezando por SELECT, pero para razonarla me conviene seguir ese orden lógico.

  • ON relaciona filas y puede decidir qué coincidencias acepta un join.
  • WHERE filtra filas antes de agrupar.
  • GROUP BY arma grupos.
  • HAVING filtra esos grupos después de calcular agregados.
  • ORDER BY ordena el resultado final.

1. Filtros y orden

SELECT
  m.id,
  m.name,
  m.status
FROM monitors AS m
WHERE m.workspace_id = 7
  AND m.enabled = true
  AND m.status = 'down'
ORDER BY m.id ASC;

No hay demasiado misterio: el WHERE decide qué filas quedan y el ORDER BY se aplica al resultado.

2. Encadenar joins

Los diez workspaces con más incidentes abiertos de monitores habilitados:

SELECT
  w.id AS workspace_id,
  w.name AS workspace_name,
  COUNT(i.id) AS open_incidents_count
FROM workspaces AS w
JOIN monitors AS m
  ON m.workspace_id = w.id
JOIN incidents AS i
  ON i.monitor_id = m.id
WHERE m.enabled = true
  AND i.status = 'open'
GROUP BY w.id, w.name
ORDER BY open_incidents_count DESC, workspace_name ASC
LIMIT 10;

El recorrido es:

workspace → sus monitores → los incidentes de esos monitores

Cada JOIN necesita su propia relación. No mezclo IDs porque sí: monitors.workspace_id apunta a workspaces.id e incidents.monitor_id apunta a monitors.id.

3. LEFT JOIN y el famoso cero

Todos los workspaces y su cantidad de monitores habilitados, incluso los que no tienen ninguno:

SELECT
  w.id AS workspace_id,
  w.name AS workspace_name,
  COUNT(m.id) AS monitors_count
FROM workspaces AS w
LEFT JOIN monitors AS m
  ON m.workspace_id = w.id
 AND m.enabled = true
GROUP BY w.id, w.name
ORDER BY monitors_count DESC, workspace_name ASC;

La condición m.enabled = true está en ON porque quiero decidir qué monitores relacionar sin eliminar workspaces. Si la pongo en WHERE, un workspace sin coincidencia recibe m.enabled = NULL, no cumple la condición y desaparece.

Uso COUNT(m.id) porque ignora el NULL del monitor inexistente y devuelve cero. COUNT(*) contaría la fila que preservó el LEFT JOIN y devolvería uno.

4. GROUP BY y HAVING

Workspaces con al menos tres monitores habilitados:

SELECT
  w.id AS workspace_id,
  w.name AS workspace_name,
  COUNT(m.id) AS monitors_count
FROM workspaces AS w
JOIN monitors AS m
  ON m.workspace_id = w.id
WHERE m.enabled = true
GROUP BY w.id, w.name
HAVING COUNT(m.id) >= 3
ORDER BY monitors_count DESC, workspace_name ASC;

WHERE m.enabled = true filtra monitores antes de agrupar. Después HAVING COUNT(m.id) >= 3 filtra grupos. No puedo poner el COUNT en ON ni en WHERE porque en ese momento el grupo todavía no existe.

5. NOT EXISTS

Workspaces sin ningún monitor habilitado y caído:

SELECT
  w.id AS workspace_id,
  w.name AS workspace_name
FROM workspaces AS w
WHERE NOT EXISTS (
  SELECT 1
  FROM monitors AS m
  WHERE m.workspace_id = w.id
    AND m.enabled = true
    AND m.status = 'down'
)
ORDER BY workspace_name ASC;

La subconsulta está correlacionada porque usa w.id de la consulta exterior. SELECT 1 no busca el número uno: a EXISTS solamente le importa si aparece alguna fila. NOT EXISTS conserva el workspace cuando no aparece ninguna.

Cuando la pregunta dice “no existe una fila relacionada que cumpla esto”, ésta es la estructura que quiero recordar.

6. CTE y window function

El incidente más reciente de cada monitor:

WITH ranked_incidents AS (
  SELECT
    i.id,
    i.monitor_id,
    i.status,
    i.created_at,
    ROW_NUMBER() OVER (
      PARTITION BY i.monitor_id
      ORDER BY i.created_at DESC, i.id DESC
    ) AS position
  FROM incidents AS i
  WHERE i.monitor_id IN (4, 5, 6)
)
SELECT
  id,
  monitor_id,
  status,
  created_at
FROM ranked_incidents
WHERE position = 1
ORDER BY monitor_id ASC;

PARTITION BY arma una ventana independiente para cada monitor. ORDER BY pone primero el incidente más reciente y ROW_NUMBER() los numera. La consulta exterior se queda con el número uno.

No puedo calcular position y filtrarla en el WHERE del mismo nivel porque la window function se calcula después de ese WHERE. Por eso uso la CTE.

Una CTE no es una tabla permanente. Es un resultado con nombre que existe solamente durante esa sentencia. Tampoco vuelve mágicamente más rápida una consulta: principalmente ayuda a separar ideas.

7. Elegir un índice

No invento índices mirando solamente la tabla. Arranco por una consulta real:

SELECT id, name, status
FROM monitors
WHERE workspace_id = 7
  AND enabled = true
  AND status = 'down';

Una opción razonable podría ser un índice parcial y compuesto:

CREATE INDEX index_monitors_on_workspace_id_status_when_enabled
ON monitors (workspace_id, status)
WHERE enabled = true;

La idea es:

  • workspace_id va primero porque suele acotar el conjunto principal.
  • status ayuda dentro de cada workspace.
  • El predicado parcial evita indexar monitores deshabilitados si esa consulta siempre busca habilitados.
  • El índice acelera lecturas, pero hace más caras las escrituras y ocupa espacio. No agrego uno “por las dudas”.

Un índice (workspace_id, status) suele servir para workspace_id solo y para las dos columnas juntas. Generalmente no es eficiente para buscar únicamente por status, porque no es la primera columna.

PostgreSQL puede ignorar un índice si la consulta devuelve gran parte de la tabla. En ese caso un Seq Scan puede ser más barato que saltar entre índice y heap.

Métodos de acceso que tengo que reconocer

  • B-tree: igualdad, rangos y orden. Es el predeterminado.
  • Hash: igualdad.
  • GIN: arrays, jsonb y full-text search.
  • GiST: geometría, rangos, solapamiento y vecinos cercanos.
  • SP-GiST: datos que se pueden particionar, como puntos o prefijos IP.
  • BRIN: tablas enormes cuyo orden físico se correlaciona con el valor, como eventos cronológicos.

8. EXPLAIN, EXPLAIN ANALYZE y ANALYZE

EXPLAIN
SELECT * FROM monitors WHERE workspace_id = 7;

EXPLAIN muestra el plan estimado y normalmente no ejecuta la consulta.

EXPLAIN ANALYZE
SELECT * FROM monitors WHERE workspace_id = 7;

EXPLAIN ANALYZE ejecuta la consulta y agrega tiempos, filas reales y loops. Si es un UPDATE, DELETE o INSERT, también lo ejecuta. Ojo con eso.

Qué miro:

  • Seq Scan: lectura secuencial.
  • Index Scan, Index Only Scan o Bitmap Index Scan: uso de índice.
  • cost y rows: estimaciones del planner.
  • actual time, rows y loops: lo que realmente pasó.
  • Una diferencia grande entre filas estimadas y reales puede indicar estadísticas viejas o una distribución de datos difícil de estimar.

ANALYZE monitors; actualiza estadísticas. No es lo mismo que EXPLAIN ANALYZE.

9. VACUUM y VACUUM FULL

PostgreSQL usa MVCC. Un UPDATE o DELETE puede dejar versiones viejas de una fila porque otra transacción todavía podría necesitarlas. Cuando ya nadie puede verlas pasan a ser dead tuples.

  • VACUUM permite reutilizar ese espacio dentro de la tabla, actualiza el visibility map y ayuda a prevenir el wraparound de transaction IDs. Normalmente no devuelve el espacio al sistema operativo.
  • VACUUM FULL reescribe la tabla, puede devolver espacio al sistema operativo y necesita espacio temporal. También toma un ACCESS EXCLUSIVE lock, así que es mucho más disruptivo.

10. SQL injection

El problema aparece cuando un dato no confiable se concatena como parte de la sintaxis:

SELECT *
FROM users
WHERE username = '${username}'
  AND password = '${password}';

Un valor como éste puede modificar la expresión booleana:

' OR 1=1 OR 'a'='b

Con password vacío, la consulta resultante sería:

SELECT *
FROM users
WHERE username = '' OR 1=1 OR 'a'='b'
  AND password = '';

AND tiene mayor precedencia que OR, y la rama 1=1 hace verdadera la condición para todas las filas.

La defensa principal no es escapar a mano ni confiar ciegamente en el ORM. Es usar parámetros para que estructura y valores viajen separados:

User.find_by(params[], digest)

O, si escribo SQL:

User.where("username = ?", params[])

Los parámetros sirven para valores. Si el usuario puede elegir una columna o una dirección de orden, necesito una allowlist; esos identificadores no se resuelven con un placeholder común.

11. Lo que faltaba mencionar

Transacciones

Una transacción agrupa operaciones que deben funcionar todas juntas:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

UPDATE accounts
SET balance = balance + 100
WHERE id = 2;

COMMIT;

Si algo falla, hago ROLLBACK. La transacción da atomicidad, pero igual tengo que pensar en concurrencia, aislamiento y locks.

Constraints

Las validaciones de Rails ayudan a la experiencia de usuario. Los constraints de la base protegen la integridad aunque otra aplicación o proceso escriba los datos.

Ejemplos: NOT NULL, UNIQUE, FOREIGN KEY y CHECK.

Locks y deadlocks

Un lock coordina accesos concurrentes. Un deadlock aparece cuando dos transacciones esperan recursos que la otra ya tiene. PostgreSQL detecta el ciclo, aborta una transacción y la aplicación debe poder reintentar cuando sea seguro. Mantener transacciones cortas y tomar locks en un orden consistente reduce el riesgo.

N+1

El N+1 aparece cuando cargo una colección con una consulta y después ejecuto otra consulta por cada elemento. En Rails primero lo confirmo mirando logs o mediciones y después considero includes, preload o eager_load, según la consulta. No cargo asociaciones gigantes a ciegas porque también puedo cambiar un N+1 por un problema de memoria.

Mis errores de esta práctica

  • Relacioné workspaces.id con monitors.id en vez de monitors.workspace_id.
  • Puse una condición sobre la tabla derecha en WHERE y eliminé las filas que el LEFT JOIN tenía que conservar.
  • Olvidé el FROM al reconstruir una consulta.
  • Quise poner COUNT(m.id) >= 3 en ON; esa condición pertenece a HAVING.
  • Confundí alias como workspace_count, workspace_name y workspaces_name.
  • En algunas consultas olvidé el filtro m.enabled = true o el desempate del ORDER BY.
  • Para tres tablas me trabé hasta volver a dibujar el recorrido de las relaciones.

No es grave. La regla que me queda es no apurarme durante los primeros diez segundos. Leo el problema, decido qué representa una fila y reviso las claves de cada join.

Respuestas de veinte segundos

WHERE versus HAVING

WHERE filtra filas antes de agrupar. HAVING filtra grupos después de calcular agregados como COUNT o SUM.

INNER JOIN versus LEFT JOIN

INNER JOIN devuelve solamente coincidencias. LEFT JOIN conserva todas las filas del lado izquierdo aunque no exista una coincidencia del lado derecho.

EXPLAIN versus EXPLAIN ANALYZE

EXPLAIN muestra el plan estimado. EXPLAIN ANALYZE ejecuta la consulta y agrega tiempos y filas reales, así que hay que tener cuidado con sentencias que modifican datos.

VACUUM versus VACUUM FULL

VACUUM vuelve reutilizable el espacio de dead tuples dentro de la tabla y no suele devolverlo al sistema operativo. VACUUM FULL reescribe la tabla, puede devolver espacio al sistema y necesita un lock exclusivo.

¿Cómo elijo un índice?

Empiezo por una consulta real, miro filtros, joins y orden, pienso en selectividad y frecuencia, diseño el índice más chico que ayude y lo verifico con EXPLAIN ANALYZE. Después considero el costo sobre escrituras y espacio.

Última regla

No necesito recitar todo esto. Necesito contestar primero lo que me preguntan, explicar el mecanismo con una frase y dar un ejemplo. Si no sé algo, lo digo y explico cómo lo comprobaría. Listo.

Nos leemos,
Gastón Ramos


 ::: Si querés comentar algo mandame un email: ramos.gaston AT gmail.com :::