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,VACUUMyVACUUM 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.
ONrelaciona filas y puede decidir qué coincidencias acepta un join.WHEREfiltra filas antes de agrupar.GROUP BYarma grupos.HAVINGfiltra esos grupos después de calcular agregados.ORDER BYordena 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_idva primero porque suele acotar el conjunto principal.statusayuda 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,
jsonby 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 ScanoBitmap Index Scan: uso de índice.costyrows: estimaciones del planner.actual time,rowsyloops: 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.
VACUUMpermite 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 FULLreescribe la tabla, puede devolver espacio al sistema operativo y necesita espacio temporal. También toma unACCESS EXCLUSIVElock, 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(username: params[:username], password_digest: digest)O, si escribo SQL:
User.where("username = ?", params[:username])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.idconmonitors.iden vez demonitors.workspace_id. - Puse una condición sobre la tabla derecha en
WHEREy eliminé las filas que elLEFT JOINtenía que conservar. - Olvidé el
FROMal reconstruir una consulta. - Quise poner
COUNT(m.id) >= 3enON; esa condición pertenece aHAVING. - Confundí alias como
workspace_count,workspace_nameyworkspaces_name. - En algunas consultas olvidé el filtro
m.enabled = trueo el desempate delORDER 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 :::