Errores comunes de legibilidad en SQL
Doce hábitos que hacen que el SQL sea más difícil de leer y revisar, y menos confiable, cada uno con un "antes" y una versión reescrita más clara. Algunos son puramente de diseño y un formateador los corrige con un clic; otros requieren una decisión humana, y algunos de ellos también cambian lo que devuelve la consulta o la velocidad con que se ejecuta.
Por qué vale la pena invertir en legibilidad
Una consulta se escribe una vez y se lee muchas: en la revisión de código, durante un incidente, cuando alguien agrega un filtro dos años después. La mayoría de los bugs de SQL que llegan a producción no son errores de sintaxis: son consultas que hacen algo ligeramente distinto de lo que el autor quería, y el revisor no lo notó porque la consulta era difícil de leer. Los errores de abajo son los que aparecen con más frecuencia en las colas de revisión reales. Los errores 1 a 9 tienen que ver con la estructura y el diseño; del 10 al 12 son problemas de legibilidad que además esconden una trampa de corrección o de rendimiento.
Para las convenciones de diseño que se mencionan aquí, consulta la guía de estilo de SQL. Todos los bloques "después" marcados como salida del formateador se generaron con SQL Format usando las opciones predeterminadas.
1. Joins implícitos con comas
Listar las tablas en FROM separadas por comas y unirlas en el WHERE mezcla las condiciones de join con los filtros. Con tres tablas o más, se vuelve difícil ver qué condición une qué, y si olvidas una condición de join obtienes un producto cartesiano silencioso en lugar de un error.
SELECT c.name, o.id, o.total
FROM customers c, orders o, shipments s
WHERE o.customer_id = c.id
AND s.order_id = o.id
AND s.shipped_at IS NULL
AND o.status = 'paid'Usa JOIN … ON explícito. Cada condición de join queda junto a la tabla que incorpora, y el WHERE contiene solo los filtros. El formateador sangra cada JOIN debajo de FROM y su ON un nivel más:
SELECT c.name,
o.id,
o.total
FROM customers c
JOIN orders o
ON o.customer_id = c.id
JOIN shipments s
ON s.order_id = o.id
WHERE s.shipped_at IS NULL
AND o.status = 'paid'2. SELECT * en código de aplicación y de vistas
SELECT * está bien en una consola interactiva. En el código, oculta de qué columnas depende quien llama, transfiere columnas que nadie lee y cambia el comportamiento cuando alguien agrega o reordena una columna: una vista, un INSERT … SELECT o un mapeo de resultados por posición puede romperse o, peor aún, seguir funcionando con los datos equivocados.
SELECT *
FROM orders o
WHERE o.customer_id = 42Nombra las columnas. El formateador mantiene la primera en la línea del SELECT y pone cada una de las demás en su propia línea, así que un revisor ve una columna agregada o eliminada como un diff de una sola línea:
SELECT o.id,
o.created_at,
o.status,
o.total
FROM orders o
WHERE o.customer_id = 423. Alias que no significan nada
Alias como a, b, c o t1, t2 obligan al lector a volver al FROM cada vez que se menciona una columna. En el ejemplo de abajo, c.amount parece una columna de clientes, pero viene de pagos.
SELECT a.name, b.number, c.amount
FROM customers a
JOIN invoices b ON b.customer_id = a.id
JOIN payments c ON c.invoice_id = b.id
WHERE c.received_at >= DATE '2026-01-01'Elige alias que recuerden el nombre de la tabla. En una consulta corta basta con una inicial (o para orders); cuando las iniciales se repiten o la consulta es larga, usa una palabra corta:
SELECT cust.name,
inv.number,
pay.amount
FROM customers cust
JOIN invoices inv
ON inv.customer_id = cust.id
JOIN payments pay
ON pay.invoice_id = inv.id
WHERE pay.received_at >= DATE '2026-01-01'4. Columnas sin calificar en los joins
Cuando una consulta une dos tablas, un status o un name sin calificar obliga al lector a conocer los dos esquemas de memoria. Además es frágil: si más adelante alguien agrega una columna status a la segunda tabla, la consulta falla con un error de "columna ambigua", o una subconsulta correlacionada puede enlazarse en silencio con la tabla externa.
SELECT id, created_at, name, email
FROM orders o
JOIN customers c ON c.id = customer_id
WHERE status = 'open'
AND country = 'BR'Califica cada columna con su alias en cuanto haya más de una tabla:
SELECT o.id,
o.created_at,
c.name,
c.email
FROM orders o
JOIN customers c
ON c.id = o.customer_id
WHERE o.status = 'open'
AND c.country = 'BR'5. Mezclar AND y OR sin paréntesis
AND tiene mayor precedencia que OR. La condición de abajo se evalúa como (country = 'BR' AND status = 'active') OR (status = 'trial' AND deleted_at IS NULL), así que devuelve los clientes en período de prueba de todos los países. Eso casi nunca es lo que el autor quería, y el diseño plano lo oculta.
SELECT id, name, status
FROM customers
WHERE country = 'BR' AND status = 'active' OR status = 'trial' AND deleted_at IS NULLAgrega paréntesis siempre que AND y OR aparezcan en la misma condición, incluso cuando la precedencia da por casualidad la respuesta correcta. El formateador pone cada condición de primer nivel en su propia línea y sangra el grupo entre paréntesis, de modo que la estructura se ve de un vistazo:
SELECT id,
name,
status
FROM customers
WHERE country = 'BR'
AND (
status = 'active'
OR status = 'trial'
)
AND deleted_at IS NULL6. Subconsultas muy anidadas en lugar de CTE
Las tablas derivadas anidadas a dos o tres niveles de profundidad tienen que leerse de adentro hacia afuera, y sus nombres (p, t) aparecen solo después del código que las define.
SELECT c.name, t.revenue
FROM (SELECT customer_id, revenue
FROM (SELECT customer_id, SUM(total) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id) p
WHERE revenue > 10000) t
JOIN customers c ON c.id = t.customer_id
ORDER BY t.revenue DESCUna expresión de tabla común (WITH) te permite darle nombre a cada paso y leer la consulta de arriba hacia abajo. Todas las bases de datos incluidas en nuestra comparación de dialectos soportan CTE en sus versiones actuales. El formateador organiza el cuerpo de cada CTE como cualquier otra subconsulta, con sangría dentro de sus paréntesis:
WITH paid_orders AS (
SELECT customer_id,
SUM(total) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
),
top_customers AS (
SELECT customer_id,
revenue
FROM paid_orders
WHERE revenue > 10000
)
SELECT c.name,
t.revenue
FROM top_customers t
JOIN customers c
ON c.id = t.customer_id
ORDER BY t.revenue DESCEncontrarás más técnicas para consultas grandes en formatear consultas complejas.
7. Números mágicos sin explicación
Un status_id = 4 o un - 90 sueltos no le dicen nada al lector. Alguien tiene que averiguar qué es el estado 4, y nadie sabe si 90 es una regla de negocio o una suposición.
SELECT id, name
FROM employees
WHERE status_id = 4
AND hired_at < CURRENT_DATE - 90Haz un join con la tabla de referencia y filtra por un código legible, y pon un comentario junto a cualquier constante que represente una regla. El formateador conserva los comentarios en su lugar:
SELECT e.id,
e.name
FROM employees e
JOIN employee_status s
ON s.id = e.status_id
WHERE s.code = 'active'
-- probation period is 90 days (HR policy)
AND e.hired_at < CURRENT_DATE - INTERVAL '90 days'8. Mayúsculas y minúsculas inconsistentes en las palabras clave
SELECT en una línea, from en la siguiente y order BY después no es incorrecto, pero hace más difícil distinguir las palabras clave de los identificadores y convierte cada edición en ruido en el diff. Elige una convención para el equipo y deja que una herramienta la aplique. Con la opción Palabras clave en Mayúsculas, el formateador reescribe las palabras clave y deja los identificadores como Name y Customers tal como se escribieron:
SELECT id, Name, email
from Customers
Where status = 'active'
order BY created_at DESCSELECT id,
Name,
email
FROM Customers
WHERE status = 'active'
ORDER BY created_at DESC9. Toda la consulta en una sola línea
Las consultas copiadas de logs, ORM o chats suelen llegar como una sola línea de varios cientos de caracteres. Revisarlas implica desplazarse hacia los lados, y un diff en esa línea muestra toda la consulta como modificada. Este es el caso en el que un formateador más ayuda:
select p.sku, p.name, sum(i.quantity) as units, sum(i.quantity * i.unit_price) as revenue from order_items i join products p on p.id = i.product_id join orders o on o.id = i.order_id where o.status = 'paid' and o.created_at >= date '2026-01-01' group by p.sku, p.name having sum(i.quantity) > 100 order by revenue descSELECT p.sku,
p.name,
SUM(i.quantity) AS units,
SUM(i.quantity * i.unit_price) AS revenue
FROM order_items i
JOIN products p
ON p.id = i.product_id
JOIN orders o
ON o.id = i.order_id
WHERE o.status = 'paid'
AND o.created_at >= DATE '2026-01-01'
GROUP BY p.sku,
p.name
HAVING SUM(i.quantity) > 100
ORDER BY revenue DESCSi después del formateo algunas líneas siguen siendo demasiado largas, la opción Cantidad máxima de columnas las divide en un espacio.
10. ORDER BY y GROUP BY por posición
ORDER BY 1, 3 es rápido de escribir, pero obliga al lector a contar los elementos del select y cambia de significado en silencio cuando alguien inserta una columna al inicio de la lista del select. El GROUP BY por posición no está soportado en todas partes, y la columna calculada de abajo ni siquiera tiene nombre.
SELECT region, product_line, SUM(amount)
FROM sales
GROUP BY 1, 2
ORDER BY 1, 3 DESCAgrupa por las expresiones y ordena por nombres de columna o alias:
SELECT region,
product_line,
SUM(amount) AS revenue
FROM sales
GROUP BY region,
product_line
ORDER BY region,
revenue DESC11. NOT IN con una subconsulta que puede devolver NULL
Este parece inofensivo y es un bug de corrección. x NOT IN (a, b, NULL) significa x <> a AND x <> b AND x <> NULL, y x <> NULL nunca es verdadero: es desconocido. Por lo tanto, si la subconsulta devuelve un solo NULL, el predicado completo nunca es verdadero y la consulta no devuelve ninguna fila.
-- returns no rows at all if any blocked_emails.email is NULL
SELECT c.id, c.name
FROM customers c
WHERE c.email NOT IN (SELECT email FROM blocked_emails)Usa NOT EXISTS, que compara fila por fila y no se ve afectado por los valores NULL de la otra tabla. Además expresa la intención, "clientes sin un e-mail bloqueado correspondiente", de forma más directa. El formateador mantiene NOT EXISTS ( en la línea del WHERE y aplica sangría a la subconsulta correlacionada debajo:
SELECT c.id,
c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM blocked_emails b
WHERE b.email = c.email
)Un anti-join con LEFT JOIN … IS NULL da el mismo resultado y es cuestión de preferencia del equipo; prueba la columna que no puede ser NULL en una fila coincidente, normalmente la clave del join:
SELECT c.id,
c.name
FROM customers c
LEFT JOIN blocked_emails b
ON b.email = c.email
WHERE b.email IS NULL12. Funciones sobre columnas indexadas en el WHERE
Envolver una columna en una función, como en EXTRACT(YEAR FROM created_at) = 2026, se lee con naturalidad, pero normalmente impide que la base de datos use un índice simple sobre esa columna, porque el índice almacena created_at, no el año. Se dice que el predicado no es sargable, y la consulta termina recorriendo la tabla completa. Además es más difícil de leer de lo que parece: dos condiciones separadas describen lo que en realidad es un único rango de fechas.
-- an index on created_at cannot be used for this range
SELECT id, total
FROM orders
WHERE EXTRACT(YEAR FROM created_at) = 2026
AND EXTRACT(MONTH FROM created_at) = 3Escribe el rango sobre la columna sin funciones, con un inicio inclusivo y un fin exclusivo. Esto funciona con cualquier tipo de columna, incluidos los timestamps con parte de hora:
SELECT id,
total
FROM orders
WHERE created_at >= DATE '2026-03-01'
AND created_at < DATE '2026-04-01'Un índice sobre expresión puede volver a hacer rápida la forma con función, pero el rango es más simple y no depende de un índice que alguien podría eliminar.
Qué corrige un formateador y qué no
Un formateador corrige el diseño: los errores 8 y 9 por completo, y el aspecto visual de los demás, como una columna por línea y una condición por línea. No cambia el significado de tu consulta, así que no va a agregar paréntesis, reemplazar NOT IN ni renombrar alias. Aun así, formatear primero ayuda con esos casos, porque una consulta bien organizada hace que los problemas restantes sean más fáciles de detectar. Para mantener esto consistente en todo el equipo, consulta formatear SQL para revisión de código y control de versiones.
Preguntas frecuentes
¿SELECT * siempre está mal?
No. Está bien para exploración ad hoc y dentro de subconsultas EXISTS, donde la lista del select se ignora. El problema está en el código de aplicación, las vistas y las sentencias INSERT … SELECT que dependen en silencio de la lista de columnas actual.
¿Los joins implícitos con comas son más lentos que un JOIN explícito?
Para inner joins, los optimizadores más usados suelen tratar las dos formas de la misma manera. La razón para preferir un JOIN explícito es la legibilidad y la seguridad: una condición de join olvidada queda a la vista en lugar de producir un producto cartesiano.
¿NOT IN se comporta correctamente alguna vez?
Sí, cuando ni la lista ni la subconsulta pueden producir NULL, por ejemplo una lista de literales o una columna declarada NOT NULL. Como las restricciones pueden cambiar más adelante, NOT EXISTS es el hábito más seguro para las subconsultas.