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

Subconsultas escalares y multivalor

1. Qué es

Una subconsulta es un SELECT dentro de otro SELECT. Se escribe entre paréntesis y el motor la resuelve primero.

Lo que decide cómo puedes usarla es qué forma tiene lo que devuelve:

Forma Qué devuelve Dónde va
Escalar 1 fila, 1 columna Donde quepa un valor: SELECT, WHERE, ORDER BY
Multivalor N filas, 1 columna Con IN, ANY, ALL
Tabla N filas, N columnas En el FROM → tablas-derivadas

Este tema son las dos primeras. Y las dos existen para lo mismo: comparar una fila contra algo que hay que calcular antes — la media, el máximo, una lista.

2. Subconsulta escalar

Devuelve exactamente un valor. Se comporta como si escribieras el número a mano.

SELECT product_name, unit_price,
       (SELECT round(avg(unit_price), 2) FROM products) AS media
FROM products
ORDER BY unit_price DESC
LIMIT 3;
      product_name       | unit_price | media
-------------------------+------------+-------
 Côte de Blaye           |     263.50 | 28.83
 Thüringer Rostbratwurst |     123.79 | 28.83
 Mishi Kobe Niku         |      97.00 | 28.83

El mismo valor en todas las filas, porque la subconsulta no depende de la fila: se calcula una vez para toda la consulta. (La que sí depende de la fila es la correlacionada, tema siguiente.)

Y lo que hace útil eso: comparar cada fila contra el agregado, que sin subconsulta es imposible en una sola pasada.

SELECT product_name, unit_price,
       round(unit_price - (SELECT avg(unit_price) FROM products), 2) AS vs_media
FROM products
ORDER BY vs_media DESC LIMIT 3;
      product_name       | unit_price | vs_media
-------------------------+------------+----------
 Côte de Blaye           |     263.50 |   234.67
 Thüringer Rostbratwurst |     123.79 |    94.96
 Mishi Kobe Niku         |      97.00 |    68.17

En el WHERE:

SELECT count(*) FROM products WHERE unit_price > (SELECT avg(unit_price) FROM products);   -- 25

25 productos de 77 están por encima de la media. No 38: la media está arrastrada hacia arriba por los caros (funciones-de-agregacion).

Varias en el mismo SELECT, sin FROM:

SELECT (SELECT count(*) FROM orders)    AS pedidos,
       (SELECT count(*) FROM customers) AS clientes,
       (SELECT count(*) FROM products)  AS productos;
 pedidos | clientes | productos
---------+----------+-----------
     830 |       91 |        77

Una fila de resumen a partir de tres tablas distintas, sin ningún JOIN. Es el patrón de las tarjetas de un cuadro de mando.

2.1 Las dos formas de romperla

Devuelve más de una fila → error:

SELECT product_name FROM products
WHERE unit_price > (SELECT unit_price FROM products WHERE category_id = 1);
ERROR:  more than one row returned by a subquery used as an expression

Este error es una suerte. Aparece cuando los datos crecen: la consulta funcionó en pruebas con una fila y revienta en producción con dos. Si esperas una sola fila, garantízalo con un LIMIT 1 y un ORDER BY que decida cuál, o con una agregación (max, min) que por definición devuelve una.

Devuelve cero filas → NULL, sin error:

SELECT product_name,
       (SELECT max(unit_price) FROM products WHERE category_id = 99) AS inexistente
FROM products LIMIT 2;
 product_name | inexistente
--------------+-------------
 Chai         |
 Chang        |

Este es el peligroso. Una subconsulta vacía no protesta: devuelve NULL, y ese NULL se propaga por toda la expresión (null-y-la-logica-de-tres-valores). Si va en un WHERE, la condición nunca se cumple y la consulta devuelve cero filas.

3. Subconsulta multivalor: IN, ANY, ALL

Devuelve una columna con varias filas. Hace falta un operador que sepa comparar contra un conjunto.

Operador Significa
x IN (subconsulta) x es igual a alguno
x = ANY (...) Lo mismo que IN
x > ANY (...) Mayor que al menos uno → mayor que el mínimo
x > ALL (...) Mayor que todos → mayor que el máximo
x <> ALL (...) Distinto de todos → lo mismo que NOT IN

IN con subconsulta:

SELECT count(*) FROM products
WHERE category_id IN (SELECT category_id FROM categories
                      WHERE category_name IN ('Beverages', 'Seafood'));
 count
-------
    24

Fíjate en los dos IN distintos: el de fuera con subconsulta, el de dentro con lista literal. Los dos son válidos y hacen cosas distintas.

ANY contra ALL:

SELECT count(*) FROM products WHERE unit_price > ANY (SELECT unit_price FROM products WHERE category_id = 1);  -- 75
SELECT count(*) FROM products WHERE unit_price > ALL (SELECT unit_price FROM products WHERE category_id = 1);  --  0

75 contra 0.

  • > ANY = más caro que el más barato de las bebidas. Casi todos lo son.
  • > ALL = más caro que el más caro de las bebidas, que es Côte de Blaye a 263,50 — el producto más caro de toda la base. Nadie lo supera.

En la práctica casi siempre es más claro escribir el agregado: > (SELECT max(unit_price) ...) dice lo mismo que > ALL y se lee sin pensar. ANY y ALL se conocen sobre todo para leer código ajeno.

3.1 La trampa: ALL sobre un conjunto vacío

SELECT count(*) FROM products
WHERE unit_price > ALL (SELECT unit_price FROM products WHERE product_id > 900);
 count
-------
    77

Los 77. No hay ningún producto con id mayor que 900, así que la condición "mayor que todos los de un conjunto vacío" es cierta por vacuidad: no hay ninguno que la incumpla.

Es lógicamente correcto y prácticamente venenoso: un filtro que se supone restrictivo deja pasar la tabla entera el día que la subconsulta se queda sin filas.

3.2 La otra trampa: ALL con NULL

SELECT count(*) FROM products WHERE unit_price > ALL (SELECT unit_price FROM products WHERE category_id = 6);                       -- 1
SELECT count(*) FROM products WHERE unit_price > ALL (SELECT unit_price FROM products WHERE category_id = 6 UNION ALL SELECT NULL); -- 0

1 contra 0. El máximo de la categoría 6 es 123,79, así que solo Côte de Blaye (263,50) lo supera. Añadir un solo NULL al conjunto lo borra: 263.50 > NULL es UNKNOWN, y ALL exige que todas las comparaciones sean ciertas.

Y <> ALL es literalmente NOT IN, con la misma trampa:

SELECT count(*) FROM employees WHERE employee_id <> ALL (SELECT reports_to FROM employees);                        -- 0  ❌
SELECT count(*) FROM employees WHERE employee_id <> ALL (SELECT reports_to FROM employees WHERE reports_to IS NOT NULL);  -- 7  ✅

Los mismos 0 contra 7 del tema anti-join-y-semi-join, con otra sintaxis. La conclusión no cambia: para negar, NOT EXISTS.

4. Cuándo subconsulta y cuándo JOIN

Situación Mejor
Comparar contra un agregado global Subconsulta escalar
Necesitas columnas de la otra tabla JOIN
Solo quieres filtrar por existencia EXISTS
Un valor de resumen en cada fila Subconsulta escalar o función de ventana
Filtrar por una lista de otra tabla IN o EXISTS — equivalentes

El criterio no es el rendimiento —PostgreSQL reescribe casi todo esto internamente— sino cuál se lee mejor. Un WHERE unit_price > (SELECT avg(unit_price) FROM products) dice exactamente lo que hace; la versión con JOIN contra una subconsulta agregada dice lo mismo con el triple de ruido.

5. Errores comunes

  1. Subconsulta escalar que devuelve más de una fila. Error en tiempo de ejecución, a veces solo cuando crecen los datos.
  2. Subconsulta escalar vacía. NULL silencioso que anula la expresión o vacía el WHERE.
  3. NOT IN / <> ALL sobre una columna con nulos. Cero filas.
  4. ALL sobre un conjunto vacío. Cierto para todo.
  5. Usar subconsulta cuando necesitas columnas de la otra tabla. Acabas escribiendo cinco subconsultas escalares donde cabía un JOIN.
  6. Repetir la misma subconsulta escalar varias veces. Se calcula una vez, así que no es un problema de rendimiento — pero sí de mantenimiento: el día que cambia el criterio hay que tocarla en cinco sitios. Ahí es donde entra un CTE (cte-with).
  7. Olvidar los paréntesis. Error de sintaxis, este avisa.

6. Diferencias con SQL Server

PostgreSQL SQL Server
Subconsulta escalar ✅ ✅ Idéntico
IN / ANY / ALL ✅ ✅ Idéntico
Subconsulta escalar vacía → NULL ✅ ✅
Más de una fila → error ✅ ✅ (Subquery returned more than 1 value)
SOME como sinónimo de ANY ✅ ✅

Este tema es completamente portable, trampas incluidas. ALL sobre vacío es cierto en los dos, y NOT IN con nulos falla igual en los dos: es el estándar SQL, no una decisión de motor.

7. Ejercicios

  1. Los productos que cuestan más que la media de su tabla, con la diferencia respecto a esa media.
  2. El pedido con el flete más alto, mostrando id, cliente y flete.
  3. Los clientes que están en alguno de los países donde también hay proveedores.
  4. Los productos más caros que el más caro de la categoría Seafood.
  5. Explica por qué esta consulta devuelve todas las filas y cómo la arreglarías: sql SELECT count(*) FROM orders WHERE freight > ALL (SELECT freight FROM orders WHERE ship_country = 'Narnia');

8. Pregunta de negocio

Dirección quiere una ficha de una línea por producto para la reunión: nombre, precio, cuánto se aleja del precio medio de la empresa, y qué porcentaje de la facturación total representa. Solo los productos que estén por encima de la media.

? RespuestaRespóndela antes de abrir
SELECT p.product_name,
       p.unit_price,
       round(p.unit_price - (SELECT avg(unit_price) FROM products), 2) AS sobre_la_media,
       round(100.0 * (SELECT coalesce(sum(od.unit_price * od.quantity * (1 - od.discount)), 0)
                      FROM order_details od
                      WHERE od.product_id = p.product_id)
                   / (SELECT sum(unit_price * quantity * (1 - discount)) FROM order_details), 2)
           AS pct_facturacion
FROM products p
WHERE p.unit_price > (SELECT avg(unit_price) FROM products)
ORDER BY pct_facturacion DESC;
      product_name       | unit_price | sobre_la_media | pct_facturacion
-------------------------+------------+----------------+-----------------
 Côte de Blaye           |     263.50 |         234.67 |           11.17
 Thüringer Rostbratwurst |     123.79 |          94.96 |            6.35
 Raclette Courdavault    |      55.00 |          26.17 |            5.62
 Tarte au sucre          |      49.30 |          20.47 |            3.73

25 filas. Tres cosas del diseño:

  1. Hay tres tipos de subconsulta en juego y conviene distinguirlos. (SELECT avg(unit_price) FROM products) y el denominador de la facturación son escalares globales: se calculan una vez. La del numerador es correlacionada —lleva WHERE od.product_id = p.product_id— y se evalúa por fila. Es el tema siguiente, subconsultas-correlacionadas, y es donde esta consulta empieza a ser cara.
  2. El coalesce del numerador no es decorativo. Un producto que no se hubiera vendido nunca daría NULL, y ese NULL convertiría todo el porcentaje en NULL — apareciendo arriba del todo en un ORDER BY ... DESC. Aquí no pasa (los 77 se han vendido), pero la consulta tiene que aguantar el día que entre un producto nuevo.
  3. Côte de Blaye es el 11,17% de la facturación de la empresa. Ese es el dato que justifica el informe: un solo producto, de 77, concentra una novena parte del negocio. Y también es el que arrastraba la media de precios en el tema 18 — el mismo valor extremo apareciendo por tercera vez.

Y lo que hay que decir al entregar: el filtro "por encima de la media" deja fuera productos que facturan mucho siendo baratos. La lista responde literalmente a lo que pidieron, pero si la pregunta real es "qué productos importan", el criterio debería ser la facturación, no el precio.

✓ SolucionesResuélvelos antes de abrir

1.

SELECT product_name, unit_price,
       round(unit_price - (SELECT avg(unit_price) FROM products), 2) AS sobre_la_media
FROM products
WHERE unit_price > (SELECT avg(unit_price) FROM products)
ORDER BY sobre_la_media DESC;

25 productos. La subconsulta aparece dos veces y se calcula una sola vez: PostgreSQL detecta que no depende de la fila. El problema de escribirla dos veces no es de velocidad, es que hay que cambiarla en dos sitios si mañana la media pasa a ser solo de productos activos.

2.

SELECT o.order_id, c.company_name, o.freight
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.freight = (SELECT max(freight) FROM orders);
 order_id | company_name | freight
----------+--------------+---------
    10540 | QUICK-Stop   | 1007.64

Aquí conviven las dos herramientas bien usadas: subconsulta para el máximo (un valor calculado), JOIN para el nombre del cliente (una columna de otra tabla). Es exactamente el criterio de la sección 4.

Ojo con una alternativa tentadora: ORDER BY freight DESC LIMIT 1 da lo mismo pero se comporta distinto en un empate — devuelve una fila arbitraria, mientras que la subconsulta devuelve todas las empatadas (ordenar-y-limitar).

3.

SELECT company_name, country
FROM customers
WHERE country IN (SELECT country FROM suppliers)
ORDER BY country, company_name;

69 clientes en los 12 países que también tienen proveedores. Con EXISTS sería equivalente; con INTERSECT no, porque INTERSECT solo devolvería la columna country (union-intersect-except).

4.

SELECT product_name, unit_price
FROM products
WHERE unit_price > (SELECT max(p.unit_price)
                    FROM products p
                    JOIN categories c ON c.category_id = p.category_id
                    WHERE c.category_name = 'Seafood')
ORDER BY unit_price DESC;
      product_name       | unit_price
-------------------------+------------
 Côte de Blaye           |     263.50
 Thüringer Rostbratwurst |     123.79
 Mishi Kobe Niku         |      97.00
 Sir Rodney's Marmalade  |      81.00

4 productos superan los 62,50 de Carnarvon Tigers. Escrito con > ALL daría lo mismo; con max() se entiende sin releerlo.

5. No hay ningún pedido a Narnia, así que la subconsulta devuelve cero filas y > ALL sobre un conjunto vacío es cierto para todo: salen los 830.

El arreglo depende de lo que quisieras:

-- Si querías "cero cuando no hay referencia":
SELECT count(*) FROM orders
WHERE freight > (SELECT max(freight) FROM orders WHERE ship_country = 'Narnia');

Devuelve 0, porque max() sobre vacío es NULL y freight > NULL es UNKNOWN para todas las filas.

-- Si querías que el filtro simplemente no aplique cuando no hay referencia:
SELECT count(*) FROM orders
WHERE freight > coalesce((SELECT max(freight) FROM orders WHERE ship_country = 'Narnia'), 0);

Lo importante del ejercicio: > ALL sobre vacío da 830 y > max() sobre vacío da 0. Dos respuestas opuestas a la misma pregunta mal planteada. Cuando una subconsulta puede quedarse vacía, hay que decidir explícitamente qué pasa entonces — no dejar que lo decida la lógica de tres valores.