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
- Tabla resumen: qué cambia según dónde la pongas
- En el
SELECT: la columna calculada - En el
FROM: la tabla derivada LATERAL: la tabla derivada que sí puede correlacionarse- En el
WHEREy en elHAVING - En
INSERT,UPDATEyDELETE - Tabla de decisión: dado un problema, dónde ponerla
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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 | Sí, 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 |
Sí | 1, o 1 por fila si correlaciona | JOIN si necesitas sus columnas |
HAVING |
Escalar | Sí (por grupo) | 1 | — |
SET de un UPDATE |
Escalar | Sí | 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.
- En el
SELECT: la columna calculada
SELECT: la columna calculadaUna 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.
- En el
FROM: la tabla derivada
FROM: la tabla derivadaUna 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);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 cliente —ped 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.
LATERAL: la tabla derivada que sí puede correlacionarse
LATERAL: la tabla derivada que sí puede correlacionarseYa 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 | Sí |
| Veces que se evalúa | 1 | 1 por fila de la izquierda |
Permite LIMIT por grupo |
No | Sí: es su gran ventaja |
Nota de dialecto:
LATERALes estándar SQL:1999 y funciona en PostgreSQL 9.3+, MySQL 8.0.14+ y Oracle 12c+. En SQL Server el equivalente se llamaCROSS APPLY(yOUTER APPLYpara la versión conNULL), con la misma semántica y sin la palabraLATERAL. 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.
- En el
WHERE y en el HAVING
WHERE y en el HAVINGEs 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.
- En
INSERT, UPDATE y DELETE
INSERT, UPDATE y DELETEEl 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.
- 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 mismoFROMdesde una derivada dainvalid 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_enviodespués de unir conlineas_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 variosLEFT JOINinfla: cada pedido aparece tantas veces como líneas, así queCOUNT(DISTINCT pe.id). - Un
UPDATEcon subconsulta en elSETsinWHEREque la acote. Las filas sin coincidencia recibenNULL: 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
t1yt2: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;- ¿Cuántas ejecuciones de subconsulta supone tal como está, y cuántas si añade las dos columnas que faltan?
- Reescríbelo con un
LEFT JOINyGROUP BY, cuidando el recuento de pedidos. - ¿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 sí 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 elLEFT JOIN+GROUP BY, cuidando elCOUNT(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
SUMse hace antes delJOIN. LATERALes 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, conLIMITaplicado por categoría. En SQL Server se llamaCROSS APPLY; en SQLite no existe; y para rankings suele ser mejor una función de ventana (10-03).- En
WHEREyHAVINGvale todo lo de 07-01 y 07-03; enUPDATE, una subconsulta en elSETexige unWHEREque 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
- ¿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
