Curso SQL · Northwind
Tema 29 de 64
Bloque 6 · Consultas dentro de consultas PostgreSQL 17 northwind

Tablas derivadas

1. Qué es

Una tabla derivada es una subconsulta puesta en el FROM. El resultado de un SELECT se comporta como si fuera una tabla más.

SELECT ...
FROM (SELECT ...) AS t     ← esto es una tabla derivada
WHERE t.columna > ...

Es la tercera forma de subconsulta del bloque:

Dónde va Qué devuelve Se llama
SELECT / WHERE Un valor Escalar
WHERE ... IN Una columna Multivalor
FROM Una tabla Derivada

Y resuelve un problema muy concreto: hay cosas que SQL no deja hacer en un solo paso.

  • Filtrar por un alias del SELECT → el WHERE no lo ve (orden-logico-de-ejecucion).
  • Agregar sobre un agregado → avg(count(*)) da error.
  • Filtrar por una función de ventana → el WHERE se ejecuta antes que las ventanas.

Todas se resuelven igual: haz el primer paso en una tabla derivada y trabaja sobre su resultado.

2. Sintaxis

SELECT t.a, t.b
FROM (SELECT algo AS a, otra_cosa AS b FROM tabla WHERE ...) AS t
WHERE t.a > 10;
  • El alias (AS t) es obligatorio en el estándar SQL. PostgreSQL 16+ lo permite omitir, pero escríbelo siempre: sin él la consulta no es portable y las columnas no se pueden prefijar.
  • Las columnas de dentro tienen que tener nombre para poder usarlas fuera. Un count(*) sin alias se llama count, y eso funciona pero es feo.
  • Se pueden anidar y unir con JOIN como cualquier tabla.

3. Ejemplos sobre Northwind

Filtrar por un alias que el WHERE no ve:

SELECT *
FROM (SELECT category_id,
             count(*)                 AS productos,
             round(avg(unit_price),2) AS precio_medio
      FROM products
      GROUP BY category_id) AS t
WHERE t.productos >= 12
ORDER BY t.precio_medio DESC;
 category_id | productos | precio_medio
-------------+-----------+--------------
           1 |        12 |        37.98
           3 |        13 |        25.16
           2 |        12 |        22.85
           8 |        12 |        20.68

Aquí el HAVING count(*) >= 12 habría hecho lo mismo. La tabla derivada gana cuando el filtro es sobre algo que ni WHERE ni HAVING pueden ver — un alias calculado, una ventana, una expresión compuesta.

3.1 La agregación de dos niveles

Esto no se puede escribir directamente:

SELECT avg(count(*)) FROM orders GROUP BY customer_id;   -- no es lo que parece

Y esto sí:

SELECT round(avg(pedidos),2) AS media, max(pedidos) AS maximo
FROM (SELECT customer_id, count(*) AS pedidos
      FROM orders
      GROUP BY customer_id) AS t;
 media | maximo
-------+--------
  9.33 |     31

"La media de pedidos por cliente" es una media de conteos: primero se cuenta por cliente, después se promedia. Son dos agregaciones a dos granularidades distintas, y la tabla derivada es lo que las separa.

El ticket medio, bien calculado:

SELECT round(avg(facturacion),2) AS ticket_medio,
       round(max(facturacion),2) AS mayor,
       round(min(facturacion),2) AS menor
FROM (SELECT order_id, sum(unit_price * quantity * (1 - discount)) AS facturacion
      FROM order_details
      GROUP BY order_id) AS t;
 ticket_medio |  mayor   | menor
--------------+----------+-------
      1525.05 | 16387.50 | 12.50

1.525,05 — el mismo número del tema funciones-de-agregacion, pero ahora con su dispersión al lado: pedidos de 12,50 y de 16.387,50. Un rango de 1.300 a 1. Esa media no describe a ningún pedido real, y solo se ve porque la tabla derivada dejó los totales por pedido a mano.

Dos niveles con desglose:

SELECT anio, round(avg(pedidos_mes),1) AS pedidos_mes_medio
FROM (SELECT extract(year FROM order_date)::int AS anio,
             date_trunc('month', order_date)    AS mes,
             count(*)                           AS pedidos_mes
      FROM orders
      GROUP BY 1, 2) AS t
GROUP BY anio
ORDER BY anio;
 anio | pedidos_mes_medio
------+-------------------
 1996 |              25.3
 1997 |              34.0
 1998 |              54.0

El negocio se duplicó en dos años, y esta es la forma honesta de verlo: 1996 y 1998 están incompletos, así que comparar totales anuales engaña (152 / 408 / 270) y comparar medias mensuales no. Es el mismo aviso del gráfico que se desploma en el último periodo.

3.2 Filtrar por una función de ventana

Las ventanas se calculan después del WHERE, así que no se pueden filtrar donde se calculan. La tabla derivada es la salida:

SELECT t.pais, t.pedidos
FROM (SELECT ship_country AS pais,
             count(*)     AS pedidos,
             rank() OVER (ORDER BY count(*) DESC) AS puesto
      FROM orders
      GROUP BY 1) AS t
WHERE t.puesto <= 3;
  pais   | pedidos
---------+---------
 Germany |     122
 USA     |     122
 Brazil  |      83

Tres filas para "el top 3", y Alemania y EE. UU. empatan a 122. rank() da el mismo puesto a los empatados, así que aquí el corte es honesto. Con LIMIT 3 habría salido uno de los dos arbitrariamente. Las ventanas están en el bloque 7 (ranking-row-number-rank-ntile); este patrón —ventana dentro, filtro fuera— es el que más se repite de todo SQL analítico.

3.3 Unir dos agregados

SELECT count(*)
FROM (SELECT customer_id FROM orders GROUP BY customer_id HAVING count(*) > 10)   AS a
JOIN (SELECT customer_id FROM orders GROUP BY customer_id HAVING sum(freight) > 1000) AS b
  USING (customer_id);
 count
-------
    15

15 clientes cumplen las dos condiciones. Es el patrón de joins-multiples-y-granularidad: agregar por separado y unir los agregados. Aquí las dos condiciones cabrían en un solo HAVING, pero en cuanto vengan de tablas distintas esta es la única forma.

4. El problema: se leen del revés

SELECT t2.pais, t2.pedidos
FROM (SELECT t1.pais, t1.pedidos
      FROM (SELECT ship_country AS pais, count(*) AS pedidos
            FROM orders GROUP BY 1) AS t1
      WHERE t1.pedidos > 20) AS t2
ORDER BY t2.pedidos DESC;

Funciona. Y hay que leerla de dentro hacia fuera, mientras que el ojo va de arriba abajo. Con dos niveles ya cuesta; con cuatro es ilegible.

Esa es la única razón por la que existe el tema siguiente: un CTE es una tabla derivada con nombre, escrita en el orden en que se piensa (cte-with).

-- Lo mismo, legible
WITH por_pais AS (
    SELECT ship_country AS pais, count(*) AS pedidos FROM orders GROUP BY 1
)
SELECT pais, pedidos FROM por_pais WHERE pedidos > 20 ORDER BY pedidos DESC;

Cuándo sigue mereciendo la pena una tabla derivada: cuando es corta, se usa una sola vez y está dentro de un FROM que ya se lee bien. Para todo lo demás, CTE.

5. Errores comunes

  1. Olvidar el alias. PostgreSQL 16+ lo perdona; el estándar y SQL Server no. Escríbelo.
  2. Columnas sin nombre dentro. No se pueden referenciar bien desde fuera.
  3. Anidar tres o más niveles. Legal e ilegible. Pasa a CTE.
  4. Repetir la misma tabla derivada dos veces en la misma consulta. Se ejecuta dos veces y hay que mantenerla en dos sitios. Es la señal exacta de que quieres un CTE.
  5. Usar una tabla derivada donde bastaba HAVING. Si el filtro es sobre una agregación del mismo nivel, HAVING es más corto.
  6. Filtrar dentro cuando podías filtrar fuera, o al revés. Filtra lo antes posible: reducir filas dentro de la derivada es lo que abarata todo lo de fuera.
  7. Creer que una tabla derivada "materializa" el resultado. No es una tabla temporal: PostgreSQL suele fusionarla con la consulta externa. No hay coste extra por usarla, ni ahorro por reutilizarla.

6. Diferencias con SQL Server

PostgreSQL SQL Server
Tabla derivada en FROM ✅ ✅ Idéntica
Alias obligatorio ❌ Opcional desde la 16 ✅ Obligatorio
Anidar ✅ ✅
Correlacionar con la consulta externa Solo con LATERAL Solo con APPLY

La única diferencia práctica es el alias, y como SQL Server lo exige, escribirlo siempre hace el código portable sin coste.

El punto importante: una tabla derivada normal no puede ver las columnas de la consulta externa. Esto falla en los dos motores:

SELECT p.product_name, t.media
FROM products p,
     (SELECT avg(unit_price) AS media FROM products WHERE category_id = p.category_id) t;
                                                                        ↑ p no existe aquí

Para eso está LATERAL (o APPLY), y es el tema lateral.

7. Ejercicios

  1. La media, el máximo y el mínimo de productos por categoría.
  2. Los clientes cuyo ticket medio supera los 2.000, con su número de pedidos.
  3. El número medio de líneas por pedido.
  4. Los tres empleados con más facturación, usando rank() dentro de una tabla derivada.
  5. Explica por qué esto da error y arréglalo: sql SELECT category_id, count(*) AS productos FROM products WHERE productos > 10 GROUP BY category_id;

8. Pregunta de negocio

Dirección quiere saber si el negocio depende demasiado de unos pocos clientes: qué porcentaje de la facturación total aportan los 10 mayores.

? RespuestaRespóndela antes de abrir
SELECT count(*)                                   AS clientes_top,
       round(sum(t.facturacion), 2)               AS facturacion_top10,
       round((SELECT sum(unit_price * quantity * (1 - discount)) FROM order_details), 2) AS facturacion_total,
       round(100.0 * sum(t.facturacion)
             / (SELECT sum(unit_price * quantity * (1 - discount)) FROM order_details), 1) AS pct
FROM (SELECT o.customer_id,
             sum(od.unit_price * od.quantity * (1 - od.discount)) AS facturacion
      FROM orders o
      JOIN order_details od ON od.order_id = o.order_id
      GROUP BY o.customer_id
      ORDER BY facturacion DESC
      LIMIT 10) AS t;
 clientes_top | facturacion_top10 | facturacion_total | pct
--------------+-------------------+-------------------+------
           10 |         570145.05 |        1265793.04 | 45.0

Los 10 mayores clientes de 89 aportan el 45% de la facturación.

Tres cosas del diseño:

  1. El ORDER BY ... LIMIT 10 va dentro de la tabla derivada. Fuera no serviría: LIMIT es el último paso y recortaría el resultado final, que es una sola fila. Ordenar y cortar dentro, agregar fuera es el patrón.
  2. El total va como subconsulta escalar, no como otra derivada. Es un único número que no depende de nada, así que la forma más simple es la del tema 26.
  3. Comprobación obligatoria: 570.145,05 / 1.265.793,04 = 45,0%, y el total coincide con el del tema funciones-de-agregacion. Si el denominador no cuadra con la facturación conocida de la empresa, el porcentaje no vale nada por muy bien calculado que esté el numerador.

Y lo que hay que decir al entregar. El 11% de los clientes aporta el 45% del negocio: está en la línea de lo que se ve en cualquier cartera, algo por debajo del 20/80 clásico.

Pero la pregunta era sobre riesgo, y el riesgo no está en la suma de los diez — está en el máximo individual:

SELECT o.customer_id,
       round(100.0 * sum(od.unit_price * od.quantity * (1 - od.discount))
             / (SELECT sum(unit_price * quantity * (1 - discount)) FROM order_details), 2) AS pct
FROM orders o JOIN order_details od ON od.order_id = o.order_id
GROUP BY 1 ORDER BY pct DESC LIMIT 3;
 customer_id | pct
-------------+------
 QUICK       | 8.71
 ERNSH       | 8.29
 SAVEA       | 8.24

Tres clientes rondan el 8,5% cada uno: entre los tres, la cuarta parte del negocio. Perder a QUICK cuesta casi el 9% de la facturación anual. Ese es el número que contesta a "¿dependemos demasiado?", y no estaba en lo que pidieron.

✓ SolucionesResuélvelos antes de abrir

1.

SELECT round(avg(productos),2) AS media, max(productos) AS maximo, min(productos) AS minimo
FROM (SELECT category_id, count(*) AS productos FROM products GROUP BY category_id) AS t;
 media | maximo | minimo
-------+--------+--------
  9.63 |     13 |      5

Media de conteos otra vez. Y 9,63 × 8 = 77. ✓ Comprobar que el producto cuadra con el total es la verificación de una línea.

2.

SELECT t.customer_id, t.pedidos, round(t.ticket_medio, 2) AS ticket_medio
FROM (SELECT o.customer_id,
             count(DISTINCT o.order_id) AS pedidos,
             sum(od.unit_price * od.quantity * (1 - od.discount))
               / count(DISTINCT o.order_id) AS ticket_medio
      FROM orders o
      JOIN order_details od ON od.order_id = o.order_id
      GROUP BY o.customer_id) AS t
WHERE t.ticket_medio > 2000
ORDER BY t.ticket_medio DESC;

12 clientes. El count(DISTINCT o.order_id) sigue siendo obligatorio por el fan-out con order_details.

3.

SELECT round(avg(lineas), 2) AS lineas_por_pedido
FROM (SELECT order_id, count(*) AS lineas FROM order_details GROUP BY order_id) AS t;
 lineas_por_pedido
-------------------
              2.60

2,60 — el multiplicador exacto del fan-out del tema 25. Ahí lo estimé; aquí está calculado.

4.

SELECT t.employee_id, t.facturacion, t.puesto
FROM (SELECT o.employee_id,
             round(sum(od.unit_price * od.quantity * (1 - od.discount)), 2) AS facturacion,
             rank() OVER (ORDER BY sum(od.unit_price * od.quantity * (1 - od.discount)) DESC) AS puesto
      FROM orders o
      JOIN order_details od ON od.order_id = o.order_id
      GROUP BY o.employee_id) AS t
WHERE t.puesto <= 3
ORDER BY t.puesto;
 employee_id | facturacion | puesto
-------------+-------------+--------
           4 |   232890.85 |      1
           3 |   202812.84 |      2
           1 |   192107.60 |      3

Una ventana sobre una agregación: rank() OVER (ORDER BY sum(...)) es legal porque las ventanas se calculan después del GROUP BY. Lo que no se puede es filtrarla ahí — por eso la tabla derivada.

5. Falla porque productos es un alias del SELECT y el WHERE se ejecuta antes (orden-logico-de-ejecucion). Además es una agregación, y las agregaciones no van en el WHERE.

Dos arreglos:

-- A: HAVING (más corto)
SELECT category_id, count(*) AS productos FROM products GROUP BY category_id HAVING count(*) > 10;

-- B: tabla derivada (permite usar el alias)
SELECT * FROM (SELECT category_id, count(*) AS productos FROM products GROUP BY category_id) AS t
WHERE t.productos > 10;

Los dos dan 4 categorías. A es mejor aquí: HAVING existe justo para esto. B es la única opción cuando el filtro es sobre algo que HAVING tampoco ve, como una función de ventana.