SQL · Capítulo 11 · Lección de conceptos · 7 min de lectura

Una consulta puede ejecutarse y estar mal

Los peores errores de SQL no lanzan ningún error. Lee una consulta cláusula por cláusula, pregunta qué significa una fila después de cada una y prueba los pocos lugares donde se esconden las respuestas equivocadas.

La idea

La mayoría de los errores en SQL no detienen la consulta. Una consulta con un join equivocado, un filtro equivocado o una división equivocada sigue devolviendo filas, y esas filas parecen datos. Depurar significa atrapar un número equivocado mientras la consulta funciona.

El método es leer la consulta en el orden en que la ejecuta la base de datos y, en cada cláusula, preguntar qué significa ahora una fila. Ese significado es el nivel de detalle. Cuando no es el que suponías, todos los números de arriba están mal, y no recibirás ningún error.

Figura 1

Lee la consulta en el orden en que la ejecuta la base de datos

  1. FROM / JOIN ¿qué significa una fila?
  2. WHERE ¿qué filas se descartan?
  3. GROUP BY ¿qué se colapsa?
  4. HAVING ¿qué grupos se descartan?
  5. SELECT ¿está bien la cuenta?

Paso 1 de 5 Empieza con FROM y cada JOIN. Después de ellos, una fila significa una cosa: una línea de pedido, un par pedido-sesión. Ese es el **nivel de detalle**, y todo lo que sigue depende de él.

  1. Empieza con FROM y cada JOIN. Después de ellos, una fila significa una cosa: una línea de pedido, un par pedido-sesión. Ese es el **nivel de detalle**, y todo lo que sigue depende de él.
  2. WHERE filtra filas antes de agrupar. Pregunta qué filas quita, y si una condición sobre una tabla unida convierte en silencio un LEFT JOIN en un INNER JOIN.
  3. GROUP BY colapsa filas. Las columnas de agrupación definen el nuevo nivel de detalle. Si querías una fila por día y agrupas por mes, el gráfico está mal sin ningún error.
  4. HAVING filtra los grupos, cuando ya existen los totales. Una regla sobre una sola fila va en WHERE; una regla sobre un total o un conteo va aquí.
  5. Al final, SELECT calcula los números. Revisa la división, el redondeo y el agregado según el nivel de detalle al que llegaste.

Por qué existe

En el trabajo heredas consultas más de lo que las escribes: la consulta de un panel de alguien que se fue, un script pegado en un ticket. Leer y arreglar es una habilidad propia, y una tarea común de entrevista.

También es donde los analistas pierden la confianza de los demás. Un informe que cuenta de más los ingresos por envío sin avisar, o un número de retención que nadie puede reproducir, cuesta más que un error visible, porque la gente ya usó el número. Los hábitos de este capítulo son formas baratas de atraparlo antes de que alguien lo vea.

Cómo funciona por dentro

Revisa primero el nivel de detalle

Los errores de fan-out y de nivel de detalle equivocado son el mismo error visto desde dos lados. Tras un join uno a muchos, las columnas de un pedido se repiten una vez por línea, así que sumar el costo de envío da un múltiplo. Agrupar por la columna equivocada hace lo contrario: agrupa una tabla de pedidos por mes cuando el gráfico necesita días y tu línea diaria es un único punto plano. En ambos casos el arreglo empieza por escribir el nivel de detalle que quieres y el que tienes.

Un caso concreto: el pedido 1049 pagó $4.99 de envío y tiene tres líneas en order_items. Une las dos tablas y SUM(shipping_fee) devuelve 14.97 para ese pedido. El join no tiene nada malo; la consulta está sumando copias. Hay dos arreglos honestos: agregar las líneas a una fila por pedido antes del join, o contar el costo de cada pedido una sola vez con una ventana o un DISTINCT sobre el id del pedido. La comprobación que lo revela es simple. Compara el número de filas del join con el de la tabla cuyo nivel de detalle querías. Si el join tiene más filas, una relación uno a muchos repitió algo.

Sabe dónde actúa cada filtro

WHERE ve filas y se ejecuta antes de agrupar. HAVING ve grupos y se ejecuta después. Poner un total en WHERE falla, pero poner una regla de fila en HAVING funciona mientras oculta las filas al agregado, lo que cambia los totales. Una condición sobre la tabla derecha de un LEFT JOIN, escrita en WHERE, descarta las filas NULL que el join había conservado.

NULL rompe las comparaciones

Una comparación con NULL no es ni verdadera ni falsa. NOT IN contra una lista que contiene aunque sea un NULL nunca devuelve verdadero, así que todo el resultado queda vacío, y el informe parece decir "nadie se ha saltado nunca el sitio web".

Figura 2

Un solo NULL en la lista y NOT IN no devuelve nada

customers
customer_idfirst_name
18Linda
1262Patricia
1269Joseph
web_sessions.customer_id (primeras filas)
session_idcustomer_id
1NULL
218
3NULL
NOT IN (SELECT customer_id FROM web_sessions)
customer_id
NOT EXISTS (…)
customer_id
1262
1269

Paso 1 de 4 ¿Qué clientes nunca tuvieron una sesión web? web_sessions.customer_id es NULL para los invitados, así que la lista de la subconsulta contiene NULL, aquí como 18, NULL.

  1. ¿Qué clientes nunca tuvieron una sesión web? web_sessions.customer_id es NULL para los invitados, así que la lista de la subconsulta contiene NULL, aquí como 18, NULL.
  2. La clienta 18 (Linda) está en la lista, así que NOT IN es falso y se descarta. Hasta aquí, bien.
  3. El cliente 1262 nunca tuvo una sesión, así que debería conservarse. Pero 1262 <> 18 es verdadero y 1262 <> NULL es desconocido, y verdadero AND desconocido es desconocido. WHERE solo conserva lo verdadero, así que Patricia se descarta, y todos los demás también.
  4. NOT EXISTS pregunta si alguna fila de sesión coincide con este cliente. NULL nunca coincide con nadie, así que no puede envenenar la respuesta: vuelven los clientes 1262 y 1269.

Protégete con NOT EXISTS, o filtra con IS NOT NULL dentro de la subconsulta. La misma lógica hace que COUNT(col) sea menor que COUNT(*) y que col <> 'x' descarte las filas NULL.

Las fechas son instantes

Una marca de tiempo es un momento, no un día. Una fecha escrita como texto significa la medianoche de su inicio. Por eso los rangos deben ser semiabiertos: desde el primer instante del primer día, hasta pero sin incluir el primer instante del día siguiente.

Figura 3

BETWEEN se detiene a medianoche del último día

orders
order_idorder_date
129752025-12-30 21:12:55
129762025-12-31 05:18:13
129772025-12-31 05:29:21
BETWEEN '2025-12-01' AND '2025-12-31'
order_id
12975
>= '2025-12-01' AND < '2026-01-01'
order_id
12975
12976
12977

Paso 1 de 3 order_date es una marca de tiempo. El texto '2025-12-31' se lee como 2025-12-31 00:00:00, el primer instante del día.

  1. order_date es una marca de tiempo. El texto '2025-12-31' se lee como 2025-12-31 00:00:00, el primer instante del día.
  2. BETWEEN '2025-12-01' AND '2025-12-31' incluye todo hasta esa medianoche. Los pedidos hechos el 31 a las 05:18 vienen después, así que se pierden.
  3. Un rango semiabierto, >= '2025-12-01' AND < '2026-01-01', conserva cada instante de diciembre. En la tabla completa, 752 pedidos pasan a ser los 777 correctos.

Las zonas horarias son la misma idea. Un informe del Black Friday hecho en UTC y una plataforma de anuncios que reporta en otra zona no coinciden sobre qué pedidos pertenecen a qué día. Convierte una vez, de forma explícita, y di qué zona usa el informe.

División entera

Dividir dos enteros puede truncar. En DuckDB // lo hace a propósito, y en Postgres, SQL Server y Redshift un / simple entre enteros también. Una tasa de devoluciones de 6.90% se imprime como 6. Empieza cada razón con 100.0 * y redondea solo al final.

Leer la consulta de otro

Cuando la consulta no es tuya, resiste las ganas de reescribirla. Léela una vez para entender su intención: ¿qué pregunta debe responder, y para qué periodo y qué filas? Luego ejecútala y compara un número con una fuente en la que confíes. Solo cuando sepas que está mal debes cambiar algo, y cambia una cosa a la vez. Si reemplazas tres líneas a la vez y el total se mueve, no sabrás qué edición importó ni si las otras hacían falta.

Una rutina de depuración

  1. Escribe el número que esperas, de una fuente confiable.
  2. Ejecuta la consulta con COUNT(*) después de cada join o filtro y mira dónde salta el conteo.
  3. Sigue un pedido, un cliente o un día a través de la consulta, a mano.
  4. Arregla una cosa y luego vuelve a revisar el número, no solo que se ejecute.

Errores comunes

"Si se ejecutó sin error, la lógica está bien"

Los errores atrapan problemas de sintaxis y de tipos. No dicen nada sobre el significado. Los fallos que llegan a los informes son justamente los que se ejecutan: devuelven una tabla plausible con un número equivocado dentro.

"BETWEEN es inclusivo, así que cubre todo el último día"

Incluye el valor final, que en una columna de marca de tiempo es un solo instante al inicio del día. Todo lo posterior de ese día queda fuera. Usa un rango semiabierto con < el inicio del día siguiente.

"NOT IN y NOT EXISTS son intercambiables"

Coinciden hasta que la columna interna contiene un NULL. Entonces NOT IN no devuelve filas y NOT EXISTS sigue bien. Siempre que la subconsulta pueda devolver NULL, usa NOT EXISTS.

Ponte a prueba

Una pregunta. Al responderla, la lección queda marcada en el mapa; nunca bloquea un nivel.

Un informe cuenta pedidos con WHERE order_date BETWEEN '2025-12-01' AND '2025-12-31' y obtiene 752. El total real de diciembre es 777. ¿Qué salió mal?

Elige qué puede ejecutarse en este sitio. Puedes cambiarlo cuando quieras desde "Configurar cookies", al pie de cada página.