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

Subconsultas SQL: subconsultas en WHERE, SELECT y FROM, con ejemplos

Un pequeño panel de salidas dentro de un panel de salidas de aeropuerto más grande

Una subconsulta es un SELECT escrito dentro de otra consulta, entre paréntesis. La consulta exterior usa su resultado como usaría un valor, una lista o una tabla. Eso da tres sitios donde aparecen las subconsultas y tres formas que adoptan:

Además, una subconsulta es independiente (se ejecuta una vez y no le importa la fila exterior) o correlacionada (usa una columna de la consulta exterior y, lógicamente, se vuelve a evaluar para cada fila exterior). En cuanto distingues estos casos, casi cualquier subconsulta se lee sin esfuerzo.

Todos 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, 262.788 reservas, billetes, tramos de vuelo y un programa de fidelización (valores en inglés, dinero en dólares). El SQL usa sintaxis de PostgreSQL y cada resultado de abajo sale de ejecutar la consulta. Puedes pegarlas en la sala de SQL de Nomto y comprobarlo tú mismo.

Subconsulta en WHERE: comparar con un único valor

La primera subconsulta clásica: ¿qué reservas superan el importe medio? No puedes escribir WHERE total_amount > AVG(total_amount), porque los agregados no están permitidos en WHERE. Así que calculas la media en una subconsulta:

SELECT COUNT(*) AS bookings_above_avg
FROM bookings
WHERE total_amount > (SELECT AVG(total_amount) FROM bookings);
bookings_above_avg
87224

La consulta interior devuelve exactamente una fila y una columna: 790,26 dólares. Es una subconsulta escalar, y SQL te deja ponerla en cualquier sitio donde se admita un único valor. La base de datos la ejecuta una vez y compara cada reserva con ese número. Alrededor de un tercio de las 262.788 reservas supera la media, algo normal con importes de dinero: unas pocas reservas grandes tiran de la media hacia arriba.

El mismo patrón encuentra la fila que tiene un valor extremo:

SELECT book_ref, book_date, total_amount
FROM bookings
WHERE total_amount = (SELECT MAX(total_amount) FROM bookings);
book_ref book_date total_amount
3B54BB 2026-07-01 17:08:00 12045.00

Si dos reservas compartieran el máximo, obtendrías las dos. Suele ser lo que quieres, y es la diferencia con ORDER BY total_amount DESC LIMIT 1.

IN: comparar con una lista

Cuando la subconsulta devuelve una columna pero muchas filas, usa IN. ¿Qué modelos de avión salen de Sheremétievo (SVO)?

SELECT aircraft_code, model
FROM aircrafts
WHERE aircraft_code IN (
  SELECT aircraft_code FROM flights WHERE departure_airport = 'SVO'
)
ORDER BY aircraft_code;
aircraft_code model
319 Airbus A319-100
321 Airbus A321-200
733 Boeing 737-300
763 Boeing 767-300
773 Boeing 777-300
CN1 Cessna 208 Caravan
CR2 Bombardier CRJ-200
SU9 Sukhoi Superjet-100

La consulta interior devuelve 2.981 códigos, uno por cada salida desde SVO, con muchísimas repeticiones. A IN le da igual: cada modelo está en la lista o no lo está, así que cada modelo sale una sola vez.

Compáralo con el JOIN que quizá escribirías en su lugar:

SELECT COUNT(*) AS rows_returned, COUNT(DISTINCT a.aircraft_code) AS models
FROM aircrafts a
JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.departure_airport = 'SVO';
rows_returned models
2981 8

El JOIN devuelve una fila por cada vuelo que coincide, así que necesitarías DISTINCT para volver a los ocho modelos. Esa es la principal diferencia práctica entre los dos: una subconsulta en WHERE filtra filas sin multiplicarlas; un JOIN puede multiplicarlas. Cuando solo necesitas saber si existe una coincidencia, una subconsulta (o EXISTS, más abajo) es la herramienta más limpia. La guía de JOIN en SQL explica esta multiplicación de filas en detalle.

Una subconsulta de lista puede llevar su propio GROUP BY y HAVING. ¿Qué ciudades tienen más de un aeropuerto, y cuántos?

SELECT city, COUNT(*) AS airports
FROM airports
WHERE city IN (
  SELECT city FROM airports GROUP BY city HAVING COUNT(*) > 1
)
GROUP BY city
ORDER BY city;
city airports
Moscow 3
Ulyanovsk 2

ANY y ALL

IN es una forma abreviada de = ANY (...). Las formas ANY y ALL también funcionan con >, < y el resto de comparaciones. ¿Qué aviones tienen más autonomía que todos los Airbus de la flota?

SELECT model, range
FROM aircrafts
WHERE range > ALL (SELECT range FROM aircrafts WHERE model LIKE 'Airbus%')
ORDER BY range DESC;
model range
Boeing 777-300 11100
Boeing 767-300 7900

> ALL (...) equivale a > (SELECT MAX(...)), y a casi todo el mundo la versión con MAX le resulta más fácil de leer. ANY y ALL aparecen más en código antiguo y en preguntas de entrevista que en analítica nueva.

EXISTS y NOT EXISTS: ¿hay al menos una fila que coincida?

EXISTS (subconsulta) es verdadero si la subconsulta devuelve al menos una fila. Lo que seleccione la subconsulta no importa, por eso se suele escribir SELECT 1. EXISTS casi siempre es correlacionado: la consulta interior hace referencia a la fila exterior.

¿Cuántos socios del programa de fidelización no han comprado nunca un billete?

SELECT COUNT(*) AS members_without_tickets
FROM loyalty_members m
WHERE NOT EXISTS (
  SELECT 1 FROM ticket_members tm WHERE tm.member_id = m.member_id
);
members_without_tickets
11426

Léela fila a fila: para cada socio m, busca cualquier billete vinculado a m.member_id; conserva al socio si no hay ninguno. De 53.000 socios, 11.426 se han apuntado al programa pero no tienen billetes en estos datos.

EXISTS también resuelve condiciones que están a dos tablas de distancia. ¿Cuántas reservas incluyen al menos un tramo en clase business?

SELECT COUNT(*) AS bookings_with_business
FROM bookings b
WHERE EXISTS (
  SELECT 1
  FROM tickets t
  JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
  WHERE t.book_ref = b.book_ref
    AND tf.fare_conditions = 'Business'
);
bookings_with_business
83730

Una reserva con cinco tramos en business cuenta una sola vez. Escribir lo mismo con un JOIN obligaría a usar COUNT(DISTINCT b.book_ref) para deshacer los duplicados.

La trampa de NOT IN

NOT EXISTS y NOT IN parecen intercambiables, y normalmente dan la misma respuesta. ¿Qué modelo de avión no aparece nunca en la programación?

SELECT aircraft_code
FROM aircrafts
WHERE aircraft_code NOT IN (SELECT aircraft_code FROM flights);
aircraft_code
320

El Airbus A320 está en la flota, pero este verano no vuela. Ha funcionado porque flights.aircraft_code no tiene NULL. Ahora prueba el mismo patrón con una columna que sí los tiene. En app_events, member_id es NULL para los visitantes que no habían iniciado sesión. ¿Qué socios no tienen eventos en la app?

SELECT COUNT(*) AS members_without_events
FROM loyalty_members
WHERE member_id NOT IN (SELECT member_id FROM app_events);
members_without_events
0

Cero, y está mal. La versión con NOT EXISTS da la respuesta real:

SELECT COUNT(*) AS members_without_events
FROM loyalty_members m
WHERE NOT EXISTS (
  SELECT 1 FROM app_events e WHERE e.member_id = m.member_id
);
members_without_events
19376

x NOT IN (lista) significa «x es distinto de todos los elementos». Si la lista contiene un NULL, la comparación x <> NULL es desconocida, así que la condición completa nunca es verdadera y se descartan todas las filas. Aquí la consulta interior devuelve 912.625 NULL. La solución es NOT EXISTS o bien NOT IN (SELECT member_id FROM app_events WHERE member_id IS NOT NULL), que devuelve los mismos 19.376. El hábito que conviene coger: usa NOT EXISTS para «filas sin coincidencia». La guía de NULL en SQL explica la lógica de tres valores que hay detrás.

Subconsultas correlacionadas: comparar cada fila con su propio grupo

Una subconsulta escalar también puede ser correlacionada. ¿Cuántos tramos cuestan más que la media de su propia clase?

SELECT tf.fare_conditions, COUNT(*) AS segments
FROM ticket_flights tf
WHERE tf.amount > (
  SELECT AVG(t2.amount)
  FROM ticket_flights t2
  WHERE t2.fare_conditions = tf.fare_conditions
)
GROUP BY tf.fare_conditions
ORDER BY segments DESC;
fare_conditions segments
Economy 291102
Business 29126
Comfort 6919

La consulta interior usa tf.fare_conditions de la fila exterior, así que la media que devuelve depende de esa fila: 159,60 dólares en Economy, 327,41 en Comfort y 511,43 en Business. Da alias distintos a las dos copias de la tabla (tf y t2); si no, no hay forma de decir a cuál te refieres.

«Se vuelve a evaluar para cada fila» es una descripción lógica: la base de datos no ejecuta literalmente la consulta interior un millón de veces. PostgreSQL y DuckDB reescriben muchas subconsultas correlacionadas como JOIN. Aun así, si una subconsulta correlacionada va lenta, la misma lógica suele quedar más clara con una función de ventana: amount > AVG(amount) OVER (PARTITION BY fare_conditions) dentro de una tabla derivada. Lo tienes en las funciones de ventana en SQL.

Subconsulta en SELECT: un valor por fila

Una subconsulta escalar en la lista del SELECT añade un valor calculado a cada fila de salida. ¿Cuántos asientos tiene cada modelo y cuántos vuelos opera?

SELECT a.model,
       (SELECT COUNT(*) FROM seats s WHERE s.aircraft_code = a.aircraft_code) AS seats,
       (SELECT COUNT(*) FROM flights f WHERE f.aircraft_code = a.aircraft_code) AS flights
FROM aircrafts a
ORDER BY seats DESC;
model seats flights
Boeing 777-300 402 610
Boeing 767-300 222 1221
Airbus A321-200 170 1952
Airbus A320-200 140 0
Boeing 737-300 130 1274
Airbus A319-100 116 1239
Sukhoi Superjet-100 97 8504
Bombardier CRJ-200 50 9048
Cessna 208 Caravan 12 9273

Es una forma limpia de poner lado a lado dos conteos de dos tablas distintas. Unir aircrafts con seats y con flights a la vez y agrupar multiplicaría asientos por vuelos y daría un disparate. Fíjate también en que el A320 recibe un 0 y no desaparece: COUNT(*) sobre cero filas es 0.

Una subconsulta escalar no correlacionada en SELECT viene bien para calcular porcentajes sobre el total:

SELECT model,
       (SELECT COUNT(*) FROM seats s WHERE s.aircraft_code = a.aircraft_code) AS seats,
       ROUND(100.0 * (SELECT COUNT(*) FROM seats s WHERE s.aircraft_code = a.aircraft_code)
             / (SELECT COUNT(*) FROM seats), 1) AS pct_of_all_seats
FROM aircrafts a
ORDER BY seats DESC
LIMIT 4;
model seats pct_of_all_seats
Boeing 777-300 402 30
Boeing 767-300 222 16.6
Airbus A321-200 170 12.7
Airbus A320-200 140 10.5

Funciona, pero ahora el conteo de asientos está escrito dos veces. Cuando ves la misma subconsulta repetida, es el momento de pasarla a una CTE o a una tabla derivada.

Subconsulta en FROM: una tabla derivada

Una subconsulta en FROM devuelve una tabla completa, y la consulta exterior la trata como cualquier otra tabla. Así es como se agrega dos veces: primero por reserva y luego entre reservas. ¿Cuántos billetes tiene una reserva típica?

SELECT tickets_in_booking, COUNT(*) AS bookings
FROM (
  SELECT book_ref, COUNT(*) AS tickets_in_booking
  FROM tickets
  GROUP BY book_ref
) AS per_booking
GROUP BY tickets_in_booking
ORDER BY tickets_in_booking;
tickets_in_booking bookings
1 173390
2 75793
3 12686
4 896
5 23

La consulta interior produce una fila por reserva con su número de billetes. La exterior vuelve a agrupar esas filas. No se puede hacer con un solo GROUP BY, porque COUNT(COUNT(*)) no está permitido. La misma tabla derivada da la media, 1,396 billetes por reserva, con SELECT ROUND(AVG(tickets_in_booking), 3) FROM (...) AS per_booking.

Las tablas derivadas también se combinan con JOIN. Aquí primero se cuentan las salidas por aeropuerto y solo después se une con airports para sacar el nombre de la ciudad:

SELECT a.city, d.departures
FROM (
  SELECT departure_airport, COUNT(*) AS departures
  FROM flights
  WHERE status = 'Arrived'
  GROUP BY departure_airport
) AS d
JOIN airports a ON a.airport_code = d.departure_airport
ORDER BY d.departures DESC
LIMIT 5;
city departures
Moscow 1618
Moscow 1503
St. Petersburg 956
Moscow 869
Novosibirsk 536

Moscú aparece tres veces porque tiene tres aeropuertos (DME, SVO y VKO) y la agrupación se hizo por aeropuerto. Si quieres una fila por ciudad, agrupa por a.city después del JOIN: Moscú suma entonces 3.990 salidas. Ninguna de las dos es incorrecta; la subconsulta deja claro a qué nivel has agregado.

LATERAL: una subconsulta en FROM que ve la fila exterior

Una tabla derivada normal no puede hacer referencia a otras tablas del mismo FROM. LATERAL quita esa restricción, y con eso el «top N por grupo» se expresa con facilidad. Los dos destinos más frecuentes desde SVO y LED:

SELECT ap.airport_code, ap.city, top.arrival_airport, top.flights
FROM airports ap
CROSS JOIN LATERAL (
  SELECT f.arrival_airport, COUNT(*) AS flights
  FROM flights f
  WHERE f.departure_airport = ap.airport_code
  GROUP BY f.arrival_airport
  ORDER BY flights DESC, f.arrival_airport
  LIMIT 2
) AS top
WHERE ap.airport_code IN ('SVO', 'LED')
ORDER BY ap.airport_code, top.flights DESC;
airport_code city arrival_airport flights
LED St. Petersburg SVO 305
LED St. Petersburg DME 244
SVO Moscow LED 305
SVO Moscow BZK 183

LATERAL funciona en PostgreSQL, DuckDB y MySQL 8; en SQL Server se escribe CROSS APPLY. Para el top N, la mayoría de analistas usa ROW_NUMBER(), pero conviene reconocer LATERAL cuando lo veas.

¿Subconsulta, JOIN o CTE?

Necesitas Lo habitual es
Filtrar por «tiene coincidencia» / «no tiene coincidencia» EXISTS / NOT EXISTS
Filtrar por una lista corta o por un único valor calculado IN / subconsulta escalar en WHERE
Columnas de otra tabla en el resultado JOIN
Agregar y luego volver a agregar tabla derivada o CTE
El mismo resultado intermedio usado dos veces, o más de dos niveles de anidación CTE
Un valor por fila sacado de otra tabla, sin multiplicar filas subconsulta escalar en SELECT o un JOIN con la tabla ya agregada

El rendimiento rara vez es lo que decide: los optimizadores modernos convierten IN, EXISTS y muchas subconsultas correlacionadas en JOIN internamente. Elige la forma que plantea la pregunta de la manera más directa y mira el plan de ejecución si va lenta.

Errores frecuentes

1. Una subconsulta escalar que devuelve más de una fila

SELECT (SELECT aircraft_code FROM flights WHERE departure_airport = 'SVO') AS x;

PostgreSQL se detiene con «more than one row returned by a subquery used as an expression», y DuckDB con un mensaje parecido. Una subconsulta usada como valor único debe devolver como mucho una fila: agrégala (MAX, COUNT), fíltrala hasta dejar una fila o cambia a IN si en realidad querías una lista. Si no devuelve ninguna fila, el valor es NULL, lo que puede vaciar un WHERE sin avisar.

2. NOT IN sobre una columna con NULL

Lo has visto arriba: un solo NULL en la lista hace que NOT IN no devuelva nada. Usa NOT EXISTS.

3. Una columna sin cualificar que acaba refiriéndose a la consulta exterior

SELECT COUNT(*) AS models
FROM aircrafts
WHERE aircraft_code IN (SELECT aircraft_code FROM airports);
models
9

airports no tiene columna aircraft_code y, aun así, la consulta se ejecuta y devuelve los nueve modelos. SQL buscó aircraft_code en la tabla interior, no la encontró y la tomó de la fila exterior de aircrafts, así que la condición se convirtió en «aircraft_code es igual a sí mismo». Cualifica siempre las columnas con alias de tabla dentro de las subconsultas: así una errata se convierte en un error en lugar de en una respuesta equivocada.

4. Una tabla derivada sin alias

SELECT COUNT(*) FROM (SELECT 1 FROM aircrafts) funciona en DuckDB y en PostgreSQL 16 o posterior. PostgreSQL 15 y anteriores, y otras bases de datos, la rechazan con «subquery in FROM must have an alias». Escribir AS t no cuesta nada y funciona en todas partes.

5. ORDER BY dentro de una subconsulta

Un ORDER BY dentro de una tabla derivada no garantiza el orden del resultado final. Ordena en la consulta más exterior. Dentro de una subconsulta, ORDER BY solo importa junto con LIMIT, como en el ejemplo de LATERAL.

Preguntas frecuentes

¿Qué es una subconsulta en SQL?

Una sentencia SELECT anidada dentro de otra, entre paréntesis. La consulta exterior usa su resultado como un único valor, como una lista de valores o como una tabla, según dónde esté la subconsulta.

¿Qué diferencia hay entre una subconsulta correlacionada y una no correlacionada?

Una subconsulta no correlacionada no hace referencia a la consulta exterior y se puede ejecutar sola; se evalúa una vez. Una correlacionada usa una columna de la consulta exterior (WHERE t2.fare_conditions = tf.fare_conditions), así que su resultado depende de la fila exterior actual.

¿Una subconsulta es más lenta que un JOIN?

No por naturaleza. PostgreSQL, DuckDB y otros motores modernos reescriben la mayoría de subconsultas con IN y EXISTS como JOIN. Escribe la versión que mejor exprese la pregunta y mira el plan de ejecución solo si de verdad va lenta.

¿Cuándo usar EXISTS en lugar de IN?

Usa EXISTS cuando la condición depende de la fila exterior o afecta a varias columnas, y prefiere siempre NOT EXISTS a NOT IN para «filas sin coincidencia», porque NOT IN falla cuando la lista contiene un NULL.

¿Una subconsulta puede devolver varias columnas?

En FROM, sí: una tabla derivada puede tener todas las columnas que quieras. En WHERE con IN, una subconsulta normalmente devuelve una columna, aunque PostgreSQL también admite comparaciones de filas como (a, b) IN (SELECT x, y ...). En SELECT, la subconsulta tiene que devolver un único valor.

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