SQL · Guías · 30 de octubre de 2026 · 14 min de lectura

CTE en SQL (cláusula WITH): consultas legibles paso a paso

Una fila de carros de equipaje etiquetados y alineados en orden en la plataforma de un aeropuerto

Una CTE (common table expression, expresión de tabla común) es un resultado temporal con nombre que solo existe durante una consulta. La defines con WITH nombre AS (SELECT ...) y luego usas nombre en la consulta principal como si fuera una tabla. Por debajo no pasa nada que una subconsulta no pudiera hacer. La gracia está en la legibilidad: en lugar de anidar consultas a tres niveles de profundidad, las escribes de arriba abajo, un paso por CTE, y le das a cada paso un nombre que dice qué contiene.

WITH step_one AS (
  SELECT ...
),
step_two AS (
  SELECT ... FROM step_one ...
)
SELECT ... FROM step_two;

Los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto: 33.121 vuelos entre 104 aeropuertos en el verano de 2026, con reservas, billetes, asientos y un programa de fidelización (valores en inglés, dinero en dólares). El SQL usa sintaxis de PostgreSQL y cada resultado sale de ejecutar la consulta; puedes comprobarlo en la sala de SQL de Nomto.

Tu primera CTE

¿Cuántos billetes suelen tener los socios del programa de fidelización? Hacen falta dos niveles de agregación: billetes por socio y luego socios por número de billetes. Con una CTE, cada nivel tiene su propio bloque:

WITH per_member AS (
  SELECT member_id, COUNT(*) AS tickets
  FROM ticket_members
  GROUP BY member_id
)
SELECT tickets, COUNT(*) AS members
FROM per_member
GROUP BY tickets
ORDER BY tickets
LIMIT 8;
tickets members
1 22835
2 11686
3 4894
4 972
5 176
6 89
7 201
8 131

per_member tiene una fila por cada socio con billetes. La consulta principal la lee como una tabla. Exactamente la misma lógica cabe en una tabla derivada (FROM (SELECT ...) AS per_member), y para un solo paso está bien. La diferencia se nota cuando hay varios pasos.

(El repunte en siete y ocho billetes, después de que los números vinieran bajando, está en los datos; no es un fallo de la consulta. Merece una mirada antes de que alguien haga un gráfico con ello, y una consulta de dos pasos como esta suele ser la forma de detectar estas cosas).

Varias CTE: un paso por idea

¿Cuál es la ocupación media (pasajeros entre asientos) de los vuelos que llegaron, por modelo de avión? Hacen falta tres ingredientes: asientos por modelo, pasajeros por vuelo y los propios vuelos. Cada uno se convierte en una CTE, y las CTE posteriores pueden usar las anteriores:

WITH seats_per_model AS (
  SELECT aircraft_code, COUNT(*) AS seats
  FROM seats
  GROUP BY aircraft_code
),
passengers AS (
  SELECT flight_id, COUNT(*) AS pax
  FROM ticket_flights
  GROUP BY flight_id
),
flight_load AS (
  SELECT f.flight_id,
         f.aircraft_code,
         COALESCE(p.pax, 0) AS pax,
         s.seats,
         COALESCE(p.pax, 0) * 1.0 / s.seats AS load_factor
  FROM flights f
  JOIN seats_per_model s ON s.aircraft_code = f.aircraft_code
  LEFT JOIN passengers p ON p.flight_id = f.flight_id
  WHERE f.status = 'Arrived'
)
SELECT aircraft_code,
       COUNT(*) AS flights,
       MAX(seats) AS seats,
       ROUND(AVG(pax), 1) AS avg_pax,
       ROUND(100 * AVG(load_factor), 1) AS avg_load_pct
FROM flight_load
GROUP BY aircraft_code
ORDER BY avg_load_pct DESC;
aircraft_code flights seats avg_pax avg_load_pct
773 307 402 264.6 65.8
733 646 130 73.3 56.4
763 612 222 111.4 50.2
SU9 4292 97 46.7 48.1
319 624 116 46.7 40.3
CR2 4570 50 18.2 36.5
321 982 170 58.2 34.2
CN1 4674 12 1.7 14.4

Cada CTE hace una cosa, y el SELECT final solo resume. Agregar asientos y pasajeros antes del JOIN es además lo que mantiene los números correctos: unir flights con seats y ticket_flights en bruto multiplicaría las filas e inflaría todos los conteos.

Esta es la misma consulta escrita con tablas derivadas anidadas (recortada a las tres primeras filas):

SELECT aircraft_code, COUNT(*) AS flights, ROUND(100 * AVG(load_factor), 1) AS avg_load_pct
FROM (
  SELECT f.aircraft_code, COALESCE(p.pax, 0) * 1.0 / s.seats AS load_factor
  FROM flights f
  JOIN (SELECT aircraft_code, COUNT(*) AS seats FROM seats GROUP BY aircraft_code) s
    ON s.aircraft_code = f.aircraft_code
  LEFT JOIN (SELECT flight_id, COUNT(*) AS pax FROM ticket_flights GROUP BY flight_id) p
    ON p.flight_id = f.flight_id
  WHERE f.status = 'Arrived'
) AS flight_load
GROUP BY aircraft_code
ORDER BY avg_load_pct DESC
LIMIT 3;
aircraft_code flights avg_load_pct
773 307 65.8
733 646 56.4
763 612 50.2

La misma respuesta. Pero hay que leerla de dentro hacia fuera, y los pasos no tienen nombre. En una revisión de código, «revisa passengers» es mucho más fácil de decir que «revisa la segunda subconsulta dentro de la primera».

Depurar una cadena de CTE paso a paso

La verdadera ventaja de los pasos con nombre: puedes inspeccionar cualquiera de ellos cambiando el SELECT final. Sustitúyelo por SELECT * FROM flight_load LIMIT 20, o por un conteo, y verás exactamente qué produce ese paso.

Ese hábito atrapa errores como este. Cambia el LEFT JOIN passengers por un JOIN normal y la ocupación se dispara:

WITH seats_per_model AS (
  SELECT aircraft_code, COUNT(*) AS seats FROM seats GROUP BY aircraft_code
),
passengers AS (
  SELECT flight_id, COUNT(*) AS pax FROM ticket_flights GROUP BY flight_id
)
SELECT f.aircraft_code,
       COUNT(*) AS flights,
       ROUND(100 * AVG(p.pax * 1.0 / s.seats), 1) AS avg_load_pct
FROM flights f
JOIN seats_per_model s ON s.aircraft_code = f.aircraft_code
JOIN passengers p ON p.flight_id = f.flight_id
WHERE f.status = 'Arrived'
GROUP BY f.aircraft_code
ORDER BY avg_load_pct DESC;
aircraft_code flights avg_load_pct
773 307 65.8
733 591 61.6
SU9 3527 58.6
321 644 52.2
763 599 51.3
CN1 1346 50.1
319 543 46.3
CR2 3881 42.9

La ocupación del Cessna pasa del 14 % al 50 %, porque sus 3.328 vuelos vacíos se han descartado sin avisar. Una comprobación rápida del paso passengers explica por qué: de 16.707 vuelos llegados, solo 11.438 tienen algún billete, y un INNER JOIN conserva solo esos. Ninguna de las dos versiones es «la» respuesta por sí sola, pero necesitas saber qué pregunta estás respondiendo, y los pasos con nombre convierten esa comprobación en una línea.

Usar una CTE más de una vez

Una CTE se puede referenciar varias veces en la consulta principal, algo que una tabla derivada no permite sin copiarla. Un uso clásico: un valor y su porcentaje sobre el total.

WITH route_revenue AS (
  SELECT f.departure_airport || '-' || f.arrival_airport AS route,
         SUM(tf.amount) AS revenue
  FROM ticket_flights tf
  JOIN flights f ON f.flight_id = tf.flight_id
  WHERE f.status = 'Arrived'
  GROUP BY route
)
SELECT route,
       ROUND(revenue) AS revenue,
       ROUND(100 * revenue / (SELECT SUM(revenue) FROM route_revenue), 2) AS pct_of_total
FROM route_revenue
ORDER BY revenue DESC
LIMIT 5;
route revenue pct_of_total
DME-KHV 4386032 3.84
KHV-DME 3863763 3.38
DME-OVB 3291211 2.88
OVB-DME 2927240 2.56
LED-KHV 2779485 2.43

route_revenue se usa dos veces: como tabla principal y dentro de la subconsulta escalar del total. Sumando las 451 rutas, los vuelos llegados ingresaron 114,3 millones de dólares, y la primera ruta, de Domodédovo a Jabárovsk, se llevó casi un 4 %.

Una CTE también puede guardar un resumen de una sola fila que unes a todas las filas. ¿Qué días de reservas fueron los más intensos comparados con un día medio?

WITH daily AS (
  SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
  FROM bookings
  GROUP BY day
),
stats AS (
  SELECT AVG(bookings) AS avg_bookings FROM daily
)
SELECT d.day, d.bookings, ROUND(d.bookings / s.avg_bookings, 2) AS vs_avg
FROM daily d
CROSS JOIN stats s
ORDER BY d.bookings DESC
LIMIT 5;
day bookings vs_avg
2026-08-10 6787 1.45
2026-08-09 6531 1.39
2026-08-08 6350 1.35
2026-08-07 6184 1.32
2026-08-06 6081 1.3

stats lee de daily, y la consulta principal lee de las dos. (La media, 4.692,6 reservas al día, se calcula sobre 56 días, del 17 de junio al 11 de agosto; el último día solo está en parte en los datos, lo que baja un poco la media. En este tipo de comparación lo normal es quitar los días incompletos).

Dar nombre a las columnas de una CTE

Puedes poner una lista de nombres de columna después del nombre de la CTE. Sustituyen a los nombres que tengan las columnas en la consulta interior:

WITH model_seats (code, seat_count) AS (
  SELECT aircraft_code, COUNT(*)
  FROM seats
  GROUP BY aircraft_code
)
SELECT code, seat_count
FROM model_seats
ORDER BY seat_count DESC
LIMIT 3;
code seat_count
773 402
763 222
321 170

Casi todo el mundo pone los alias dentro del SELECT (COUNT(*) AS seat_count). La lista de columnas es una opción ordenada cuando la consulta interior tiene expresiones sin nombre, y deja la «interfaz» de la CTE a la vista en su primera línea.

CTE recursivas

WITH RECURSIVE permite que una CTE se refiera a sí misma. Tiene dos partes unidas por UNION o UNION ALL: un ancla que produce las filas iniciales y una parte recursiva que produce filas nuevas a partir de las filas de la vuelta anterior. Se detiene cuando una vuelta no produce nada nuevo.

El ejemplo más pequeño genera números:

WITH RECURSIVE n AS (
  SELECT 1 AS i
  UNION ALL
  SELECT i + 1 FROM n WHERE i < 5
)
SELECT i FROM n;
i
1
2
3
4
5

WHERE i < 5 es la condición de parada. Sin ella, la consulta no termina nunca. (Para números y fechas, generate_series es más sencillo; lo tienes en la guía del calendario de fechas, en inglés).

La recursión de verdad merece la pena con grafos y jerarquías. ¿Cuántos vuelos hacen falta para llegar desde Yuzhno-Sajalinsk (UUS) a los demás aeropuertos de la red?

WITH RECURSIVE routes AS MATERIALIZED (
  SELECT DISTINCT departure_airport AS src, arrival_airport AS dst
  FROM flights
),
reach AS (
  SELECT 'UUS'::VARCHAR AS airport, 0 AS hops
  UNION
  SELECT r.dst, reach.hops + 1
  FROM reach
  JOIN routes r ON r.src = reach.airport
  WHERE reach.hops < 4
),
best AS (
  SELECT airport, MIN(hops) AS hops
  FROM reach
  GROUP BY airport
)
SELECT hops, COUNT(*) AS airports
FROM best
GROUP BY hops
ORDER BY hops;
hops airports
0 1
1 4
2 76
3 23

Desde UUS hay cuatro destinos directos, 76 aeropuertos más necesitan una escala y los 23 restantes, dos escalas (tres vuelos). En total son 1 + 4 + 76 + 23 = 104: toda la red es alcanzable. La búsqueda podía llegar hasta cuatro saltos, pero ningún aeropuerto necesita un cuarto vuelo.

Cuatro detalles hacen que esta consulta sea segura:

CTE frente a subconsulta, tabla temporal y vista

Dura Reutilizable en la misma consulta Visible para otras consultas
Subconsulta un sitio de una consulta no, hay que copiarla no
CTE una sentencia sí, por su nombre no
Tabla temporal la sesión sí sí, en tu sesión
Vista hasta que se elimina sí sí, para todos los que tengan acceso

Usa una CTE para los pasos dentro de un mismo análisis. Si el mismo resultado intermedio alimenta varias consultas separadas, una tabla temporal te ahorra recalcularlo. Si todo un equipo necesita esa lógica, su sitio es una vista o un modelo en tu pipeline de datos.

¿Las CTE hacen las consultas más rápidas o más lentas?

Ni una cosa ni otra, por defecto. En PostgreSQL 12 y posteriores, una CTE no recursiva que se referencia una sola vez se integra en la consulta principal exactamente igual que una subconsulta, y el optimizador ve a través de ella. Una CTE referenciada más de una vez normalmente se calcula una vez y se reutiliza.

Puedes forzar otra cosa. WITH big AS MATERIALIZED (...) obliga a PostgreSQL a calcular la CTE una vez y guardarla, lo que ayuda cuando la CTE es cara y el optimizador se empeña en recalcularla. NOT MATERIALIZED fuerza la integración, de modo que los filtros de la consulta principal se pueden empujar hacia dentro. DuckDB acepta las mismas palabras clave MATERIALIZED y NOT MATERIALIZED.

El viejo consejo de que «las CTE son una barrera para el optimizador» viene de PostgreSQL 11 y anteriores, que siempre las materializaban. Si lo lees en un artículo antiguo, ese es el motivo.

Notas de portabilidad

Errores frecuentes

  1. Una coma después de la última CTE. Las CTE se separan con comas, pero entre la última y el SELECT principal no va coma. ), SELECT ... es un error de sintaxis.
  2. Escribir WITH dos veces. Una sentencia tiene un solo WITH; las demás CTE van detrás, separadas por comas: WITH a AS (...), b AS (...).
  3. Esperar que una CTE sobreviva a la sentencia. Después del punto y coma, el nombre desaparece. Si la siguiente consulta lo necesita, repite la CTE o crea una tabla temporal.
  4. Confiar en un ORDER BY dentro de una CTE. No fija el orden del resultado final. Ordena en la consulta principal.
  5. Una CTE recursiva sin condición de parada. Limita siempre la profundidad o asegúrate de que cada vuelta acabe devolviendo cero filas. UNION en lugar de UNION ALL ayuda cuando los caminos vuelven a pasar por las mismas filas.
  6. Una cadena larga que nadie revisa. Siete CTE solo son legibles si cada una es correcta. Mira el número de filas de cada paso mientras construyes la cadena, sobre todo después de los JOIN.

Si quieres ver las formas con subconsultas que las CTE sustituyen, lee la guía de subconsultas SQL. Muchas cadenas de CTE analíticas terminan en una función de ventana; la guía de funciones de ventana y la de retención por cohortes, en inglés muestran cadenas reales más largas.

Preguntas frecuentes

¿Qué es una CTE en SQL?

Una expresión de tabla común: un resultado temporal con nombre que se define con WITH nombre AS (SELECT ...) y que usa la consulta que va a continuación. Solo existe durante esa sentencia.

¿Qué diferencia hay entre una CTE y una subconsulta?

Lógicamente, ninguna: una CTE que se usa una vez se comporta como una tabla derivada. Las diferencias son prácticas. Una CTE tiene nombre, va arriba del todo para que la consulta se lea en orden, se puede referenciar varias veces y puede ser recursiva.

¿Puedo usar varias CTE en una consulta?

Sí. Escribe WITH una vez y separa las CTE con comas. Cada CTE puede usar las que se han definido antes.

¿Las CTE son más lentas que las subconsultas?

No en las versiones actuales de PostgreSQL ni en DuckDB: una CTE que se usa una vez se integra como una subconsulta. En PostgreSQL 11 y anteriores, las CTE siempre se materializaban, lo que podía bloquear optimizaciones. Usa MATERIALIZED o NOT MATERIALIZED si necesitas controlarlo.

¿Para qué sirve una CTE recursiva?

Para jerarquías y grafos: organigramas, árboles de categorías, rutas entre aeropuertos, cadenas de eventos. Aplica una consulta una y otra vez sobre su propio resultado anterior hasta que no aparecen filas nuevas.

También en English, Português (Brasil), Tiếng Việt, 简体中文