Módulo 2: SQL Queries y Relationships
CTEs (Common Table Expressions)
Descripción
Una CTE (Common Table Expression) es una "tabla temporal" que defines al inicio de una query con WITH ... AS (...) y luego usas como si fuera una tabla real. Convierte queries de 3 niveles de subqueries anidados en algo lineal y legible.
CTEs no son un tema "avanzado" — son la mejor práctica moderna para queries complejas. Cualquier query que tenga subqueries anidadas o lógica multi-paso se vuelve más clara con CTEs. PostgreSQL las optimiza igual o mejor que subqueries equivalentes.
Esta cápsula cubre CTEs simples y CTEs recursivas (para árboles, jerarquías, grafos).
Sintaxis básica
WITH nombre_cte AS (
SELECT ... -- una query
)
SELECT * FROM nombre_cte;
Múltiples CTEs separadas por coma:
WITH
cte1 AS (SELECT ...),
cte2 AS (SELECT ... FROM cte1 ...)
SELECT * FROM cte2;
Cada CTE puede usar las anteriores. La query final puede combinarlas con JOINs.
Ejemplo 1: descomponer una query compleja
Pregunta: "Top 3 autores por número de comentarios recibidos en sus posts publicados"
Sin CTE (con subqueries anidados)
SELECT username, total_comments FROM (
SELECT
u.username,
(SELECT COUNT(*) FROM comments c
INNER JOIN posts p ON p.id = c.post_id
WHERE p.author_id = u.id AND p.published = TRUE) AS total_comments
FROM users u
) AS stats
WHERE total_comments > 0
ORDER BY total_comments DESC
LIMIT 3;
Funciona, pero hay que leerlo dos veces para entenderlo.
Con CTE
WITH author_stats AS (
SELECT
u.id AS author_id,
u.username,
COUNT(c.id) AS total_comments
FROM users u
INNER JOIN posts p ON p.author_id = u.id AND p.published = TRUE
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY u.id, u.username
)
SELECT username, total_comments
FROM author_stats
WHERE total_comments > 0
ORDER BY total_comments DESC
LIMIT 3;
La lógica es: primero agrupa stats por autor, después filtra y ordena. Lectura lineal de arriba hacia abajo.
Ejemplo 2: CTEs en cadena
Pregunta: "Para cada categoría, el post más comentado, junto con el username del autor".
WITH
-- Paso 1: contar comments por post
post_comments AS (
SELECT
p.id AS post_id,
p.title,
p.author_id,
p.category_id,
COUNT(c.id) AS num_comments
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.published = TRUE
GROUP BY p.id, p.title, p.author_id, p.category_id
),
-- Paso 2: ranking por categoría
ranked AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY num_comments DESC) AS rn
FROM post_comments
)
-- Query final: top 1 por categoría con info del autor y category
SELECT
cat.name AS categoria,
r.title AS post,
u.username AS autor,
r.num_comments
FROM ranked r
INNER JOIN users u ON u.id = r.author_id
LEFT JOIN categories cat ON cat.id = r.category_id
WHERE r.rn = 1
ORDER BY cat.name;
Cada CTE tiene un propósito claro:
post_comments: estadísticas básicasranked: aplica window function para ranking- Query final: presenta resultados con names
Window functions (
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)) son tema avanzado. Aquí solo se introduce; profundización fuera del scope de esta guía.
CTEs en INSERT, UPDATE, DELETE
CTEs no son solo para SELECT. Funcionan con cualquier statement.
INSERT con CTE
WITH new_post AS (
INSERT INTO posts (author_id, title, slug, content, published, published_at)
VALUES (
(SELECT id FROM users WHERE username = 'maria'),
'Post creado con CTE',
'post-creado-cte',
'Contenido',
TRUE,
NOW()
)
RETURNING id
)
INSERT INTO post_tags (post_id, tag_id)
SELECT new_post.id, t.id
FROM new_post, tags t
WHERE t.slug IN ('postgresql', 'tutorial');
Inserta un post y al mismo tiempo lo etiqueta. Una sola operación atómica desde el punto de vista del cliente (en módulo 3 vemos transacciones formales).
DELETE con CTE
-- Borra todos los comments de posts no publicados
WITH unpublished_posts AS (
SELECT id FROM posts WHERE published = FALSE
)
DELETE FROM comments
WHERE post_id IN (SELECT id FROM unpublished_posts)
RETURNING id, content;
CTEs recursivas: jerarquías y árboles
Una CTE recursiva se llama a sí misma. Se usa para datos jerárquicos: árboles, grafos, organigramas, hilos de comments anidados.
Sintaxis
WITH RECURSIVE nombre_cte AS (
-- Caso base (anchor): primera query
SELECT ...
UNION ALL
-- Caso recursivo: usa la CTE misma
SELECT ... FROM nombre_cte ... JOIN otra_tabla ...
)
SELECT * FROM nombre_cte;
Ejemplo: árbol de comments con replies
Recordamos que comments tiene parent_comment_id (self-reference). Vamos a obtener un comment raíz con todas sus respuestas a cualquier nivel de profundidad:
-- Insertar varios niveles de replies para el ejemplo
WITH base_comment AS (
INSERT INTO comments (post_id, author_id, content)
VALUES (
(SELECT id FROM posts LIMIT 1),
(SELECT id FROM users WHERE username = 'carlos'),
'Comment raíz'
)
RETURNING id, post_id
)
INSERT INTO comments (post_id, author_id, parent_comment_id, content)
SELECT
base_comment.post_id,
(SELECT id FROM users WHERE username = 'maria'),
base_comment.id,
'Reply nivel 1'
FROM base_comment;
-- Recursive CTE: toda la conversación
WITH RECURSIVE comment_tree AS (
-- Anchor: comment raíz (sin parent)
SELECT
id,
parent_comment_id,
content,
author_id,
0 AS depth,
ARRAY[id] AS path
FROM comments
WHERE parent_comment_id IS NULL
UNION ALL
-- Recursivo: replies a cada nivel
SELECT
c.id,
c.parent_comment_id,
c.content,
c.author_id,
ct.depth + 1,
ct.path || c.id
FROM comments c
INNER JOIN comment_tree ct ON c.parent_comment_id = ct.id
)
SELECT
REPEAT(' ', depth) || content AS comment_indented,
depth,
array_length(path, 1) AS thread_position
FROM comment_tree
ORDER BY path;
Cómo funciona:
- Anchor: comments sin parent (raíces).
- Recursivo: para cada comment ya en el árbol, encontrar sus replies.
- PostgreSQL itera hasta que la query recursiva no produce nuevas filas.
path(array de IDs) sirve para ordenar la conversación cronológicamente dentro del thread.
Ejemplo 2: organigrama (employees)
-- Tabla hipotética
-- CREATE TABLE employees (
-- id UUID PRIMARY KEY,
-- name TEXT,
-- manager_id UUID REFERENCES employees(id)
-- );
WITH RECURSIVE org_chart AS (
SELECT id, name, manager_id, 0 AS level
FROM employees
WHERE manager_id IS NULL -- CEOs
UNION ALL
SELECT e.id, e.name, e.manager_id, oc.level + 1
FROM employees e
INNER JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT REPEAT(' ', level) || name AS hierarchy, level
FROM org_chart
ORDER BY level, name;
CTEs recursivas son la forma idiomática de manejar jerarquías en PostgreSQL.
Cuidado con recursiones infinitas. Si tu data tiene ciclos (A → B → A), la recursive CTE corre infinitamente. Limita con
WHERE level < 10o usaarraypara detectar ciclos.
CTEs vs subqueries vs vistas
| Abstracción | Persistencia | Cuándo usar |
|---|---|---|
| Subquery anidada | Solo dentro de la query | Lógica simple, una sola vez |
CTE (WITH ... AS) | Solo dentro de la query (con nombre) | Lógica multi-paso, query única |
| VIEW | Persiste en el schema | Lógica usada en múltiples queries |
| MATERIALIZED VIEW | Persiste y cachea resultados | Reportes pesados que cambian rara vez |
Las views (CREATE VIEW) son tema fuera del scope de esta cápsula pero útil saber que existen.
CTEs y performance
Mito viejo: "Las CTEs son barreras de optimización en PostgreSQL".
Realidad (2026): Falso desde PostgreSQL 12 (2019). El planner inlinea CTEs cuando es beneficioso. Solo si fuerzas con WITH ... AS MATERIALIZED (...), PostgreSQL respeta la CTE como tabla temporal.
-- Sin MATERIALIZED: el planner inlinea (default desde PG 12)
WITH ranked AS (...) SELECT ...
-- MATERIALIZED: fuerza ejecutar la CTE como tabla temp y reutilizar
WITH ranked AS MATERIALIZED (...) SELECT ...
Usa MATERIALIZED solo cuando midas que ayuda — la mayoría de las veces no es necesario.
Ejercicios
Ejercicio 1. Reescribe esta query con una CTE para que sea más legible:
SELECT * FROM (
SELECT u.username, COUNT(p.id) AS num
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
GROUP BY u.id, u.username
) s WHERE num >= 2;
Solución
WITH user_post_counts AS (
SELECT u.username, COUNT(p.id) AS num
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
GROUP BY u.id, u.username
)
SELECT * FROM user_post_counts WHERE num >= 2;
Ejercicio 2. Usando CTEs, calcula: el promedio de comments por post, y luego lista posts cuyos comments superan ese promedio.
Solución
WITH
comment_counts AS (
SELECT p.id AS post_id, p.title, COUNT(c.id) AS num_comments
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id, p.title
),
avg_comments AS (
SELECT AVG(num_comments) AS avg_count FROM comment_counts
)
SELECT cc.title, cc.num_comments, ac.avg_count
FROM comment_counts cc, avg_comments ac
WHERE cc.num_comments > ac.avg_count
ORDER BY cc.num_comments DESC;
Ejercicio 3. Usando una recursive CTE, encuentra todos los descendientes (replies a cualquier nivel) de un comment específico.
Solución
WITH RECURSIVE descendants AS (
-- Anchor: el comment seleccionado
SELECT id, parent_comment_id, content, 0 AS depth
FROM comments
WHERE id = '<UUID_DEL_COMMENT>' -- reemplaza con un id real
UNION ALL
-- Recursivo: replies a cualquier nivel
SELECT c.id, c.parent_comment_id, c.content, d.depth + 1
FROM comments c
INNER JOIN descendants d ON c.parent_comment_id = d.id
)
SELECT REPEAT(' ', depth) || content AS reply, depth
FROM descendants
WHERE depth > 0 -- excluye el comment original
ORDER BY depth;
Ejercicio 4. Inserta un nuevo post y, en el mismo statement, asocia 2 tags usando CTE.
Solución
WITH new_post AS (
INSERT INTO posts (author_id, title, slug, content, published, published_at)
VALUES (
(SELECT id FROM users WHERE username = 'sofia'),
'Diseñando para todos',
'disenando-para-todos',
'Sobre accesibilidad inclusiva.',
TRUE,
NOW()
)
RETURNING id
),
tags_to_add AS (
SELECT id FROM tags WHERE slug IN ('tutorial', 'backend')
)
INSERT INTO post_tags (post_id, tag_id)
SELECT new_post.id, tags_to_add.id
FROM new_post, tags_to_add;
Resumen
- CTE (
WITH name AS (...)): tabla temporal con nombre, lectura lineal - Múltiples CTEs en cadena: cada una puede usar las previas
- CTEs en INSERT/UPDATE/DELETE funcionan con
RETURNING WITH RECURSIVEpara jerarquías, árboles, grafos- Performance: PostgreSQL 12+ inlinea CTEs por default; usa
MATERIALIZEDsolo cuando lo midas - Cuándo usar CTE vs subquery: CTE cuando hay multi-paso o se vuelve a usar; subquery para casos simples
En la siguiente cápsula vemos aggregations: COUNT, SUM, AVG, GROUP BY, HAVING — las herramientas para responder preguntas analíticas.
Recursos Adicionales
- WITH Queries — PostgreSQL Docs — Documentación oficial completa
- "The PostgreSQL Optimizer Inside Out: CTEs" — Cómo optimiza el planner
- Recursive Queries — PostgreSQL Wiki — Ejemplos avanzados
- "Modern SQL: WITH" — CTEs explicadas con detalle
Siguiente: Cápsula 07 — Aggregations: GROUP BY y HAVING.