# Pensar la consulta en pisos

URL canónica: https://missyera.com/guias/sql-desde-cero/subconsultas-y-ctes/
Última actualización: 2026-11-14
Autora: Gera Flores (Miss Yera)

> **Respuesta corta**
>
> Una subconsulta es una consulta dentro de otra, y un CTE es esa misma subconsulta con nombre, escrita arriba con WITH para que se lea de arriba abajo. WITH está en los cuatro motores y se escribe igual, y yo lo prefiero casi siempre porque se lee. Y hay una que tienes que saber sí o sí: NOT IN devuelve cero filas si la lista trae un solo NULL, sin dar error. Por eso se usa NOT EXISTS.

Capítulo 8 de 23 del libro [SQL desde cero](https://missyera.com/guias/sql-desde-cero.md).

Con lo de los siete capítulos anteriores ya puedes contestar casi cualquier pregunta de negocio. El problema empieza cuando la pregunta tiene dos pisos.

"¿Cuánto gasta en promedio un cliente de cada ciudad?" no es un `AVG`. Es dos cuentas encadenadas: primero cuánto gastó cada cliente, y sobre *eso*, el promedio por ciudad. Y para eso necesitas meter una consulta dentro de otra.

Este capítulo va de las dos formas de hacerlo, y de por qué una de ellas te va a cambiar la vida cuando la consulta pase de veinte líneas 🌟

Y hazte esta pregunta cuando escribas la próxima consulta larga: **¿esto lo va a entender alguien dentro de seis meses?** Ese alguien casi siempre eres tú, y por eso yo escribo en pisos aunque nadie más lea la consulta 🪜

## Una consulta dentro de otra

La más simple: una subconsulta que devuelve **un solo valor** y que usas como si fuera un número escrito a mano.

```
SELECT ROUND(AVG(monto), 2) AS el_promedio FROM pedidos;
```

```text
el_promedio
-----------
610.14
```

```
SELECT COUNT(*) AS por_encima_del_promedio
FROM pedidos
WHERE monto > (SELECT AVG(monto) FROM pedidos);
```

```text
por_encima_del_promedio
-----------------------
421
```

Eso de dentro del paréntesis se calcula primero, da 610,14, y la consulta de fuera lo usa como si hubieras escrito `WHERE monto > 610.14`.

La ventaja no es que sea más corto. Es que **no hay que actualizarlo nunca**: mañana entran cien pedidos más, el promedio cambia solo y la consulta sigue siendo correcta. Un número escrito a mano se pudre; una subconsulta no.

Y fíjate en el 421 de 873 pedidos con monto: casi la mitad está por encima del promedio. Eso pasa cuando los datos están repartidos parejos. Si te sale que solo el 5% supera el promedio, ahí hay unos poquitos pedidos gigantes tirando de la media, y entonces la media no era la medida que buscabas.

## Subconsultas que devuelven una lista

```
SELECT c.nombre, c.ciudad
FROM clientes c
WHERE c.id IN (SELECT id_cliente FROM pedidos WHERE monto > 1300)
ORDER BY c.id;
```

```text
nombre                   ciudad
-----------------------  --------
Autoservicio Norte 007   Chiclayo
Bodega San Martin 030    Arequipa
Cafe del Puerto 060      Arequipa
Market Central 064       Chiclayo
Market Central 099       Chiclayo
  COMERCIAL ROJAS 102    Piura
Bodega San Martin 103    Arequipa
```

Aquí la subconsulta devuelve muchas filas, y `IN` pregunta "¿este `id` está en esa lista?". Siete clientes hicieron alguna vez un pedido de más de S/1.300.

Se lee de adentro hacia afuera y es de las cosas que más rápido se vuelven naturales 💛

## La trampa del NOT IN, que es de las peores del libro

Ya sabemos del capítulo 7 que hay exactamente un cliente que nunca compró. Vamos a buscarlo con `NOT IN`, que es lo que sale solo.

```
SELECT COUNT(*) AS nunca_compraron
FROM clientes
WHERE id NOT IN (SELECT id_cliente FROM pedidos);
```

```text
nunca_compraron
---------------
0
```

Cero. Y sabemos que es uno 😳

El culpable es el `NULL`, otra vez. La tabla `pedidos` tiene 24 filas con `id_cliente` nulo, así que la lista que devuelve la subconsulta contiene nulos. Y `id NOT IN (5, 8, NULL)` significa "`id <> 5` Y `id <> 8` Y `id <> NULL`". Esa última no es ni verdadera ni falsa, es desconocida, y una condición desconocida hace que toda la fila se caiga.

Resultado: **con un solo `NULL` en la lista, `NOT IN` no devuelve nunca ninguna fila**. Y no da error. Te entrega un cero limpio que parece una buena noticia.

Esto es igual en los cuatro motores, que para variar se ponen de acuerdo en lo peor. Hay dos arreglos.

```
SELECT COUNT(*) AS nunca_compraron
FROM clientes
WHERE id NOT IN (SELECT id_cliente FROM pedidos WHERE id_cliente IS NOT NULL);
```

```text
nunca_compraron
---------------
1
```

```
SELECT COUNT(*) AS nunca_compraron
FROM clientes c
WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.id_cliente = c.id);
```

```text
nunca_compraron
---------------
1
```

El primero limpia la lista a mano. El segundo usa `NOT EXISTS`, que **no tiene este problema nunca** porque no compara valores: pregunta "¿existe al menos una fila que cumpla esto?" y a eso el `NULL` no le hace nada.

Mi regla, y te la regalo: **usa `NOT EXISTS` siempre y olvídate de `NOT IN`.** Es igual de legible, es igual o más rápido en los cuatro motores, y no te va a mentir un martes por la tarde.

Ese `SELECT 1` de dentro llama la atención y es a propósito: a `EXISTS` no le importa qué devuelvas, solo si devuelve algo. Puedes poner `SELECT 1`, `SELECT *` o `SELECT 'pollito'` y da exactamente lo mismo 🐣

## Una subconsulta en el FROM

Volvamos a la pregunta del principio: cuánto gasta en promedio un cliente de cada ciudad.

```
SELECT ciudad, ROUND(AVG(soles), 2) AS gasto_medio_por_cliente
FROM (
    SELECT c.id, c.ciudad, SUM(p.monto) AS soles
    FROM clientes c
    JOIN pedidos p ON p.id_cliente = c.id
    GROUP BY c.id, c.ciudad
) AS por_cliente
GROUP BY ciudad
ORDER BY gasto_medio_por_cliente DESC;
```

```text
ciudad    gasto_medio_por_cliente
--------  -----------------------
Chiclayo  4639.18
Arequipa  4610.57
Piura     4465.22
Lima      4249.9
Cusco     4060.39
Trujillo  3773.57
```

La de dentro arma una tabla temporal con una fila por cliente y su total. La de fuera la trata como si fuera una tabla normal y le hace el promedio por ciudad. Eso se llama **tabla derivada**.

Y mira qué distinto se ve el negocio así: por ticket promedio (capítulo 7) mandaba Piura; por gasto anual de cada cliente manda Chiclayo. Son dos preguntas distintas y dan dos respuestas distintas, las dos correctas 📊

El alias de la tabla derivada

| PostgreSQL | `obligatorio: sin él, "subquery in FROM must have an alias"` |
|---|---|
| MySQL | `obligatorio: "Every derived table must have its own alias"` |
| SQL Server | `obligatorio` |
| SQLite | `opcional, funciona igual sin ponerlo` |

Tres de cuatro te obligan, así que ponle alias siempre aunque estés en SQLite. Es una línea de tres letras y te ahorra que la consulta no arranque el día que la muevas.

![Una pregunta de dos pisos se puede escribir anidada, que se lee de dentro hacia afuera, o con WITH dándole nombre a cada piso, que se lee de arriba abajo.](https://missyera.com/static/diagramas/webp/sql-05-pisos.webp)

*Las dos devuelven lo mismo. La diferencia es quién la lee dentro de seis meses, que casi siempre eres tú.*

## WITH, o cómo escribir de arriba abajo

La consulta de arriba funciona, pero se lee al revés: lo primero que pasa está en el medio, entre paréntesis. Con dos pisos se aguanta; con cuatro es ilegible.

`WITH` arregla eso. Es la misma consulta, dada vuelta.

```
WITH por_cliente AS (
    SELECT c.id, c.ciudad, c.segmento, SUM(p.monto) AS soles, COUNT(p.id) AS pedidos
    FROM clientes c
    JOIN pedidos p ON p.id_cliente = c.id
    GROUP BY c.id, c.ciudad, c.segmento
)
SELECT ciudad, COUNT(*) AS clientes, ROUND(AVG(soles), 2) AS gasto_medio
FROM por_cliente
GROUP BY ciudad
ORDER BY gasto_medio DESC;
```

```text
ciudad    clientes  gasto_medio
--------  --------  -----------
Chiclayo  25        4639.18
Arequipa  22        4610.57
Piura     24        4465.22
Lima      15        4249.9
Cusco     16        4060.39
Trujillo  17        3773.57
```

Mismos números, y ahora se lee como se piensa: primero calculo el gasto de cada cliente y le pongo nombre, después uso ese nombre.

Eso se llama **CTE** (common table expression) y en cristiano es "una tabla temporal con nombre, que vive solo mientras dura la consulta". Es lo que más me cambió la forma de escribir SQL, y por eso lo pongo tan pronto en el libro.

Y ojo a un detalle de la salida: Cusco dice 16 clientes y en el capítulo 6 dijimos 17. No es un error: aquí hay un `JOIN` normal, así que Comercial Rojas 120 no entra porque nunca compró. Cuando un número no cuadra con otro capítulo, casi siempre es que la pregunta no era la misma 🔍

Los CTE se encadenan, y ahí es donde brilla.

```
WITH por_cliente AS (
    SELECT c.id, c.nombre, SUM(p.monto) AS soles
    FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
    GROUP BY c.id, c.nombre
),
promedio AS (
    SELECT AVG(soles) AS media FROM por_cliente
)
SELECT COUNT(*) AS por_encima
FROM por_cliente, promedio
WHERE por_cliente.soles > promedio.media;
```

```text
por_encima
----------
58
```

Dos CTE, y el segundo usa al primero. 58 de los 119 clientes que compraron están por encima del gasto medio.

Fíjate en la coma del `FROM por_cliente, promedio`: es un `CROSS JOIN` del capítulo 7, y aquí es correcto justamente porque `promedio` tiene una sola fila. Multiplicar por uno no multiplica nada.

Desde cuándo hay WITH

| PostgreSQL | `desde la 8.4, año 2009` |
|---|---|
| MySQL | `desde la 8.0, año 2018` |
| SQL Server | `desde 2005` |
| SQLite | `desde la 3.8.3, año 2014` |

Se escribe idéntico en los cuatro, que en este libro es casi una fiesta. La única pega es MySQL 5.7, que todavía se ve en empresas y no lo tiene: ahí toca tabla derivada.

## Una subconsulta en el SELECT

```
SELECT c.nombre,
       (SELECT COUNT(*) FROM pedidos p WHERE p.id_cliente = c.id) AS pedidos
FROM clientes c
ORDER BY pedidos DESC
LIMIT 3;
```

```text
nombre                  pedidos
----------------------  -------
Distribuidora Paz 058   17
Autoservicio Norte 008  14
Bodega San Martin 048   14
```

Esta se llama **correlacionada** porque la de dentro mira a la de fuera: ese `c.id` cambia en cada fila. O sea que la subconsulta se ejecuta una vez por cliente, 120 veces.

Con 120 filas ni lo notas. Con dos millones, esa misma consulta se cuelga y el `JOIN` con `GROUP BY` del capítulo 7 hace lo mismo en un segundo. **Cuando una subconsulta en el SELECT se puede escribir como JOIN, escríbela como JOIN.**

Y ahora el error que tiene que salir sí o sí.

```
SELECT (SELECT id, nombre FROM clientes LIMIT 1) AS x;
```

```
OperationalError: sub-select returns 2 columns - expected 1
```

*sub-select returns 2 columns - expected 1*. Una subconsulta que va en el `SELECT` o al lado de un `=` tiene que devolver **exactamente una columna**, y como mucho una fila. Es de los pocos sitios donde SQLite se pone estricto, y menos mal 🙌

## Apilar resultados: UNION

Los `JOIN` pegan tablas de lado. `UNION` las apila una debajo de otra.

```
SELECT id_cliente FROM pedidos WHERE canal = 'Web' AND id_cliente IS NOT NULL
EXCEPT
SELECT id_cliente FROM pedidos WHERE canal = 'WhatsApp' AND id_cliente IS NOT NULL
ORDER BY id_cliente
LIMIT 5;
```

```text
id_cliente
----------
3
4
16
24
25
```

Ese `EXCEPT` es "lo del primero que no esté en el segundo": los clientes que compran por Web y nunca por WhatsApp. Son 16 en total, y esa es una lista concreta para el equipo que quiere mover gente al canal barato 📞

Las cuatro operaciones de conjuntos son:

- ➕ `UNION`: apila y **quita duplicados**.
- ➕ `UNION ALL`: apila y no quita nada. Es más rápida, y es la que quieres cuando sabes que no hay repetidos.
- ✖️ `INTERSECT`: solo lo que está en las dos.
- ➖ `EXCEPT`: lo del primero que no esté en el segundo.

Las cuatro piden que las dos consultas tengan **el mismo número de columnas y en el mismo orden**. Los nombres no importan: manda la primera.

INTERSECT y EXCEPT

| PostgreSQL | `INTERSECT y EXCEPT` |
|---|---|
| MySQL | `solo desde la 8.0.31, año 2022; antes había que armarlo a mano` |
| SQL Server | `INTERSECT y EXCEPT` |
| SQLite | `INTERSECT y EXCEPT` |

MySQL llegó tardísimo a estos dos, así que en cualquier MySQL que no sea de los últimos años hay que hacer el EXCEPT con un LEFT JOIN ... IS NULL del capítulo 7, y el INTERSECT con un INNER JOIN.

## El CTE recursivo, que parece magia

Un CTE puede llamarse a sí mismo. Suena raro y sirve para dos cosas muy concretas: recorrer jerarquías (el jefe del jefe del jefe) y **fabricar listas que no existen en ninguna tabla**, que es la que vas a usar tú.

```
WITH RECURSIVE meses(mes) AS (
    SELECT '2026-01'
    UNION ALL
    SELECT STRFTIME('%Y-%m', DATE(mes || '-01', '+1 month'))
    FROM meses
    WHERE mes < '2026-06'
)
SELECT mes FROM meses;
```

```text
mes
-------
2026-01
2026-02
2026-03
2026-04
2026-05
2026-06
```

Se lee en tres partes:

1. 1️⃣ **El primer escalón**, `SELECT '2026-01'`. De dónde arranca.
2. 2️⃣ **El escalón siguiente**, que se calcula a partir del anterior. Aquí, sumarle un mes.
3. 3️⃣ **Cuándo parar**, ese `WHERE mes < '2026-06'`. Si te lo olvidas, la consulta no termina nunca.

¿Para qué quieres una lista de meses? Para el problema más común de todo reporte: **los meses sin ventas no salen en un GROUP BY**, porque no hay filas que agrupar. Con esta lista y un `LEFT JOIN` del capítulo 7, salen con cero.

```
WITH meses(mes) AS (
    SELECT '2026-01' UNION ALL SELECT '2026-02' UNION ALL SELECT '2026-03'
)
SELECT m.mes, COUNT(p.id) AS pedidos
FROM meses m
LEFT JOIN pedidos p ON STRFTIME('%Y-%m', p.fecha) = m.mes
GROUP BY m.mes
ORDER BY m.mes;
```

```text
mes      pedidos
-------  -------
2026-01  51
2026-02  40
2026-03  48
```

Aquí los tres meses tienen pedidos, así que el ejemplo se ve tonto. El día que uno tenga cero, tu gráfico va a mostrar el hueco en vez de saltárselo, que es la diferencia entre un reporte honesto y uno que disimula 🌟

La palabra RECURSIVE

| PostgreSQL | `WITH RECURSIVE ... obligatoria` |
|---|---|
| MySQL | `WITH RECURSIVE ... obligatoria` |
| SQL Server | `WITH ... sin la palabra RECURSIVE, que no existe` |
| SQLite | `WITH RECURSIVE ... obligatoria` |

Tres la piden y SQL Server la prohíbe, así que es una de las poquísimas líneas que hay que cambiar sí o sí al mover una consulta. Lo demás del CTE recursivo se escribe igual en los cuatro.

## Ejercicios

Siete sobre `tienda.db`. Intenta antes de abrir 💛

**1. El pedido más grande**

Trae el pedido con el monto más alto, sin escribir el número a mano.

```
SELECT id, monto FROM pedidos WHERE monto = (SELECT MAX(monto) FROM pedidos);
```

```text
id  monto
--  -------
25  1399.98
```

Con `ORDER BY monto DESC LIMIT 1` también sale, y es más rápido. La diferencia está en los empates: si dos pedidos tuvieran el mismo monto máximo, el `LIMIT 1` te da uno solo y esta consulta te da los dos. Casi siempre quieres los dos.

**2. Clientes con más de una dirección**

Cuántos clientes tienen dos direcciones o más, con una subconsulta correlacionada.

```
SELECT COUNT(*) AS clientes_con_dos_direcciones
FROM clientes c
WHERE (SELECT COUNT(*) FROM direcciones d WHERE d.id_cliente = c.id) >= 2;
```

```text
clientes_con_dos_direcciones
----------------------------
33
```

33 de 120. Y esto mismo con `JOIN` + `GROUP BY` + `HAVING` da igual y corre mejor; lo hago así aquí para que veas la forma correlacionada en el `WHERE`, que es donde sí se usa mucho.

**3. Los mejores clientes, con el listón calculado**

Los clientes que gastaron más de diez veces el ticket promedio de la tienda.

```
SELECT c.nombre, c.ciudad, ROUND(SUM(p.monto), 2) AS soles
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.ciudad
HAVING SUM(p.monto) > (SELECT AVG(monto) * 10 FROM pedidos)
ORDER BY soles DESC
LIMIT 4;
```

```text
nombre                      ciudad    soles
--------------------------  --------  --------
Distribuidora Paz 058       Lima      10131.72
Almacenes Vega 067          Piura     9516.06
Market Central 087          Chiclayo  8148.56
Restaurante Miraflores 093  Trujillo  8119.95
```

La subconsulta va dentro del `HAVING`, que es perfectamente legal y poca gente lo sabe. El listón sale de los datos, así que el día que suba el ticket promedio, el listón sube solo.

Si le quitas el `ROUND` vas a ver un 8148.5599999999995, que es el `FLOAT` del capítulo 4 saludando 👋

**4. Los clientes que compran por los dos canales**

Cuántos clientes han comprado alguna vez por Web y alguna vez por WhatsApp.

```
SELECT COUNT(*) AS en_los_dos FROM (
SELECT id_cliente FROM pedidos WHERE canal = 'Web' AND id_cliente IS NOT NULL
INTERSECT
SELECT id_cliente FROM pedidos WHERE canal = 'WhatsApp' AND id_cliente IS NOT NULL
);
```

```text
en_los_dos
----------
89
```

89 de los 119 que compraron usan los dos canales. O sea que la gente no elige canal, elige el que tenga a mano ese día. Eso cambia bastante cómo se piensa una campaña 💡

Y ese `id_cliente IS NOT NULL` está puesto a propósito: sin él, el grupo de los nulos entra en los dos lados y el `INTERSECT` te suma un cliente que no existe.

**5. Los que más pedidos grandes hacen**

Con un CTE: los 5 clientes con más pedidos de más de S/800, con su nombre y ciudad.

```
WITH grandes AS (
    SELECT id_cliente, COUNT(*) AS pedidos_grandes
    FROM pedidos
    WHERE monto > 800 AND id_cliente IS NOT NULL
    GROUP BY id_cliente
)
SELECT c.nombre, c.ciudad, g.pedidos_grandes
FROM grandes g
JOIN clientes c ON c.id = g.id_cliente
ORDER BY g.pedidos_grandes DESC, c.id
LIMIT 5;
```

```text
nombre                  ciudad    pedidos_grandes
----------------------  --------  ---------------
Mayorista Peru 027      Cusco     7
  MARKET CENTRAL 069    Trujillo  6
Cafe del Puerto 092     Piura     6
Minimarket El Sol 001   Piura     5
Almacenes Vega 067      Piura     5
```

Un CTE se puede unir con un `JOIN` igual que una tabla de verdad, y eso es lo que lo hace tan cómodo: calculas lo difícil arriba y abajo escribes una consulta normal y corriente.

**6. El NOT IN que miente**

Cuenta los clientes que nunca compraron, de las tres formas, y explica por qué una da cero.

```
SELECT COUNT(*) AS con_not_in
FROM clientes
WHERE id NOT IN (SELECT id_cliente FROM pedidos);
```

```text
con_not_in
----------
0
```

```
SELECT COUNT(*) AS con_not_exists
FROM clientes c
WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.id_cliente = c.id);
```

```text
con_not_exists
--------------
1
```

```
SELECT COUNT(*) AS con_left_join
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
WHERE p.id IS NULL;
```

```text
con_left_join
-------------
1
```

Cero, uno y uno. Las dos últimas son correctas y la primera se rompe por los 24 `id_cliente` nulos de la tabla de pedidos.

Este ejercicio es el que más quiero que se te quede de todo el capítulo, porque el fallo no se ve, no avisa, y el resultado que devuelve es justo el que alguien quiere oír: "no hay clientes inactivos" 🫠

**7. Escríbelo para los cuatro**

Sin ejecutar: los clientes que nunca compraron, en los cuatro motores.

```
-- Se escribe IGUAL en PostgreSQL, MySQL, SQL Server y SQLite.
SELECT c.id, c.nombre
FROM clientes c
WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.id_cliente = c.id);
```

Una sola versión para los cuatro, y de las poquísimas del libro. Las subconsultas y `EXISTS` son de lo más estándar que tiene SQL: lo que cambia entre motores casi siempre son las funciones (texto, fechas, formato), no la estructura de la consulta.

O sea que si algo de este capítulo te parece que cuesta, buena noticia: lo que aprendas aquí te sirve en los cuatro sin traducir nada 🐣

## La subconsulta que mira hacia afuera

Antes de cerrar, una que da miedo de verdad: una consulta mal escrita que no revienta, sino que contesta con toda tranquilidad 😳

La trampa

Cruzas dos tablas con una subconsulta. Te equivocas de columna al escribirla, y en vez de un error te sale un resultado.

```
SELECT COUNT(*) FROM clientes
WHERE nombre IN (SELECT nombre FROM pedidos);
-- 120
```

**Qué está mal**

Los 120. Todos 😳 Y `pedidos` no tiene ninguna columna que se llame `nombre`.

Cuando SQL no encuentra una columna dentro de la subconsulta, **la busca fuera**. Y como `clientes` sí tiene `nombre`, la subconsulta se convierte en "el nombre de este cliente, repetido una vez por cada pedido que hay". Claro que está en la lista: es él mismo.

Esto es una regla del lenguaje, no un fallo de SQLite: pasa igual en PostgreSQL y en SQL Server. Y es de las pocas cosas de SQL que producen un resultado con toda tranquilidad estando la consulta mal escrita.

Se evita poniéndole alias a todo y usándolos siempre: `SELECT p.nombre FROM pedidos p` habría reventado con un *no such column*, que es justo lo que querías. Los alias no son para escribir menos, son para que el motor no adivine por ti.

Comprueba que lo tienes

Buscas los clientes que no están en una lista con WHERE cliente_id NOT IN (SELECT cliente_id FROM devoluciones) y te devuelve cero filas, aunque sabes que hay clientes que nunca devolvieron.

- Si la subconsulta trae aunque sea un NULL, el NOT IN se queda vacío
- La subconsulta devuelve demasiadas filas y el motor se rinde
- Hay que poner DISTINCT en la subconsulta
- NOT IN no existe, hay que escribir NOT EXISTS

## Lo que te llevas

- 🔢 Una subconsulta escalar en el `WHERE` te deja poner un listón que se recalcula solo. Nunca escribas el número a mano.
- 💣 `NOT IN` con un `NULL` en la lista devuelve cero filas, sin error, en los cuatro motores. Usa `NOT EXISTS` y listo.
- 🧱 Una subconsulta en el `FROM` es una tabla derivada. Ponle alias siempre: tres de los cuatro motores te lo exigen.
- 📖 `WITH` es la misma consulta escrita de arriba abajo. Está en los cuatro y es lo que hace legible una consulta larga.
- 🐌 Una subconsulta correlacionada en el `SELECT` corre una vez por fila. Con pocas filas da igual; con muchas, pásala a `JOIN`.
- ➕ `UNION` quita duplicados y `UNION ALL` no. `INTERSECT` y `EXCEPT` no existen en MySQL anterior a la 8.0.31.
- 🔁 `WITH RECURSIVE` fabrica listas que no están en ninguna tabla, como el calendario de meses para que no falten los ceros. En SQL Server va sin la palabra `RECURSIVE`.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

`NOT IN` contra una subconsulta es una bomba de tiempo. Se escribe `NOT EXISTS`.

Y esto de partir un problema grande en trozos con nombre no es de SQL: es lo mismo que hace una función, y por el mismo motivo. Está en el [libro de Python](https://missyera.com/guias/python-desde-cero/) 🪜

En el capítulo 9 vienen las funciones de ventana, que es lo que te deja calcular un ranking, un acumulado o un "cuánto creció respecto al mes pasado" sin perder el detalle. Es la herramienta que más separa a alguien que sabe SQL de alguien que lo usa.

Que tengas lindo día! 🌸

## Seguir leyendo

- Anterior: [Juntar dos tablas](https://missyera.com/guias/sql-desde-cero/joins.md)
- Siguiente: [Funciones de ventana, sin perder el detalle](https://missyera.com/guias/sql-desde-cero/funciones-de-ventana.md)

## Contacto

- Diagnóstico de 30 minutos con Miss Yera: https://calendly.com/soymissyera/reunion-con-yera
- Correo: hola@missyera.com
- Esta página en HTML: https://missyera.com/guias/sql-desde-cero/subconsultas-y-ctes/
- Índice de todas las páginas en texto: https://missyera.com/llms-index.txt
