Una vista da nombre a una consulta para siempre y para todo el mundo. Muchas veces lo que necesitas es lo contrario: dar nombre a un paso intermedio aquí, ahora y solo dentro de esta consulta, sin crear ningún objeto ni pedir permiso a nadie. Eso es una expresión de tabla común —Common Table Expression, CTE—, y se escribe poniendo WITH delante del SELECT.
Con ellas cerrarás dos promesas del curso. La primera es de 07-04 y 07-05: la legibilidad se rompe a partir de tres niveles de tablas derivadas, y aquí verás la misma consulta escrita de las dos formas, lado a lado. La segunda es de 03-06: un SELF JOIN recorre un nivel de la jerarquía, y para recorrer un árbol de profundidad desconocida hace falta WITH RECURSIVE — con el que sacarás por fin el organigrama completo de TiendaVerde con su nivel y su ruta, la cadena de referidos de tres saltos que va de Lucía a Núria, y una serie de doce meses generada de la nada.
Contenido
WITH: la subconsulta con nombre, puesta delante- Legibilidad: tres niveles de tablas derivadas frente a tres CTE
- Varias CTE encadenadas: la consulta por pasos
- CTE, vista y tabla derivada: cuál usar
- Materialización: lo que cambió en PostgreSQL 12
- CTE en
INSERT,UPDATEyDELETE; el patrón "mover filas" WITH RECURSIVE: la anatomía- Los tres casos de TiendaVerde
- Bucles infinitos y cómo protegerse
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
WITH: la subconsulta con nombre, puesta delante
WITH: la subconsulta con nombre, puesta delanteUna CTE es una subconsulta a la que se le da un nombre antes de la consulta principal. Vive solo durante esa sentencia y se usa después como si fuera una tabla.
WITH totales_pedido AS (
SELECT pe.id AS pedido_id,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total
FROM pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY pe.id
)
SELECT COUNT(*) AS pedidos, ROUND(AVG(t.total), 2) AS ticket_medio,
ROUND(MIN(t.total), 2) AS ticket_minimo, ROUND(MAX(t.total), 2) AS ticket_maximo,
ROUND(SUM(t.total), 2) AS facturacion
FROM totales_pedido AS t;| pedidos | ticket_medio | ticket_minimo | ticket_maximo | facturacion |
|---|---|---|---|---|
| 20 | 36.40 | 22.60 | 66.90 | 727.95 |
Es exactamente la tabla derivada canónica de 07-04, con las mismas cifras, pero leída de arriba abajo: primero se define totales_pedido, después se usa. La sintaxis mínima es WITH nombre1 AS ( SELECT ... ), nombre2 AS ( SELECT ... ) SELECT ... FROM nombre1 JOIN nombre2 ..., donde nombre2 puede usar nombre1.
Cuatro reglas que conviene fijar desde el principio: cada CTE necesita nombre (y opcionalmente puede renombrar sus columnas, WITH t (pedido_id, total) AS (...)); se separan por comas, y WITH se escribe una sola vez aunque haya cinco; una CTE puede referirse a las anteriores, nunca a las posteriores (salvo con RECURSIVE, apartado 7); y una CTE se puede usar varias veces en la misma consulta, cosa que una tabla derivada no permite.
- Legibilidad: tres niveles de tablas derivadas frente a tres CTE
Aquí se cierra la promesa de 07-04. La pregunta: de los clientes que han comprado, ¿cuáles superan la facturación media por cliente, y por cuánto? Son tres pasos —total por pedido, total por cliente, media de esos totales— y con tablas derivadas quedan anidados:
-- ⚠️ Correcta, pero ilegible: tres niveles de anidamiento
SELECT c.id, c.nombre || ' ' || c.apellidos AS cliente, x.pedidos, x.facturacion,
ROUND(x.facturacion - x.media_clientes, 2) AS dif
FROM (SELECT tc.cliente_id, tc.pedidos, tc.facturacion,
(SELECT AVG(tc2.facturacion)
FROM (SELECT tp2.cliente_id, SUM(tp2.total) AS facturacion
FROM (SELECT pe2.id, pe2.cliente_id,
ROUND(SUM(lp2.cantidad * lp2.precio_unitario * (1 - lp2.descuento)), 2) AS total
FROM pedidos pe2 JOIN lineas_pedido lp2 ON lp2.pedido_id = pe2.id
GROUP BY pe2.id, pe2.cliente_id) AS tp2
GROUP BY tp2.cliente_id) AS tc2) AS media_clientes
FROM (SELECT tp.cliente_id, COUNT(*) AS pedidos, SUM(tp.total) AS facturacion
FROM (/* … y aquí, otra vez completo, el mismo cálculo de tp2 … */) AS tp
GROUP BY tp.cliente_id) AS tc) AS x
JOIN clientes AS c ON c.id = x.cliente_id
WHERE x.facturacion > x.media_clientes ORDER BY x.facturacion DESC;Cuenta los paréntesis, y fíjate en que el mismo cálculo de totales por pedido aparece dos veces, copiado, porque una tabla derivada no se puede reutilizar. Ahora lo mismo con CTE:
-- ✅ La misma consulta, por pasos
WITH totales_pedido AS (
SELECT pe.id AS pedido_id, pe.cliente_id,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total
FROM pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY pe.id, pe.cliente_id
),
totales_cliente AS (
SELECT cliente_id, COUNT(*) AS pedidos, SUM(total) AS facturacion
FROM totales_pedido GROUP BY cliente_id
),
media AS (
SELECT ROUND(AVG(facturacion), 2) AS media_clientes FROM totales_cliente
)
SELECT c.id, c.nombre || ' ' || c.apellidos AS cliente,
tc.pedidos, tc.facturacion, m.media_clientes,
ROUND(tc.facturacion - m.media_clientes, 2) AS dif
FROM totales_cliente AS tc
JOIN clientes AS c ON c.id = tc.cliente_id
CROSS JOIN media AS m
WHERE tc.facturacion > m.media_clientes ORDER BY tc.facturacion DESC;| id | cliente | pedidos | facturacion | media_clientes | dif |
|---|---|---|---|---|---|
| 7 | Sofia Moreira Costa | 2 | 111.88 | 60.66 | 51.22 |
| 1 | Lucía Martínez Soler | 3 | 107.60 | 60.66 | 46.94 |
| 9 | Camille Dubois | 2 | 70.87 | 60.66 | 10.21 |
| 10 | Julien Moreau | 1 | 66.90 | 60.66 | 6.24 |
| 4 | Javier Ortega Ruiz | 2 | 62.93 | 60.66 | 2.27 |
Cinco de los doce clientes con compras superan la media de 60,66 € (727,95 € entre 12). El resultado es idéntico al de la versión anterior; lo que ha cambiado es que ahora se puede leer, revisar en un pull request y depurar paso a paso: basta con cambiar el SELECT final por SELECT * FROM totales_cliente para ver el resultado intermedio sin tocar nada más.
Y hay una ganancia que no se ve: totales_pedido se escribe una vez y se usa dos. En la versión anidada estaba duplicada, con todo lo que eso significa el día que cambie la fórmula del importe.
- Varias CTE encadenadas: la consulta por pasos
Ese es el patrón natural de escribir análisis complejos: no se piensa en una consulta, sino en una secuencia de transformaciones.
flowchart LR
A["lineas_pedido<br/>47 filas"] --> B["totales_pedido<br/>20 filas"] --> C["totales_cliente<br/>12 filas"] --> D["media<br/>1 fila"] --> E["final<br/>5 filas"]
C --> E
Cada CTE reduce o transforma la granularidad y tiene un nombre que dice qué contiene. Si el resultado final no cuadra, el diagnóstico es mecánico: ejecuta SELECT COUNT(*) FROM totales_pedido (deben salir 20), luego totales_cliente (12), y el nivel donde el número no sea el esperado es donde está el error. Es el consejo de 07-04 —"cuenta las filas de cada nivel"—, ahora con los niveles bautizados. Y nómbralas por lo que contienen, no c1, c2, c3: una consulta con WITH ventas_2025 AS ..., devoluciones_2025 AS ..., neto AS ... se entiende sin leer el SQL.
- CTE, vista y tabla derivada: cuál usar
| Tabla derivada | CTE | Vista | |
|---|---|---|---|
| Dónde se escribe | En el FROM |
Delante, con WITH |
En el esquema, con CREATE VIEW |
| Cuánto vive / ¿la ven otras sesiones? | Esa consulta / no | Esa consulta / no | Hasta el DROP / sí |
| Reutilizable dentro de la consulta | No: hay que copiarla | Sí, cuantas veces quieras | Sí |
| Legible con 3+ niveles / recursividad | Mal / no | Bien / sí | Bien / solo con WITH RECURSIVE dentro |
| Requiere permisos para crearla | No | No | Sí (CREATE sobre el esquema) |
La regla de decisión cabe en tres frases: un solo nivel y de usar y tirar → tabla derivada; dos o más pasos, o el mismo paso usado dos veces → CTE; lo que usarán otras consultas, otras personas o el equipo de BI → vista. Y no son excluyentes: una vista puede estar definida con WITH dentro.
- Materialización: lo que cambió en PostgreSQL 12
Este apartado corrige lo que dicen casi todos los tutoriales antiguos. Hasta PostgreSQL 11, una CTE era una barrera de optimización: el motor la ejecutaba entera, guardaba su resultado en memoria y solo después seguía, de modo que un filtro de la consulta exterior no podía empujarse dentro. Escribir WITH t AS (SELECT * FROM lineas_pedido) SELECT * FROM t WHERE producto_id = 15 calculaba las 47 filas para descartar casi todas.
Desde PostgreSQL 12 ya no es así. Una CTE que se usa una sola vez y no tiene efectos secundarios se integra (inlining) en la consulta principal, exactamente igual que una vista o una tabla derivada, y el planificador puede empujar los filtros hacia dentro. Se acabó la penalización por escribir consultas legibles. Las dos palabras clave para forzar cada comportamiento:
| Cláusula | Qué hace | Cuándo usarla |
|---|---|---|
| (nada) | Se integra si se usa una vez; se materializa si se usa dos o más | El 95 % de los casos |
AS MATERIALIZED |
Fuerza a calcularla una vez y guardar el resultado | La CTE es cara y se usa varias veces; o quieres una barrera deliberada (por ejemplo, para que una función volátil se evalúe una sola vez) |
AS NOT MATERIALIZED |
Fuerza la integración aunque se use varias veces | La CTE es trivial y materializarla estorba a los índices |
Escrito como WITH totales_pedido AS MATERIALIZED ( ... ), el paso se calcula una sola vez por muchas veces que lo consulte la sentencia principal — que es lo que quieres cuando ese paso recorre 47 líneas y lo usas en dos subconsultas distintas.
La forma de saber qué está pasando es mirarlo, no suponerlo: si en el EXPLAIN aparece un nodo CTE Scan sobre un CTE nombre, se ha materializado; si no aparece por ninguna parte y ves directamente los escaneos de las tablas, se ha integrado. La técnica es la de 08-05.
El error heredado. Durante años se enseñó "usa una CTE para forzar el orden de ejecución" y "no uses CTE, que son lentas". Las dos afirmaciones caducaron en PostgreSQL 12. Si necesitas la barrera, pídela explícitamente con
MATERIALIZED; si no, escribe la CTE porque se lee mejor y no pagues nada por ello. En MySQL 8 y SQL Server las CTE siempre se integran y no existe la palabra clave; en Oracle hay pistas equivalentes (/*+ MATERIALIZE */).
- CTE en
INSERT, UPDATE y DELETE; el patrón "mover filas"
INSERT, UPDATE y DELETE; el patrón "mover filas"Un WITH puede preceder a cualquier sentencia, no solo a un SELECT. Y hay más: una CTE puede ser ella misma un INSERT, UPDATE o DELETE con RETURNING, y su resultado alimentar al siguiente paso. Ese es el patrón que resuelve algo que en 05-04 era imposible en una sola sentencia: mover filas de una tabla a otra.
TiendaVerde quiere archivar los pedidos cancelados —hoy, solo el 6— en una tabla histórica:
CREATE TABLE pedidos_archivo (LIKE pedidos INCLUDING DEFAULTS); -- ⚠️ NO canónica
WITH borrados AS (
DELETE FROM pedidos WHERE estado = 'cancelado' RETURNING *
)
INSERT INTO pedidos_archivo SELECT * FROM borrados; -- INSERT 0 1Una sola sentencia, una sola transacción, ninguna ventana en la que la fila no exista en ningún sitio. Las tres propiedades que lo hacen posible: todas las subsentencias ven la misma instantánea de la base de datos, la del inicio de la sentencia: la CTE borrados no ve el efecto del INSERT, y el INSERT no ve el efecto del DELETE sobre pedidos. El orden de ejecución no está garantizado, así que escribir en la misma tabla desde dos ramas de la misma sentencia da resultados imprevisibles. Y una CTE que escribe se ejecuta siempre, aunque la consulta principal no la use: es la única excepción a la integración del apartado 5.
Un segundo uso, más cotidiano: calcular con WITH y actualizar con el resultado. Alinear el precio de catálogo con el precio medio realmente vendido:
WITH precio_real AS (
SELECT lp.producto_id, ROUND(AVG(lp.precio_unitario), 2) AS medio
FROM lineas_pedido AS lp GROUP BY lp.producto_id
)
UPDATE productos AS p SET precio = pr.medio
FROM precio_real AS pr WHERE pr.producto_id = p.id; -- UPDATE 17Diecisiete productos, los que se han vendido alguna vez. Y fíjate en la ventaja sobre la versión de 07-04: allí hacía falta un WHERE EXISTS para que los tres productos nunca vendidos no recibieran NULL; aquí el JOIN implícito con la CTE ya los deja fuera. (Recarga el script después de probarlo.)
WITH RECURSIVE: la anatomía
WITH RECURSIVE: la anatomíaAquí se cierra la promesa de 03-06. Una CTE recursiva es una CTE que se referencia a sí misma, y tiene siempre la misma forma:
WITH RECURSIVE nombre AS (
SELECT ... -- TÉRMINO BASE: de dónde se parte. No se referencia a sí mismo
UNION ALL
SELECT ... FROM tabla JOIN nombre ON ... -- TÉRMINO RECURSIVO: usa el resultado anterior
)
SELECT * FROM nombre;Cómo se ejecuta, que es lo único que hay que entender de verdad:
flowchart TD
A["<b>Término base</b><br/>Rosa (nivel 1)"] --> B["tabla de trabajo: 1 fila"] --> C{"¿tabla de trabajo<br/>vacía?"}
C -->|no| D["<b>Término recursivo</b>: hijos de<br/>las filas de la tabla de trabajo"]
D --> E["se acumulan en el resultado y pasan<br/>a ser la nueva tabla de trabajo"] --> C
C -->|sí| F["<b>fin</b>: se devuelve<br/>todo lo acumulado"]
Iteración a iteración, con el organigrama de TiendaVerde: la base produce Rosa; la primera iteración busca los hijos de Rosa y produce Andrés, Beatriz y Daniel; la segunda busca los hijos de esos tres y produce Óscar, Laia, Marc e Irene; la tercera busca los hijos de esos cuatro, no encuentra ninguno, la tabla de trabajo queda vacía y el proceso termina. Total: 1 + 3 + 4 = 8 filas, los ocho empleados.
Tres detalles de sintaxis con trampa: RECURSIVE va inmediatamente después de WITH, una sola vez, aunque haya varias CTE y solo una sea recursiva; UNION ALL no elimina duplicados y es lo normal, mientras que UNION a secas los elimina en cada paso —protección rudimentaria contra ciclos, pero más cara—; y el término recursivo solo puede referenciar la CTE una vez, sin agregados, sin ORDER BY y sin LIMIT dentro.
- Los tres casos de TiendaVerde
8.1. La jerarquía de empleados completa
WITH RECURSIVE arbol AS (
-- Base: la raíz, quien no tiene jefe
SELECT e.id, e.nombre || ' ' || e.apellidos AS empleado, e.puesto,
1 AS nivel, e.nombre AS ruta
FROM empleados AS e WHERE e.jefe_id IS NULL
UNION ALL
-- Recursivo: los subordinados de quien ya está en el árbol
SELECT e.id, e.nombre || ' ' || e.apellidos, e.puesto,
a.nivel + 1, a.ruta || ' > ' || e.nombre
FROM empleados AS e JOIN arbol AS a ON e.jefe_id = a.id
)
SELECT nivel, id, empleado, puesto, ruta FROM arbol ORDER BY ruta;| nivel | id | empleado | puesto | ruta |
|---|---|---|---|---|
| 1 | 1 | Rosa Alcázar Vives | Directora general | Rosa |
| 2 | 2 | Andrés Company Talens | Responsable de ventas | Rosa > Andrés |
| 3 | 5 | Laia Puig Sanchis | Comercial | Rosa > Andrés > Laia |
| 3 | 6 | Marc Estévez Roig | Atención al cliente | Rosa > Andrés > Marc |
| 3 | 4 | Óscar Peris Blasco | Comercial | Rosa > Andrés > Óscar |
| 2 | 3 | Beatriz Nadal Ripoll | Responsable de logística | Rosa > Beatriz |
| 3 | 7 | Irene Salvador Mira | Operaria de almacén | Rosa > Beatriz > Irene |
| 2 | 8 | Daniel Vercher Lluch | Analista de datos | Rosa > Daniel |
Los ocho empleados, con su profundidad y su cadena de mando, y presentados como un árbol gracias a ORDER BY ruta. Compáralo con 03-06: allí hacían falta dos LEFT JOIN encadenados, el número de niveles estaba escrito en la consulta y aun así se llegaba solo hasta el "abuelo". Aquí la consulta no sabe cuántos niveles hay, y funcionaría igual con quince.
Dos variantes de una línea que valen mucho: el subárbol de una persona —cambia el WHERE e.jefe_id IS NULL del término base por WHERE e.id = 2 y obtienes a Andrés y sus tres subordinados, 4 filas—; y un orden estable, porque ruta con nombres depende de cómo ordene los acentos el idioma: acumula una segunda columna con los ids rellenados, a.ruta_id || '.' || lpad(e.id::text, 5, '0'), y ordena por ella.
8.2. La cadena de referidos, hacia arriba
La recursividad también funciona en sentido contrario: en lugar de bajar de padres a hijos, subir de hijo a padre. La pregunta de marketing es "¿de quién viene, en última instancia, Núria Bosch Ferrer?".
WITH RECURSIVE cadena AS (
SELECT c.id, c.nombre || ' ' || c.apellidos AS cliente, c.referido_por_id,
0 AS salto, c.nombre AS ruta
FROM clientes AS c WHERE c.id = 13
UNION ALL
SELECT r.id, r.nombre || ' ' || r.apellidos, r.referido_por_id,
ca.salto + 1, r.nombre || ' > ' || ca.ruta
FROM clientes AS r JOIN cadena AS ca ON ca.referido_por_id = r.id
)
SELECT salto, id, cliente, ruta FROM cadena ORDER BY salto;| salto | id | cliente | ruta |
|---|---|---|---|
| 0 | 13 | Núria Bosch Ferrer | Núria |
| 1 | 5 | Ana Belmonte Roca | Ana > Núria |
| 2 | 2 | Carlos Ferrer Ibáñez | Carlos > Ana > Núria |
| 3 | 1 | Lucía Martínez Soler | Lucía > Carlos > Ana > Núria |
La cadena de tres saltos que 03-06 no podía recorrer: 1 → 2 → 5 → 13. La fila con salto = 3 es el origen de la rama, y se detecta porque su referido_por_id es NULL. Dándole la vuelta al JOIN —ON h.referido_por_id = d.id— y partiendo del cliente 1, se obtiene lo contrario: todos los descendientes de Lucía, que son 5 (Carlos, Marta e Inés en el nivel 1; Ana en el 2; Núria en el 3).
8.3. Generar una serie sin generate_series
La recursividad no es solo para árboles: sirve para producir filas de la nada — los doce meses del informe, sin ninguna tabla de por medio:
WITH RECURSIVE meses AS (
SELECT DATE '2025-03-01' AS mes
UNION ALL
SELECT (mes + INTERVAL '1 month')::date FROM meses WHERE mes < DATE '2026-02-01'
)
SELECT to_char(mes, 'YYYY-MM') AS mes FROM meses;Devuelve doce filas, de 2025-03 a 2026-02: el esqueleto exacto del informe mensual. En PostgreSQL esto se escribe mucho mejor con generate_series (03-06), pero generate_series no existe en MySQL ni en SQLite, y esta es la forma portable de conseguir lo mismo. Fíjate en dónde está la condición de parada: dentro del término recursivo, en su propio WHERE. Si la quitas, la consulta no termina nunca.
- Bucles infinitos y cómo protegerse
El peligro de la recursividad es el ciclo en los datos: si por un error de captura el cliente 1 apareciera como referido por el 13, la consulta 8.2 daría vueltas para siempre generando filas hasta agotar el disco temporal. Cuatro defensas, de la más artesanal a la más limpia:
1. La columna de ruta con = ANY(...). Se acumula el camino recorrido en un array y se rechaza a quien ya esté en él:
WITH RECURSIVE cadena AS (
SELECT c.id, c.referido_por_id, ARRAY[c.id] AS ruta
FROM clientes AS c WHERE c.id = 13
UNION ALL
SELECT r.id, r.referido_por_id, ca.ruta || r.id
FROM clientes AS r JOIN cadena AS ca ON ca.referido_por_id = r.id
WHERE NOT r.id = ANY(ca.ruta) -- ← la defensa
)
SELECT id, ruta FROM cadena;2. CYCLE, desde PostgreSQL 14, que es lo mismo escrito por el motor. Se añade tras el paréntesis de cierre de la CTE:
Significa: vigila la columna id, marca con TRUE en es_ciclo la fila en la que se detecte repetición y deja de expandir por ahí; en camino queda el recorrido. Es más corto, más rápido y más difícil de equivocar.
3. Un contador de profundidad, útil cuando además quieres limitar el nivel: WHERE a.nivel < 10 en el término recursivo. 4. El LIMIT de emergencia: SELECT * FROM cadena LIMIT 1000 detiene la ejecución al llegar a mil filas, porque PostgreSQL evalúa la recursión de forma perezosa. Es una red de seguridad para experimentar, no una solución: úsalo mientras desarrollas una recursiva sobre datos que no conoces.
Nota de dialecto:
Motor Palabra clave Detalle PostgreSQL WITH RECURSIVEobligatorioCYCLEySEARCHdesde la 14. SinRECURSIVE, error de "relación no existe"SQL Server WITHa secasRECURSIVEno existe;MAXRECURSIONlimita a 100 niveles por omisiónMySQL 8 / MariaDB WITH RECURSIVELímite por cte_max_recursion_depth(1000 por omisión)SQLite WITH RECURSIVE(la palabra es opcional)Soporte completo, incluido UNIONOracle WITH RECURSIVE, o el clásicoCONNECT BYCONNECT BY PRIOR ... START WITH ...es anterior al estándar y sigue muy vivo, conLEVEL,SYS_CONNECT_BY_PATHyNOCYCLE
Errores Comunes y Consejos
- Olvidar
RECURSIVE. Sin él, la CTE no puede referirse a sí misma:ERROR: relation "arbol" does not exist. Y va detrás deWITH, no delante del nombre de la CTE recursiva. - Repetir
WITHen cada CTE (se escribe una sola vez; las demás se separan por comas) o referirse a una CTE definida más abajo (solo se ven las anteriores, salvo la autorreferencia deRECURSIVE). - Escribir un término recursivo sin condición de parada. La consulta no termina. En un árbol, la parada es implícita (se acaban los hijos); en una serie generada, tienes que escribirla tú en el
WHERE. - Ignorar los ciclos en los datos. Una jerarquía con un bucle cuelga la consulta.
CYCLE(PG 14+) o la columna de ruta con= ANY(...). - Creer que una CTE es siempre una barrera de optimización. Lo era hasta PostgreSQL 11. Desde la 12 se integra si se usa una vez; si quieres la barrera, pídela con
MATERIALIZED. - Usar
UNIONen lugar deUNION ALL"por si acaso". Elimina duplicados en cada paso y cuesta bastante más. Y escribir en la misma tabla desde dos ramas de la misma sentencia: el orden no está garantizado y el resultado es imprevisible. - Consejo: escribe la consulta por pasos y ejecútala por pasos. Sustituye el
SELECTfinal porSELECT * FROM paso_intermedioy comprueba las filas de cada nivel: 47 → 20 → 12 → 1. - Consejo: en una recursiva, empieza siempre por el término base solo, comprueba que devuelve exactamente las raíces que esperas y solo entonces añade el
UNION ALL. Y acumula siemprenivelyruta: no cuestan nada y son la mitad del diagnóstico cuando algo sale mal.
Ejercicios
Ejercicio 1
Reescribe con CTE el informe de "dos agregados de granularidad distinta" de 07-04: por cliente, número de pedidos, facturación de producto, portes y total. Debe cuadrar en 727,95 € + 118,25 € = 846,20 €. (1) Escríbelo con dos CTE (por_pedido y por_cliente) en lugar de dos tablas derivadas. (2) Añade una tercera que calcule los totales generales y muéstralos junto a los de cada cliente. (3) ¿Se integrarán las CTE o se materializarán? ¿Cómo lo comprobarías?
Ejercicio 2
RR. HH. quiere, para cada empleado, cuántas personas tiene por debajo en total (directas e indirectas). (1) Escribe una CTE recursiva que devuelva todos los pares (jefe, subordinado a cualquier profundidad). (2) Agrega para obtener el recuento por jefe, incluyendo con 0 a quienes no tienen a nadie. (3) Comprueba que Rosa sale con 7 y Andrés con 3.
Ejercicio 3
Un compañero ha escrito esto para archivar las reseñas de productos descatalogados y no entiende el resultado:
WITH movidas AS (
DELETE FROM resenas AS r USING productos AS p
WHERE p.id = r.producto_id AND p.activo = FALSE RETURNING r.*
)
SELECT COUNT(*) FROM resenas;- ¿Qué devuelve el
COUNT, 12 u otro número? ¿Por qué? - ¿Se ha borrado algo realmente? ¿Cuántas filas?
- Reescríbelo para que archive de verdad en una tabla
resenas_archivoy devuelva cuántas ha movido.
Soluciones
Solución 1
1 y 2:
WITH por_pedido AS ( -- 20 filas: un pedido, sus productos y sus portes
SELECT pe.id AS pedido_id, pe.cliente_id, pe.gastos_envio,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS productos
FROM pedidos AS pe JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY pe.id, pe.cliente_id, pe.gastos_envio
),
por_cliente AS ( -- 12 filas
SELECT cliente_id, COUNT(*) AS pedidos,
SUM(productos) AS productos, SUM(gastos_envio) AS portes
FROM por_pedido GROUP BY cliente_id
),
total_general AS ( -- 1 fila
SELECT SUM(productos) AS productos_tot, SUM(portes) AS portes_tot FROM por_cliente)
SELECT c.nombre || ' ' || c.apellidos AS cliente, pc.pedidos, pc.productos, pc.portes,
ROUND(pc.productos + pc.portes, 2) AS total, tg.productos_tot, tg.portes_tot
FROM por_cliente AS pc JOIN clientes AS c ON c.id = pc.cliente_id
CROSS JOIN total_general AS tg ORDER BY total DESC;| cliente | pedidos | productos | portes | total | productos_tot | portes_tot |
|---|---|---|---|---|---|---|
| Sofia Moreira Costa | 2 | 111.88 | 19.80 | 131.68 | 727.95 | 118.25 |
| Lucía Martínez Soler | 3 | 107.60 | 4.95 | 112.55 | 727.95 | 118.25 |
(2 primeras de 12 filas.) Las columnas cuadran: 727,95 + 118,25 = 846,20 €. Lo que resuelve el problema es lo mismo que en 07-04 —agregar cada cosa a su granularidad antes de unir—, pero ahora los pasos tienen nombre y por_pedido se define una vez y se usa dos veces (desde por_cliente y, a través de ella, desde total_general).
3. por_pedido se usa una sola vez (desde por_cliente), así que se integrará; por_cliente se usa dos veces, así que PostgreSQL la materializará — y aquí es lo deseable, porque calcularla dos veces sería recorrer 47 líneas dos veces. Se comprueba con EXPLAIN (ANALYZE, COSTS OFF): si aparece un nodo CTE Scan on por_cliente, se ha materializado.
Solución 2
WITH RECURSIVE descendencia AS (
SELECT e.id AS jefe_id, e.id AS emp_id FROM empleados AS e
UNION ALL
SELECT d.jefe_id, e.id
FROM empleados AS e JOIN descendencia AS d ON e.jefe_id = d.emp_id
)
SELECT e.id, e.nombre || ' ' || e.apellidos AS empleado, e.puesto,
COUNT(*) - 1 AS subordinados_totales
FROM descendencia AS d JOIN empleados AS e ON e.id = d.jefe_id
GROUP BY e.id, e.nombre, e.apellidos, e.puesto
ORDER BY subordinados_totales DESC, e.id;| id | empleado | puesto | subordinados_totales |
|---|---|---|---|
| 1 | Rosa Alcázar Vives | Directora general | 7 |
| 2 | Andrés Company Talens | Responsable de ventas | 3 |
| 3 | Beatriz Nadal Ripoll | Responsable de logística | 1 |
| 4 | Óscar Peris Blasco | Comercial | 0 |
(4 primeras de 8 filas; los empleados 5, 6, 7 y 8 cierran también con 0.) Rosa con 7 y Andrés con 3, como pedía el enunciado. Dos ideas hacen que funcione: el término base arranca desde todos los empleados a la vez, no solo desde la raíz, de modo que cada uno construye su propio subárbol; y el COUNT(*) - 1 descuenta la fila (x, x) en la que cada empleado se cuenta a sí mismo — que es justamente lo que permite que los cuatro sin subordinados aparezcan con 0 en lugar de desaparecer.
Solución 3
1. Devuelve 12, es decir, el recuento de antes del borrado. Es la propiedad 1 del apartado 6: todas las partes de la sentencia ven la misma instantánea, la del instante inicial. El SELECT principal no ve el efecto del DELETE de la CTE.
2. Sí se ha borrado, pero cero filas. El único producto con activo = FALSE es el 20 (Cápsulas de espirulina) y no tiene reseñas, así que la CTE devuelve el conjunto vacío. Si el producto descatalogado fuera el 1, se habrían borrado sus 2 reseñas y el COUNT habría seguido diciendo 12 — que es donde está la trampa real del ejercicio. 3. La versión correcta:
CREATE TABLE resenas_archivo (LIKE resenas); -- ⚠️ NO canónica
WITH movidas AS (
DELETE FROM resenas AS r USING productos AS p
WHERE p.id = r.producto_id AND p.activo = FALSE RETURNING r.*
),
archivadas AS (
INSERT INTO resenas_archivo SELECT * FROM movidas RETURNING id
)
SELECT COUNT(*) AS resenas_archivadas FROM archivadas;Ahora sí: el DELETE alimenta al INSERT, el INSERT devuelve lo insertado y el SELECT final cuenta el resultado de la operación, no el estado de una tabla. Es el patrón "mover filas" completo, en una sentencia y en una transacción.
Conclusión
WITH es la herramienta que convierte SQL en algo que se puede leer:
- Una CTE es una subconsulta con nombre puesta delante. Vive solo durante la sentencia, se puede reutilizar dentro de ella —cosa que una tabla derivada no permite— y no requiere permisos ni deja rastro. Frente a tres niveles de tablas derivadas anidadas, tres CTE encadenadas dicen lo mismo con la mitad de paréntesis y se depuran paso a paso: 47 líneas → 20 pedidos → 12 clientes → 1 media, con 5 clientes por encima de los 60,66 € de media.
- CTE, vista o tabla derivada: un paso de usar y tirar, derivada; dos o más pasos o reutilización, CTE; algo que usarán otras consultas y otras personas, vista.
- Desde PostgreSQL 12 una CTE ya no es una barrera de optimización: se integra si se usa una vez y se materializa si se usa varias.
AS MATERIALIZEDyAS NOT MATERIALIZEDfuerzan cada comportamiento, y elEXPLAINdice cuál está ocurriendo. Lo que digan los tutoriales anteriores a 2019 sobre esto ya no vale. - Un
WITHpuede preceder a unINSERT,UPDATEoDELETE, y una CTE puede ser ella misma una escritura conRETURNING: de ahí el patrón "mover filas", que borra de una tabla e inserta lo borrado en otra en una sola sentencia atómica. Todas las ramas ven la misma instantánea. WITH RECURSIVE= término baseUNION ALLtérmino recursivo, iterando hasta que no salen filas nuevas. Con él: el organigrama completo con nivel y ruta (8 empleados, 3 niveles), la cadena de referidos de tres saltos1 → 2 → 5 → 13, y una serie de 12 meses generada singenerate_series. Y los ciclos en los datos, que la cuelgan, se evitan con una columna de ruta y= ANY(...), conCYCLE ... SET ... USING ...desde PostgreSQL 14, con un límite de profundidad o, mientras se experimenta, con unLIMITde emergencia.
Con las CTE ya sabes descomponer una consulta y recorrer una estructura. Queda el hueco que 04-05 dejó abierto con todas las letras: agregar sin colapsar las filas. Cuando quieres el total del pedido junto a cada una de sus líneas, el porcentaje que representa cada producto sobre la facturación global, la posición de cada cliente en un ranking, cuánto ha variado un mes respecto al anterior o la media móvil de un trimestre, un GROUP BY no sirve: colapsa exactamente lo que quieres conservar. En la lección siguiente, funciones de ventana, verás la cláusula OVER que resuelve todo eso de una vez, la anatomía de PARTITION BY / ORDER BY / marco, por qué no se puede filtrar por una función de ventana en el WHERE —el error más frecuente de todos— y los rankings, acumulados y medias móviles de TiendaVerde calculados sin perder ni una de las 47 líneas.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
