Blog

Preguntas de entrevista de SQL: 15 errores que no dan error

26 de septiembre de 2026

En una entrevista de SQL casi nunca te piden que escribas un JOIN desde cero. Lo que sí pasa es que te enseñan una consulta que corre sin errores, devuelve un número creíble, y te preguntan qué tiene mal. Ahí es donde se nota quién ha trabajado con datos de verdad.

Estas son quince de esas preguntas. Cada una tiene la respuesta corta, la forma correcta de escribirla y un enlace a una página con un dataset mínimo para que lo compruebes tú. Todos los ejemplos se probaron en DuckDB, pero casi todo vale igual en PostgreSQL, MySQL o SQL Server; donde no, se dice.

NULL, el que más engaña

¿Por qué NOT IN devuelve cero filas?

Porque basta un NULL en la subconsulta. x NOT IN (a, b, NULL) se evalúa como x <> a AND x <> b AND x <> NULL, y esa última comparación no da verdadero ni falso: da NULL. Una condición con un NULL dentro nunca es verdadera, así que no pasa ninguna fila. Lo peor es que no falla: devuelve cero filas y parece que todo cuadra.

La respuesta que esperan es NOT EXISTS, que pregunta fila por fila:

WHERE NOT EXISTS (SELECT 1 FROM coartadas c WHERE c.nombre = p.nombre)

Detalle y dataset: ¿Por qué NOT IN devuelve cero filas?

¿Por qué COUNT(columna) da menos que COUNT(*)?

Porque no cuentan lo mismo. COUNT(*) cuenta filas. COUNT(columna) cuenta los valores no nulos de esa columna. Si alguien pregunta cuántas llamadas hubo y usas COUNT(numero), las llamadas con número oculto no entran. El número que sale es real, pero responde a otra pregunta.

Detalle y dataset: COUNT(columna) frente a COUNT(*)

¿Por qué mi LEFT JOIN se comporta como un INNER JOIN?

Porque filtraste en el WHERE por una columna de la tabla de la derecha. Las filas sin pareja traen NULL en esas columnas, NULL = 'DHL' da NULL, y el WHERE las descarta. Justo las filas que el LEFT JOIN debía conservar.

Si la condición es parte del emparejamiento, va en el ON:

LEFT JOIN envios e ON e.pedido_id = p.id AND e.transportista = 'DHL'

Detalle y dataset: Un WHERE sobre la tabla derecha convierte tu LEFT JOIN en INNER

Joins que cambian el número de filas

¿Por qué la suma sale inflada después de un JOIN?

Porque el JOIN es de uno a muchos. Si un pedido tiene tres líneas, al unirlo con sus líneas aparece tres veces, y sum(p.importe) lo suma tres veces. Es el error que produce informes con números creíbles y falsos.

La pregunta que conviene hacerse después de cualquier JOIN es cuántas filas esperas que salgan. La corrección es agregar la tabla de muchos antes de unirla:

JOIN (SELECT pedido_id, count(*) AS n FROM lineas GROUP BY pedido_id) l
  ON l.pedido_id = p.id

Detalle y dataset: El join que multiplica filas

¿Por qué un self-join siempre trae una fila de más?

Porque cada fila se empareja consigo misma. Si buscas quién coincidió con quién en la misma sala y a la misma hora, cada estancia solapa perfectamente con su propia copia. Falta excluir la pareja trivial con o.id <> v.id.

Detalle y dataset: ¿Por qué un self-join siempre trae una fila de más?

¿Cuándo se solapan de verdad dos intervalos?

a.desde < b.hasta AND b.desde < a.hasta. Esas dos comparaciones cubren todos los casos: que uno empiece dentro del otro, que termine dentro, que lo contenga o que esté contenido. La única decisión es < o <=. Con <=, un turno que termina a las 22:00 y otro que empieza a las 22:00 cuentan como solapados, aunque no coincidieron ni un segundo.

Detalle y dataset: ¿Cuándo se solapan de verdad dos intervalos de tiempo?

Agregar sin engañarte

¿Por qué el WHERE no encuentra a quien «siempre» paga en efectivo?

Porque el WHERE corre antes del GROUP BY. Si filtras metodo = 'efectivo' y luego pides HAVING count(*) >= 3, encuentras a quien pagó en efectivo al menos tres veces, aunque otras veces haya pagado con tarjeta. «Siempre» describe al grupo entero, y eso se comprueba en el HAVING:

HAVING count(*) >= 3 AND count(*) FILTER (WHERE metodo <> 'efectivo') = 0

FILTER existe en PostgreSQL y DuckDB. En MySQL o SQL Server se escribe SUM(CASE WHEN metodo <> 'efectivo' THEN 1 ELSE 0 END) = 0.

Detalle y dataset: WHERE frente a HAVING

¿Por qué una tienda con dos pedidos aparece como la peor?

Porque ordenaste por una tasa sin exigir un mínimo de casos. El conteo solo señala a quien más vende. La tasa sola señala a quien menos vende, porque con dos pedidos y un reembolso ya tienes un 50 %. Hacen falta las dos cosas: la tasa y un HAVING count(*) >= N que deje fuera las muestras demasiado pequeñas. Y comparar siempre contra la tasa global.

Detalle y dataset: Una tasa sin denominador no significa nada

¿Por qué el promedio histórico sale demasiado bueno?

Porque uniste el histórico con el catálogo de hoy. Si filtras por los productos, clientes o fondos que siguen activos, dejas fuera a los que desaparecieron, que suelen ser justo los que traían malas noticias. La condición correcta es temporal: la fila cuenta si ese elemento existía en la fecha de esa fila.

Detalle y dataset: El sesgo de supervivencia está en el JOIN

Fechas y horas

¿Por qué BETWEEN se come el último día del rango?

Porque sobre un TIMESTAMP, BETWEEN '2026-09-01' AND '2026-09-30' convierte la segunda fecha en 2026-09-30 00:00:00. Todo lo que pasó ese día después de la medianoche queda fuera. El rango semiabierto no tiene ese problema:

WHERE entrada >= '2026-09-01' AND entrada < '2026-10-01'

Detalle y dataset: ¿Por qué BETWEEN se come el último día del rango?

¿Por qué las ventas de la noche aparecen al día siguiente?

Porque agrupaste por día en UTC. Si tus usuarios están en Ciudad de México (UTC-6), todo lo que pasa después de las 18:00 cae en la fecha siguiente. Hay que decidir el huso antes de agrupar, y en DuckDB hacen falta dos conversiones, en este orden: primero decir que el dato guardado está en UTC, después pasarlo a la hora local.

date_trunc('day',
  momento_utc AT TIME ZONE 'UTC' AT TIME ZONE 'America/Mexico_City')

Detalle y dataset: Zonas horarias: guarda UTC, muestra local

Texto que se hace pasar por otra cosa

¿Por qué la transferencia más grande sale como $990?

Porque el monto está guardado como texto, y el texto se ordena letra por letra. '990' va antes que '75000' porque '9' es mayor que '7', sin importar cuántas cifras vengan después. Convierte antes de ordenar con CAST(monto AS BIGINT). En datos reales conviene saber la diferencia con TRY_CAST (DuckDB, SQL Server, Snowflake): el primero revienta si una fila trae '12,000', el segundo la deja en NULL y sigue.

Detalle y dataset: Un número guardado como texto

¿Por qué LIKE no encuentra un nombre que sí está en la tabla?

Porque en PostgreSQL, DuckDB y Oracle LIKE distingue mayúsculas de minúsculas: 'VELA, ANDRES' LIKE '%Vela%' es falso. ILIKE ignora mayúsculas en PostgreSQL y DuckDB; en Oracle se usa lower() en los dos lados. En MySQL y SQL Server depende de la collation, y la de por defecto no distingue mayúsculas, así que ahí esta pregunta tiene trampa al revés. Y queda un problema que ILIKE no arregla: los acentos. Para la base de datos, 'andres' no es 'andrés'.

Detalle y dataset: ¿Por qué LIKE no encuentra un nombre que sí está en la tabla?

Window functions

¿Por qué el saldo acumulado repite el mismo número en dos filas?

Porque sum(importe) OVER (ORDER BY fecha) sin marco usa RANGE por defecto, y RANGE junta todas las filas con la misma fecha. Dos movimientos del mismo día enseñan el mismo saldo, el de después de sumar los dos. Para un saldo corrido de verdad hay que pedir ROWS:

sum(importe) OVER (
  ORDER BY fecha
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)

Detalle y dataset: RANGE frente a ROWS

¿Por qué el WHERE no puede filtrar el resultado de una window function?

Porque el WHERE se evalúa antes de que existan las columnas calculadas con OVER. Esta vez el motor sí avisa: la consulta da error. Las salidas son QUALIFY, que es el HAVING de las ventanas y existe en DuckDB, Snowflake y BigQuery, o envolver la consulta en un CTE y filtrar desde fuera, que funciona en cualquier motor.

Detalle y dataset: QUALIFY frente a WHERE

Cómo practicarlas

Leerlas ayuda, pero se quedan cuando las ves fallar. Las quince están en un repositorio abierto, sql-traps, cada una con su dataset, la consulta rota, la corrección y un script que ejecuta las dos en DuckDB y comprueba que dan resultados distintos. También están en inglés.

Salen de los casos de Caso Abierto, un juego de detectives que se resuelve escribiendo SQL. El primer caso se juega gratis en el navegador. Ahí las trampas no vienen avisadas: están escondidas en los datos.

El primer caso se juega gratis en el navegador, sin cuenta.

Jugar el caso 1 gratis →