Las tres lecciones anteriores han respondido al qué: qué devuelve una subconsulta, si está correlacionada, si pregunta por existencia. Esta responde al dónde. Porque la misma subconsulta colocada en el SELECT, en el FROM o en el WHERE no hace lo mismo, no cuesta lo mismo y no admite lo mismo.

Y hay una cláusula que todavía no has usado y que es, con diferencia, la más potente: el FROM. Una subconsulta ahí se llama tabla derivada y permite algo que ninguna otra construcción del curso permitía: agregar un resultado ya agregado. Con ella calcularás por fin el ticket medio de 36,40 € desde su origen, cruzarás dos agregados de granularidad distinta para que los 727,95 € de producto y los 118,25 € de portes cuadren sin inflarse —el problema que arrastras desde el módulo 3— y conocerás LATERAL, la excepción que permite correlacionar una tabla derivada.

Contenido

  1. Tabla resumen: qué cambia según dónde la pongas
  2. En el SELECT: la columna calculada
  3. En el FROM: la tabla derivada
  4. LATERAL: la tabla derivada que sí puede correlacionarse
  5. En el WHERE y en el HAVING
  6. En INSERT, UPDATE y DELETE
  7. Tabla de decisión: dado un problema, dónde ponerla
  8. Errores Comunes y Consejos
  9. Ejercicios
  10. Conclusión

  1. Tabla resumen: qué cambia según dónde la pongas

Cláusula Qué debe devolver ¿Correlacionada? Coste típico Alternativa preferible
SELECT Escalar: 1 fila, 1 columna , y casi siempre lo es 1 ejecución por fila LEFT JOIN + GROUP BY
FROM Tabla: cualquier forma No, salvo con LATERAL 1 (o 1 por fila con LATERAL) CTE (10-02) con 3+ niveles
WHERE Escalar, o lista para IN/EXISTS 1, o 1 por fila si correlaciona JOIN si necesitas sus columnas
HAVING Escalar Sí (por grupo) 1
SET de un UPDATE Escalar 1 por fila actualizada UPDATE ... FROM (05-03)

Tres reglas se deducen de la tabla: en el SELECT solo cabe un valor (dos filas o dos columnas y la consulta revienta); en el FROM el alias es obligatorio, siempre, aunque no lo uses; y una tabla derivada no ve las demás tablas de su mismo FROM, que es la restricción que LATERAL levanta.

  1. En el SELECT: la columna calculada

Una subconsulta escalar en el SELECT se comporta como una columna más. Ya la usaste en 07-02: aquí interesa su límite y su alternativa.

SELECT c.id,
       c.nombre || ' ' || c.apellidos AS cliente,
       c.pais,
       (SELECT COUNT(*) FROM pedidos AS pe WHERE pe.cliente_id = c.id) AS pedidos,
       (SELECT COALESCE(ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2), 0)
        FROM pedidos AS pe
        JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
        WHERE pe.cliente_id = c.id) AS total_comprado
FROM clientes AS c
ORDER BY total_comprado DESC, c.id
LIMIT 4;
id cliente pais pedidos total_comprado
7 Sofia Moreira Costa Portugal 2 111.88
1 Lucía Martínez Soler España 3 107.60
9 Camille Dubois Francia 2 70.87
10 Julien Moreau Francia 1 66.90

(4 primeras de 15 filas.) Los tres clientes sin pedidos cierran la lista con 0 y 0.00, gracias al COALESCE de 06-04.

Las tres propiedades que definen este uso: solo puede devolver un valor (si necesitas el total y el número de pedidos, hacen falta dos subconsultas, y cada una recorre pedidos por su cuenta); no filtra filas, los 15 clientes siguen ahí, comportándose como un LEFT JOIN sin serlo; y se ejecuta una vez por fila: dos subconsultas × 15 clientes = 30 ejecuciones.

La misma consulta con LEFT JOIN y GROUP BY

SELECT c.id,
       c.nombre || ' ' || c.apellidos AS cliente,
       c.pais,
       COUNT(DISTINCT pe.id) AS pedidos,
       COALESCE(ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2), 0) AS total_comprado
FROM clientes AS c
LEFT JOIN pedidos       AS pe ON pe.cliente_id = c.id
LEFT JOIN lineas_pedido AS lp ON lp.pedido_id  = pe.id
GROUP BY c.id, c.nombre, c.apellidos, c.pais
ORDER BY total_comprado DESC, c.id
LIMIT 4;

Resultado idéntico, las mismas 15 filas con los mismos valores. Pero el trabajo es muy distinto:

Subconsultas en el SELECT LEFT JOIN + GROUP BY
Pasadas por pedidos 30 (2 × 15 clientes) 1
Legibilidad con 2 columnas / con 5 Muy buena / mala Buena / buena
Riesgo de COUNT(*) inflado Ninguno Alto: hay que usar COUNT(DISTINCT pe.id)
Añadir una métrica nueva Copiar y pegar un bloque Añadir un agregado

Fíjate en la penúltima fila, que es el matiz importante: la versión con JOIN necesita COUNT(DISTINCT pe.id) porque el segundo LEFT JOIN multiplica las filas de cada pedido por sus líneas. Es exactamente el problema de 04-04, y la versión con subconsultas es inmune a él. Ninguna de las dos formas es mejor siempre: con una o dos columnas la subconsulta se lee mejor; a partir de tres, el GROUP BY gana por goleada. La discusión completa es 07-05.

  1. En el FROM: la tabla derivada

Una subconsulta en el FROM produce una tabla derivada: un resultado intermedio que la consulta externa trata como una tabla real. Es la cláusula que desbloquea el patrón más útil del análisis: agregar dos veces.

El alias obligatorio

-- ⚠️ INCORRECTA
SELECT AVG(total) FROM (SELECT SUM(cantidad) AS total FROM lineas_pedido GROUP BY pedido_id);
ERROR:  subquery in FROM must have an alias
HINT:  For example, FROM (SELECT ...) [AS] foo.

Toda tabla derivada necesita nombre, aunque no lo utilices: sus columnas tienen que poder cualificarse (t.total), y sin nombre de tabla eso es imposible.

Nota de dialecto: PostgreSQL, MySQL, MariaDB y SQL Server exigen el alias; SQLite y Oracle permiten omitirlo. Escríbelo siempre: es portable y hace la consulta legible.

Caso 1: agregar dos veces — el ticket medio, desde su origen

En 07-01 calculaste el ticket medio como SUM(importe) / COUNT(DISTINCT pedido_id). Es correcto, pero es un atajo: la formulación honesta es calcular el total de cada pedido y luego hacer la media de esos totales. Son dos agregaciones encadenadas, y solo una tabla derivada las permite.

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 (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) AS t;
pedidos ticket_medio ticket_minimo ticket_maximo facturacion
20 36.40 22.60 66.90 727.95

Ahí están las cifras canónicas del curso, y ahora por construcción: 20 tickets, media de 36,40 €, facturación de 727,95 €; el ticket más barato es el pedido 20 de Camille y el más caro el 12 de Julien.

Lo que hace posible el resultado es que la tabla derivada cambia la granularidad: entra a 47 líneas y sale a 20 pedidos, y la consulta externa agrega sobre esas 20. Sin ella, AVG sobre las líneas daría los 15,49 € de importe medio de línea, que es otra cosa.

Caso 2: agregar y luego filtrar

Una vez tienes la tabla derivada, la filtras como cualquier tabla:

SELECT t.pedido_id, c.nombre || ' ' || c.apellidos AS cliente, t.total
FROM (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) AS t
JOIN clientes AS c ON c.id = t.cliente_id
WHERE t.total > 36.40
ORDER BY t.total DESC;
pedido_id cliente total
12 Julien Moreau 66.90
8 Sofia Moreira Costa 64.88
10 Camille Dubois 48.27
17 Sofia Moreira Costa 47.00

(4 primeras de 6 filas; cierran los pedidos 9 de Tiago, 44,60 €, y 1 de Lucía, 42,10 €.) 6 pedidos de 20 superan el ticket medio. El filtro está en el WHERE de la consulta externa, no en un HAVING: para ella, t.total es una columna normal. Una tabla derivada convierte agregados en columnas corrientes, y eso simplifica muchísimo la escritura.

Caso 3: dos agregados de granularidad distinta — el problema de los portes

Este es el caso que el curso lleva pendiente desde el módulo 3. Los gastos de envío viven en pedidos (uno por pedido) y los importes en lineas_pedido (varias por pedido): sumarlos en la misma consulta con un JOIN daba 278,70 € de portes en lugar de 118,25 €, porque cada pedido se contaba tantas veces como líneas tenía. La solución es agregar cada cosa por su lado y unir los resultados ya agregados:

SELECT c.id,
       c.nombre || ' ' || c.apellidos AS cliente,
       ped.pedidos,
       lin.productos,
       ped.portes,
       ROUND(lin.productos + ped.portes, 2) AS total
FROM clientes AS c
JOIN (SELECT cliente_id, COUNT(*) AS pedidos, SUM(gastos_envio) AS portes
      FROM pedidos GROUP BY cliente_id) AS ped ON ped.cliente_id = c.id
JOIN (SELECT pe.cliente_id,
             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.cliente_id) AS lin ON lin.cliente_id = c.id
ORDER BY total DESC;
id cliente pedidos productos portes total
7 Sofia Moreira Costa 2 111.88 19.80 131.68
1 Lucía Martínez Soler 3 107.60 4.95 112.55
9 Camille Dubois 2 70.87 25.00 95.87

(3 primeras de 12 filas.) Y las columnas cuadran: sumadas las 12 filas dan 727,95 € de producto + 118,25 € de portes = 846,20 €, las tres cifras canónicas del curso. Ni un céntimo inflado.

La clave está en que cada derivada ya viene a granularidad de clienteped tiene 12 filas y lin también—, así que al unirlas cada pedido aporta sus portes una sola vez: el SUM se hizo antes del JOIN. La versión global cabe en tres líneas:

SELECT lin.facturacion, ped.portes, ROUND(lin.facturacion + ped.portes, 2) AS total
FROM (SELECT ROUND(SUM(cantidad * precio_unitario * (1 - descuento)), 2) AS facturacion
      FROM lineas_pedido) AS lin
CROSS JOIN (SELECT SUM(gastos_envio) AS portes FROM pedidos) AS ped;
facturacion portes total
727.95 118.25 846.20

A partir de tres niveles, usa CTE. Dos tablas derivadas todavía se leen; con tres, o con una derivada dentro de otra dentro de otra, la sangría se come la pantalla. La solución es dar nombre a cada paso con WITH, y eso es 10-02: todo lo de esta sección se reescribe allí con la mitad de paréntesis.

  1. LATERAL: la tabla derivada que sí puede correlacionarse

Ya sabes que una tabla derivada no ve las demás tablas de su mismo FROM:

-- ⚠️ INCORRECTA
SELECT cat.nombre, top.nombre FROM categorias AS cat,
     (SELECT p.nombre FROM productos AS p WHERE p.categoria_id = cat.id LIMIT 2) AS top;
ERROR:  invalid reference to FROM-clause entry for table "cat"
HINT:  There is an entry for table "cat", but it cannot be referenced from this part of the query.

La palabra clave LATERAL levanta esa restricción: dice al motor "evalúa esta subconsulta una vez por cada fila de lo que hay a su izquierda". Es un for sobre la tabla anterior, y se combina con CROSS JOIN LATERAL o con LEFT JOIN LATERAL ... ON TRUE. Su caso natural es el top-N por grupo:

SELECT cat.id, cat.nombre AS categoria, top.producto, top.unidades
FROM categorias AS cat
CROSS JOIN LATERAL (
        SELECT p.nombre AS producto, SUM(lp.cantidad) AS unidades
        FROM productos       AS p
        JOIN lineas_pedido   AS lp ON lp.producto_id = p.id
        WHERE p.categoria_id = cat.id
        GROUP BY p.id, p.nombre
        ORDER BY unidades DESC, p.id
        LIMIT 2) AS top
ORDER BY cat.id, top.unidades DESC;
id categoria producto unidades
1 Alimentación Arroz integral ecológico 1 kg 14
1 Alimentación Tomate triturado ecológico 400 g 14
2 Cosmética natural Bálsamo labial de caléndula 15 ml 7
3 Hogar sostenible Bolsas reutilizables de algodón (pack 5) 4
4 Bebidas Kombucha de jengibre 750 ml 12
5 Higiene personal Cepillo de dientes de bambú 9

(6 de las 9 filas.) Las cuatro primeras categorías aportan sus dos productos más vendidos; Higiene personal solo tiene uno vendido (el cepillo, porque el desodorante nunca se vendió) y aporta una fila. Y Complementos no aparece, porque su subconsulta devuelve cero filas y CROSS JOIN LATERAL se comporta como un INNER JOIN; para verla con NULL se usa LEFT JOIN LATERAL (...) AS top ON TRUE, que da 10 filas. Ese ON TRUE no es un adorno: la sintaxis exige una condición de unión y la correlación ya está dentro, así que no queda nada que poner.

Tabla derivada normal LATERAL
Ve las tablas anteriores del FROM No
Veces que se evalúa 1 1 por fila de la izquierda
Permite LIMIT por grupo No : es su gran ventaja

Nota de dialecto: LATERAL es estándar SQL:1999 y funciona en PostgreSQL 9.3+, MySQL 8.0.14+ y Oracle 12c+. En SQL Server el equivalente se llama CROSS APPLY (y OUTER APPLY para la versión con NULL), con la misma semántica y sin la palabra LATERAL. SQLite no lo soporta. Y para este mismo problema hay una tercera vía, a menudo mejor: ROW_NUMBER() OVER (PARTITION BY categoria_id ORDER BY unidades DESC) filtrando por <= 2, que es 10-03.

  1. En el WHERE y en el HAVING

Es el territorio de 07-01 y 07-03: en el WHERE caben subconsultas escalares (> (SELECT AVG(...))), de lista (IN, ANY, ALL) y de existencia (EXISTS), correlacionadas o no; y si necesitas columnas de la tabla interna en el resultado, eso no es un WHERE, es un JOIN. El HAVING merece un ejemplo propio, porque su subconsulta compara el agregado de un grupo con un valor calculado sobre otro conjunto:

SELECT cat.id, cat.nombre AS categoria,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos  AS p   ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.id, cat.nombre
HAVING SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
       > (SELECT SUM(lp2.cantidad * lp2.precio_unitario * (1 - lp2.descuento))
                 / COUNT(DISTINCT p2.categoria_id)
          FROM lineas_pedido AS lp2
          JOIN productos AS p2 ON lp2.producto_id = p2.id)
ORDER BY facturacion DESC;
id categoria facturacion
1 Alimentación 256.27
4 Bebidas 195.28
2 Cosmética natural 156.32

3 categorías de las 5 con ventas superan la facturación media por categoría, que es de 145,59 € (727,945 € entre las 5 categorías que han vendido algo). Hogar sostenible (88,58 €) e Higiene personal (31,50 €) se quedan por debajo, y Complementos ni siquiera llega al GROUP BY porque el INNER JOIN la descartó.

Fíjate en el divisor: COUNT(DISTINCT p2.categoria_id) cuenta 5, no 6. Para dividir entre las seis categorías del catálogo, el divisor tendría que salir de categorias, no de las ventas. La media depende de sobre qué la calculas.

  1. En INSERT, UPDATE y DELETE

El módulo 5 te enseñó las tres instrucciones; ahora puedes darles una subconsulta en cualquiera de sus cláusulas.

-- 1. En el WHERE de un UPDATE: subir un 10 % las bebidas
UPDATE productos SET precio = ROUND(precio * 1.10, 2)
WHERE categoria_id = (SELECT id FROM categorias WHERE nombre = 'Bebidas');
-- 2. CORRELACIONADA en el SET: alinear el precio con la media realmente vendida
UPDATE productos AS p
SET precio = (SELECT ROUND(AVG(lp.precio_unitario), 2)
              FROM lineas_pedido AS lp WHERE lp.producto_id = p.id)
WHERE EXISTS (SELECT 1 FROM lineas_pedido AS lp WHERE lp.producto_id = p.id);
-- 3. En un DELETE: borrar reseñas de productos descatalogados
DELETE FROM resenas AS r
WHERE EXISTS (SELECT 1 FROM productos AS p WHERE p.id = r.producto_id AND p.activo = FALSE);

La segunda esconde la trampa más peligrosa de la lección. Sin el WHERE EXISTS final, los tres productos nunca vendidos recibirían el resultado de una subconsulta sin filas —es decir NULL— y precio es NOT NULL: la sentencia fallaría entera con null value in column "precio" violates not-null constraint. Si la columna admitiera nulos sería peor: se quedaría en NULL sin decir nada. La regla de 05-03 se amplía: en un UPDATE con subconsulta en el SET, el WHERE debe garantizar que esa subconsulta encuentra algo.

  1. Tabla de decisión: dado un problema, dónde ponerla

Lo que quieres Dónde va la subconsulta Forma
Filtrar por un umbral calculado o por pertenencia a una lista WHERE > (SELECT AVG(...)), IN (SELECT ...)
Filtrar por existencia o ausencia WHERE EXISTS / NOT EXISTS
Filtrar grupos por un valor global HAVING > (SELECT ...)
Añadir un dato calculado por fila (o varios) SELECT escalar correlacionada; con 3+, LEFT JOIN + GROUP BY
Agregar sobre un agregado FROM Tabla derivada
Cruzar métricas de granularidad distinta FROM (dos derivadas) JOIN entre ellas
Un "top N" por cada fila de otra tabla FROM LATERAL
Reutilizar el mismo cálculo tres veces Ninguna: CTE WITH (10-02)

Errores Comunes y Consejos

  • Olvidar el alias de una tabla derivada. subquery in FROM must have an alias. Ponlo siempre, aunque no lo uses. Y referenciar otra tabla del mismo FROM desde una derivada da invalid reference to FROM-clause entry: para eso está LATERAL.
  • Poner una subconsulta multifila en el SELECT. more than one row returned by a subquery used as an expression: ahí solo cabe un valor.
  • Sumar gastos_envio después de unir con lineas_pedido. Es el error de 04-04 —278,70 € en lugar de 118,25 €—: agrega cada cosa por su lado en dos derivadas.
  • Confundir el ticket medio (36,40 €, media de 20 pedidos) con el importe medio de línea (15,49 €, media de 47). Solo la tabla derivada da el primero. Y contar con COUNT(*) tras varios LEFT JOIN infla: cada pedido aparece tantas veces como líneas, así que COUNT(DISTINCT pe.id).
  • Un UPDATE con subconsulta en el SET sin WHERE que la acote. Las filas sin coincidencia reciben NULL: o revienta la restricción, o corrompe los datos en silencio.
  • Consejo: construye las derivadas de dentro hacia fuera. Escribe la subconsulta sola, ejecútala, comprueba cuántas filas y qué granularidad tiene, y solo entonces envuélvela: una derivada siempre se puede ejecutar aislada (salvo con LATERAL).
  • Consejo: nómbrala por lo que contiene, no t1 y t2: ped, lin, ventas_por_cliente. Y cuenta las filas de cada nivel —47 líneas → 20 pedidos → 1 fila—: si un nivel no reduce lo que esperabas, el error está ahí y no en el de arriba.

Ejercicios

Ejercicio 1

Dirección quiere el ticket medio por país: para cada país, el número de pedidos, la facturación de producto y el ticket medio (media de los totales de sus pedidos). Usa una tabla derivada. Después responde: ¿sería distinto el resultado si calcularas SUM(importe) / COUNT(DISTINCT pedido_id) sin tabla derivada?

Ejercicio 2

Un compañero quiere el informe "por cliente: pedidos, líneas, unidades, productos distintos y facturación" y ha empezado así:

SELECT c.id, c.nombre,
       (SELECT COUNT(*) FROM pedidos pe WHERE pe.cliente_id = c.id) AS pedidos,
       (SELECT COUNT(*) FROM pedidos pe JOIN lineas_pedido lp ON lp.pedido_id = pe.id
        WHERE pe.cliente_id = c.id) AS lineas,
       (SELECT SUM(lp.cantidad) FROM pedidos pe JOIN lineas_pedido lp ON lp.pedido_id = pe.id
        WHERE pe.cliente_id = c.id) AS unidades
FROM clientes c;
  1. ¿Cuántas ejecuciones de subconsulta supone tal como está, y cuántas si añade las dos columnas que faltan?
  2. Reescríbelo con un LEFT JOIN y GROUP BY, cuidando el recuento de pedidos.
  3. ¿Qué diferencia habrá en el resultado para los clientes 13, 14 y 15?

Ejercicio 3

Marketing quiere, para cada cliente que haya comprado, su pedido más caro: id del pedido, fecha e importe. Escríbelo con LATERAL y explica por qué una tabla derivada normal no serviría.

Soluciones

Solución 1

SELECT t.pais,
       COUNT(*)               AS pedidos,
       ROUND(SUM(t.total), 2) AS facturacion,
       ROUND(AVG(t.total), 2) AS ticket_medio
FROM (SELECT pe.id, c.pais,
             ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total
      FROM pedidos       AS pe
      JOIN clientes      AS c  ON c.id = pe.cliente_id
      JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
      GROUP BY pe.id, c.pais) AS t
GROUP BY t.pais
ORDER BY ticket_medio DESC;
pais pedidos facturacion ticket_medio
Portugal 3 156.48 52.16
Francia 3 137.77 45.92
España 14 433.70 30.98

3 + 3 + 14 = 20 pedidos y 156,48 + 137,77 + 433,70 = 727,95 €: cuadra con las cifras canónicas. Y la lectura confirma lo que ya viste en 07-01: los pedidos extranjeros son sensiblemente mayores —52,16 € y 45,92 € frente a 30,98 €—, coherente con unos portes de 9,90 € y 12,50 € que empujan a agrupar la compra.

Sobre la pregunta: aquí el resultado sería el mismo, porque AVG de los totales por pedido y SUM(importe) / COUNT(DISTINCT pedido_id) son aritméticamente idénticos cuando cada pedido pertenece a un solo país. La tabla derivada gana igual por dos motivos: se lee mucho mejor —dice literalmente "media de los totales de los pedidos"— y permite calcular lo que el atajo no puede, como MIN(t.total), MAX(t.total) o la mediana.

Solución 2

1. Las ejecuciones. Tres subconsultas × 15 clientes = 45; con las dos columnas que faltan, 75. Y las cinco recorren las mismas dos tablas con el mismo filtro: cinco veces el mismo trabajo. 2. La reescritura:

SELECT c.id,
       c.nombre,
       COUNT(DISTINCT pe.id)         AS pedidos,
       COUNT(lp.id)                  AS lineas,
       COALESCE(SUM(lp.cantidad), 0) AS unidades,
       COUNT(DISTINCT lp.producto_id) AS productos_distintos,
       COALESCE(ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2), 0) AS facturacion
FROM clientes AS c
LEFT JOIN pedidos       AS pe ON pe.cliente_id = c.id
LEFT JOIN lineas_pedido AS lp ON lp.pedido_id  = pe.id
GROUP BY c.id, c.nombre
ORDER BY c.id;
id nombre pedidos lineas unidades productos_distintos facturacion
1 Lucía 3 9 15 8 107.60
7 Sofia 2 5 11 4 111.88
13 Núria 0 0 0 0 0.00

(3 de las 15 filas, a modo de muestra.) COUNT(DISTINCT pe.id) es obligatorio: con COUNT(pe.id) a secas, Lucía tendría 9 pedidos en vez de 3, uno por cada línea. Es exactamente la trampa de 04-04. En cambio COUNT(lp.id) sí va sin DISTINCT, porque cada línea es única.

3. Los clientes 13, 14 y 15 aparecen en las dos versiones con los mismos valores: 0 donde hay COUNT y 0.00 donde el COALESCE cubre el NULL de SUM. Ni la subconsulta en el SELECT ni el LEFT JOIN los eliminan; lo que los habría eliminado es un INNER JOIN, y por eso el LEFT no es negociable aquí.

Solución 3

SELECT c.id,
       c.nombre || ' ' || c.apellidos AS cliente,
       mx.pedido_id, mx.fecha_pedido, mx.total
FROM clientes AS c
CROSS JOIN LATERAL (
        SELECT pe.id AS pedido_id, pe.fecha_pedido,
               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
        WHERE pe.cliente_id = c.id
        GROUP BY pe.id, pe.fecha_pedido
        ORDER BY total DESC, pe.id
        LIMIT 1) AS mx
ORDER BY mx.total DESC, c.id;
id cliente pedido_id fecha_pedido total
10 Julien Moreau 12 2025-10-01 66.90
7 Sofia Moreira Costa 8 2025-06-28 64.88
9 Camille Dubois 10 2025-08-03 48.27

(3 primeras de 12 filas.) Los clientes 13, 14 y 15 no aparecen: su subconsulta lateral devuelve cero filas y CROSS JOIN LATERAL los descarta, que es justo lo que pedía el enunciado. Con LEFT JOIN LATERAL ... ON TRUE saldrían las 15 filas, con NULL en las tres últimas.

Por qué una tabla derivada normal no sirve: necesitaríamos escribir WHERE pe.cliente_id = c.id dentro de ella, y una derivada corriente no ve c (invalid reference to FROM-clause entry for table "c"). Podrías rodearlo agregando por cliente y uniendo por MAX(total), pero eso obliga a un segundo JOIN para recuperar el id y la fecha del pedido ganador, y duplica filas si hay empate. LATERAL lo resuelve en una pasada porque el LIMIT 1 se aplica por cliente, y eso ninguna otra construcción del módulo puede hacerlo. La alternativa moderna es ROW_NUMBER() (10-03).

Conclusión

El dónde importa tanto como el qué:

  • En el SELECT, una subconsulta escalar es una columna calculada: no filtra filas y se ejecuta una vez por fila. Con una o dos columnas se lee muy bien; a partir de tres gana el LEFT JOIN + GROUP BY, cuidando el COUNT(DISTINCT).
  • En el FROM, una subconsulta es una tabla derivada y necesita alias obligatoriamente (subquery in FROM must have an alias). Es la única forma de agregar sobre un agregado: 47 líneas → 20 pedidos → ticket medio de 36,40 €, con mínimo de 22,60 € y máximo de 66,90 €.
  • Dos derivadas de granularidad distinta resuelven el problema de los portes que arrastrabas desde el módulo 3: 727,95 € de producto + 118,25 € de portes = 846,20 €, sin inflar nada, porque cada SUM se hace antes del JOIN.
  • LATERAL es la excepción que permite correlacionar una tabla derivada, y su caso natural es el top N por grupo: los dos productos más vendidos de cada categoría, con LIMIT aplicado por categoría. En SQL Server se llama CROSS APPLY; en SQLite no existe; y para rankings suele ser mejor una función de ventana (10-03).
  • En WHERE y HAVING vale todo lo de 07-01 y 07-03; en UPDATE, una subconsulta en el SET exige un WHERE que garantice que encuentra algo, o escribirás nulos. Y a partir de tres niveles la legibilidad exige CTE (10-02).

Ya tienes todas las piezas: subconsultas escalares, de lista, correlacionadas, EXISTS, tablas derivadas y LATERAL. Y con ellas, un problema nuevo: casi todas las preguntas admiten ahora dos o tres escrituras distintas, y ninguna te dice cuál elegir. En la última lección del módulo, subconsultas o JOIN: cuál elegir, verás las cuatro grandes equivalencias enfrentadas, la diferencia real entre IN y un INNER JOIN —que no es de estilo, sino de número de filas—, la tabla definitiva de las cuatro formas de responder "qué no casa" y el criterio honesto para decidir: legibilidad primero, rendimiento medido después.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados