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
-
FROM / JOIN¿qué significa una fila? -
WHERE¿qué filas se descartan? -
GROUP BY¿qué se colapsa? -
HAVING¿qué grupos se descartan? -
SELECT¿está bien la cuenta?
- Empieza con
FROMy cadaJOIN. 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. WHEREfiltra filas antes de agrupar. Pregunta qué filas quita, y si una condición sobre una tabla unida convierte en silencio unLEFT JOINen unINNER JOIN.GROUP BYcolapsa 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.HAVINGfiltra los grupos, cuando ya existen los totales. Una regla sobre una sola fila va enWHERE; una regla sobre un total o un conteo va aquí.- Al final,
SELECTcalcula 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
| customer_id | first_name |
|---|---|
| 18 | Linda |
| 1262 | Patricia |
| 1269 | Joseph |
| session_id | customer_id |
|---|---|
| 1 | NULL |
| 2 | 18 |
| 3 | NULL |
| customer_id |
|---|
| customer_id |
|---|
| 1262 |
| 1269 |
- ¿Qué clientes nunca tuvieron una sesión web?
web_sessions.customer_ides NULL para los invitados, así que la lista de la subconsulta contiene NULL, aquí como18, NULL. - La clienta 18 (Linda) está en la lista, así que
NOT INes falso y se descarta. Hasta aquí, bien. - El cliente 1262 nunca tuvo una sesión, así que debería conservarse. Pero
1262 <> 18es verdadero y1262 <> NULLes desconocido, y verdadero AND desconocido es desconocido.WHEREsolo conserva lo verdadero, así que Patricia se descarta, y todos los demás también. NOT EXISTSpregunta 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
| order_id | order_date |
|---|---|
| 12975 | 2025-12-30 21:12:55 |
| 12976 | 2025-12-31 05:18:13 |
| 12977 | 2025-12-31 05:29:21 |
| order_id |
|---|
| 12975 |
| order_id |
|---|
| 12975 |
| 12976 |
| 12977 |
order_datees una marca de tiempo. El texto '2025-12-31' se lee como 2025-12-31 00:00:00, el primer instante del día.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.- 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
- Escribe el número que esperas, de una fuente confiable.
- Ejecuta la consulta con
COUNT(*)después de cada join o filtro y mira dónde salta el conteo. - Sigue un pedido, un cliente o un día a través de la consulta, a mano.
- 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.
No del todo. BETWEEN incluye ambos extremos. El valor final es solo 2025-12-31 00:00:00, y los pedidos posteriores del día 31 vienen después.Prueba otra respuesta.
No del todo. También funciona con marcas de tiempo, y ese es el problema: ningún error y un resultado corto.Prueba otra respuesta.
No del todo. Diciembre tiene 31 días, y el día 31 contiene los 25 pedidos que faltan.Prueba otra respuesta.
Correcto. El texto se convierte en una marca de tiempo al inicio de ese día, así que los pedidos de las 05:18 del día 31 quedan fuera. Un rango semiabierto con < '2026-01-01' cubre todo el último día.
Lección hecha: ya está marcada en el mapa.