Cómo formatear consultas SQL complejas
Cómo organizar consultas que ocupan una pantalla o más: cadenas de CTE, subconsultas anidadas, expresiones CASE, funciones de ventana y UNION. Cada sección muestra lo que produce SQL Format y los hábitos manuales que hacen que el resultado sea más fácil de seguir.
Una consulta corta se lee bien en casi cualquier formato. Una larga solo se lee bien si su forma te indica dónde empieza y dónde termina cada parte. El formato no puede arreglar una consulta que hace demasiadas cosas a la vez, pero sí puede hacer visible su estructura, y una estructura visible es lo que te permite detectar la parte que necesita reescribirse. Los ejemplos de abajo usan las opciones por defecto del formateador: sangría de cuatro espacios, comas al final y palabras clave en mayúsculas.
Principios generales
- Una idea por bloque. Cada CTE, subconsulta o expresión CASE debería hacer una sola cosa que puedas describir en una frase corta.
- La sangría muestra el anidamiento. Todo lo que está dentro de un par de paréntesis debería llevar sangría respecto de la línea que lo abrió, para que puedas encontrar el cierre correspondiente mirando hacia abajo en línea recta.
- Lectura de arriba hacia abajo. Prefiere estructuras que se puedan leer en orden (pasos con nombre) antes que estructuras que hay que leer de adentro hacia afuera (anidamiento profundo).
- Los nombres son documentación. Un buen nombre de CTE o alias de columna reemplaza un comentario.
CTE: una cadena de pasos con nombre
Una expresión de tabla común (CTE) te permite dividir una consulta en pasos con nombre, cada uno construido sobre los anteriores. Piénsalo como un pipeline: filtrar las filas en bruto, agregarlas, clasificarlas y luego seleccionar las columnas finales. Esta es una cadena de tres pasos escrita en una sola línea y formateada:
with paid_orders as (select order_id, customer_id, total_amount, created_at from orders where status = 'paid' and created_at >= date '2026-01-01'), customer_revenue as (select customer_id, count(*) as order_count, sum(total_amount) as revenue from paid_orders group by customer_id), ranked_customers as (select customer_id, order_count, revenue, RANK() over (order by revenue desc) as revenue_rank from customer_revenue) select c.customer_name, r.order_count, r.revenue, r.revenue_rank from ranked_customers r join customers c on c.customer_id = r.customer_id where r.revenue_rank <= 10 order by r.revenue_rank;WITH paid_orders AS (
SELECT order_id,
customer_id,
total_amount,
created_at
FROM orders
WHERE status = 'paid'
AND created_at >= DATE '2026-01-01'
),
customer_revenue AS (
SELECT customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM paid_orders
GROUP BY customer_id
),
ranked_customers AS (
SELECT customer_id,
order_count,
revenue,
RANK() OVER (
ORDER BY revenue DESC
) AS revenue_rank
FROM customer_revenue
)
SELECT c.customer_name,
r.order_count,
r.revenue,
r.revenue_rank
FROM ranked_customers r
JOIN customers c
ON c.customer_id = r.customer_id
WHERE r.revenue_rank <= 10
ORDER BY r.revenue_rank;El formateador no tiene un diseño específico para las CTE; aplica las mismas reglas que usa en todo lo demás. El cuerpo de cada CTE es una subconsulta, así que lleva sangría dentro de sus paréntesis. El paréntesis de cierre vuelve al margen izquierdo, la coma entre CTE va a continuación en la misma línea y el nombre de la siguiente CTE empieza en una línea nueva. Con comas al inicio, la coma pasa en cambio al inicio del nombre siguiente (,customer_revenue AS (). Con la opción Paréntesis en nueva línea activada, el paréntesis de apertura después de AS pasa a su propia línea, lo que a algunas personas les facilita emparejarlos visualmente.
Lo que el formateador no puede hacer por ti es la estructura en sí. Algunos hábitos hacen que las cadenas de CTE sean mucho más fáciles de leer:
- Nombra cada paso según su resultado, no según cómo se construye:
paid_orders,customer_revenue,ranked_customers. Evitacte1,tmpobase. - Una transformación por CTE. Si un paso filtra, hace joins, agrega y clasifica, divídelo. Las líneas extra cuestan menos que el tiempo que alguien que revisa pasa desenredándolo.
- Mantén corto el SELECT final. Idealmente solo une el último paso con tablas de referencia y elige las columnas de salida.
- Selecciona solo las columnas que necesita el paso siguiente. Así cada paso deja claras sus entradas y las columnas sin uso saltan a la vista.
- Depura paso a paso. Una cadena con nombres te permite reemplazar el SELECT final por
SELECT * FROM customer_revenuepara inspeccionar un resultado intermedio.
Subconsultas anidadas
Las subconsultas en el WHERE, sobre todo después de IN, suelen ser la forma más clara de expresar un filtro. Se vuelven difíciles de leer cuando se anidan. El formateador aplica sangría a cada nivel dentro de sus paréntesis, así que al menos la profundidad queda visible:
select p.product_id, p.product_name from products p where p.category_id in (select category_id from categories where parent_id in (select category_id from categories where category_name = 'Hardware')) and p.discontinued = false;SELECT p.product_id,
p.product_name
FROM products p
WHERE p.category_id IN (
SELECT category_id
FROM categories
WHERE parent_id IN (
SELECT category_id
FROM categories
WHERE category_name = 'Hardware'
)
)
AND p.discontinued = false;Cada subconsulta se abre con un paréntesis al final de la línea del IN, y su paréntesis de cierre se alinea con el inicio de la línea que la abrió. La última condición, AND p.discontinued = false, vuelve al nivel externo, lo que deja claro que pertenece a la consulta externa y no a la subconsulta. Ten en cuenta que este formateador siempre escribe TRUE y FALSE en minúsculas.
Si prefieres cada paréntesis de apertura en su propia línea, la opción Paréntesis en nueva línea produce este diseño:
SELECT p.product_id,
p.product_name
FROM products p
WHERE p.category_id IN
(
SELECT category_id
FROM categories
WHERE parent_id IN
(
SELECT category_id
FROM categories
WHERE category_name = 'Hardware'
)
)
AND p.discontinued = false;Dos niveles de anidamiento suelen ser el punto en que la legibilidad empieza a resentirse. Llegado ese punto, considera mover la consulta interna a una CTE con un nombre descriptivo (aquí, algo como hardware_categories), o reemplazar una cadena de subconsultas IN por joins cuando la relación es simple.
Subconsultas correlacionadas y EXISTS
Las subconsultas correlacionadas, que hacen referencia a una columna de la consulta externa, merecen un cuidado especial porque el vínculo entre los dos niveles es fácil de pasar por alto. Pon la condición de correlación primero en el WHERE interno y califica ambos lados con sus alias. El formateador mantiene NOT EXISTS en la línea de la condición, como AND NOT EXISTS (, y aplica sangría a la subconsulta debajo, como a cualquier otra:
select c.customer_id, c.customer_name from customers c where c.is_active = true and not exists (select 1 from orders o where o.customer_id = c.customer_id and o.created_at >= current_date - 365)SELECT c.customer_id,
c.customer_name
FROM customers c
WHERE c.is_active = true
AND NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.created_at >= CURRENT_DATE - 365
)El SELECT 1 es una convención: EXISTS solo verifica si existe una fila, así que la lista del select es irrelevante, y escribir una constante se lo deja claro a quien lee.
Expresiones CASE
Una expresión CASE con varias ramas apretadas en una sola línea es una de las cosas más difíciles de leer en SQL. El formateador le da a CASE su propio diseño, que se muestra aquí para un CASE de búsqueda (condiciones después de cada WHEN) y un CASE simple (un valor comparado con constantes):
select order_id, total_amount, case when total_amount >= 1000 then 'large' when total_amount >= 100 then 'medium' else 'small' end as order_size, case status when 'open' then 1 when 'shipped' then 2 else 3 end as status_rank from orders;SELECT order_id,
total_amount,
CASE WHEN total_amount >= 1000 THEN
'large'
WHEN total_amount >= 100 THEN
'medium'
ELSE
'small'
END AS order_size,
CASE status WHEN 'open' THEN
1
WHEN 'shipped' THEN
2
ELSE
3
END AS status_rank
FROM orders;El primer WHEN se queda en la línea del CASE y cada WHEN siguiente empieza en una línea nueva, siempre junto con su condición y su THEN. El resultado va en la línea siguiente, con un nivel de sangría, y ELSE y END se alinean con las ramas WHEN. El alias va después del END, así que el nombre de la columna calculada es fácil de encontrar.
Consejos manuales para CASE:
- Ordena las ramas a propósito. Un CASE de búsqueda devuelve la primera rama que coincide, así que pon primero la condición más específica. En el ejemplo,
>= 1000debe ir antes que>= 100. - Escribe siempre un ELSE. Sin él, las filas que no coinciden devuelven
NULL. Si eso es lo que buscas, escribeELSE NULLpara que quien lee sepa que no fue un olvido. - Mantén consistentes los tipos de resultado. Mezclar números y textos entre ramas lleva a errores de conversión o a conversiones implícitas sorprendentes.
- Saca los mapeos grandes de la consulta. Un CASE con veinte ramas que traduce códigos a etiquetas suele ser una tabla de referencia esperando a ser creada.
Funciones de ventana
Las funciones de ventana calculan un valor sobre un conjunto de filas relacionadas sin colapsarlas, lo que las hace ideales para rankings, totales acumulados y comparaciones con la fila anterior. Su cláusula OVER puede alargarse, con una partición, un orden y un marco. Así las organiza el formateador:
select customer_id, order_id, total_amount, ROW_NUMBER() over (partition by customer_id order by created_at desc) as recency_rank, sum(total_amount) over (partition by customer_id order by created_at ROWS between UNBOUNDED PRECEDING and CURRENT ROW) as running_total from orders where status <> 'cancelled';SELECT customer_id,
order_id,
total_amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS recency_rank,
SUM(total_amount) OVER (
PARTITION BY customer_id
ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
WHERE status <> 'cancelled';OVER ( se queda en la misma línea que la función, y la especificación de ventana lleva sangría debajo: PARTITION BY y ORDER BY empiezan cada uno en su propia línea, y el paréntesis de cierre vuelve a la sangría de la función, seguido del alias. La cláusula de marco (ROWS BETWEEN ...) se queda en la línea del ORDER BY, y sus palabras (ROWS, UNBOUNDED PRECEDING, CURRENT ROW) cambian de mayúsculas a minúsculas o viceversa como cualquier otra palabra clave. Los nombres de las funciones de ventana, como ROW_NUMBER y RANK, no se tratan como palabras clave y conservan las mayúsculas y minúsculas que escribiste, así que escríbelos de forma consistente a mano.
Consejos manuales para funciones de ventana:
- Ponle siempre un alias al resultado con un nombre que diga lo que significa:
recency_rank,running_total, nornnisum1. - Sé explícito con el marco cuando uses una función de agregación con
ORDER BY. Es fácil equivocarse con el marco por defecto cuando hay empates en la columna de ordenamiento. - Reutiliza la definición de ventana. Cuando varias funciones comparten el mismo
PARTITION BYyORDER BY, las bases de datos que admiten la cláusulaWINDOWcon nombre, PostgreSQL entre ellas, te permiten definirla una vez y referenciarla por su nombre. - Filtra en un paso externo. Las funciones de ventana no se pueden usar en el
WHEREdel mismo nivel. Para quedarte solo con la primera fila de cada grupo, calcula el ranking en una CTE y filtra por él en el paso siguiente.
UNION y UNION ALL
Las operaciones de conjuntos combinan los resultados de varias sentencias SELECT. La clave para leerlas es ver dónde termina una rama y dónde empieza la siguiente:
select customer_id, 'order' as event_type, created_at from orders where created_at >= current_date - 7 union all select customer_id, 'refund' as event_type, created_at from refunds where created_at >= current_date - 7 union select customer_id, 'signup' as event_type, created_at from customers where created_at >= current_date - 7 order by created_at;SELECT customer_id,
'order' AS event_type,
created_at
FROM orders
WHERE created_at >= CURRENT_DATE - 7
UNION ALL
SELECT customer_id,
'refund' AS event_type,
created_at
FROM refunds
WHERE created_at >= CURRENT_DATE - 7
UNION
SELECT customer_id,
'signup' AS event_type,
created_at
FROM customers
WHERE created_at >= CURRENT_DATE - 7
ORDER BY created_at;Cada UNION o UNION ALL va en su propia línea, en el margen izquierdo, entre dos bloques SELECT que mantienen su diseño normal. El ORDER BY final aparece después de la última rama. Ordena el resultado combinado, no solo la última rama, algo fácil de malinterpretar si las ramas no están claramente separadas.
Consejos manuales para operaciones de conjuntos:
- Elige UNION ALL por defecto.
UNIONelimina las filas duplicadas, lo que cuesta un ordenamiento o un hash y puede ocultar problemas en los datos. Úsalo solo cuando realmente necesites eliminar duplicados, y considera dejar un comentario que lo diga. - Mezclar UNION y UNION ALL, como en el ejemplo, es válido pero sutil: la eliminación de duplicados se aplica al resultado construido hasta ese punto, no solo a la última rama. Cuando no sea evidente, divide la consulta en CTE.
- Alinea las listas del select. Las columnas se emparejan por posición, no por nombre. Dale a cada rama las mismas columnas en el mismo orden, con los mismos alias, y agrega una columna literal como
event_typepara indicar de dónde viene cada fila. - Mantén simple cada rama. Si una rama necesita sus propios joins y filtros, considera una CTE por rama y un UNION final de las CTE.
Líneas largas y ajuste de línea
Incluso con una buena estructura, algunas líneas se alargan: una expresión aritmética extensa, una función con muchos argumentos, una lista larga de condiciones. La opción Cantidad máxima de columnas, 120 por defecto, parte cualquier línea de salida más larga que el límite en el último espacio antes de él y aplica sangría a la continuación. Es una pasada final sobre la salida ya terminada, así que no reorganiza las cláusulas. Si una línea sigue siendo difícil de leer después del ajuste, es una señal para introducir una CTE o un alias intermedio, no para bajar el límite.
Una lista rápida de verificación para consultas complejas
- ¿Se puede describir cada CTE en una frase corta, y su nombre dice lo que devuelve?
- ¿Hay alguna subconsulta anidada a más de dos niveles de profundidad?
- ¿Cada CASE tiene un ELSE, y sus ramas están en el orden correcto?
- ¿Cada función de ventana tiene un alias con significado y un marco explícito donde importa?
- ¿Cada
UNION(sin ALL) realmente tiene la intención de eliminar duplicados? - ¿Todas las columnas están calificadas con un alias de tabla?
Para conocer las convenciones detrás de estos consejos, consulta la guía de estilo de SQL. Si tus consultas usan funcionalidades de PostgreSQL como las conversiones con :: o los cuerpos de función entre dollar quotes, la guía de formato para PostgreSQL las cubre, y errores comunes de legibilidad en SQL enumera patrones que vale la pena corregir antes de formatear.