Saltar al contenido

lección 12

Fechas sin bugs

Trabajar con fechas en SQL sin los errores clásicos: rangos, truncado, zonas horarias y el bug de "el último día del mes".

45 min

El 29 de febrero de 2024 rompió tres dashboards en una empresa donde trabajé. Uno sumaba las ventas de febrero con WHERE fecha BETWEEN «2024-02-01» AND «2024-02-28» — se comió un día entero de facturación porque ese año era bisiesto. Otro agrupaba por mes truncando con fecha::date y arrastraba los pedidos de madrugada de Madrid al día anterior en UTC — porque las 00:30 del 1 de febrero en Madrid son las 23:30 del 31 de enero en UTC. El tercero simplemente restaba 30 días para obtener «el mes pasado» y febrero le devolvía datos de enero Y marzo. Ninguno de los tres lanzó un error. Los tres devolvieron un número limpio, seguro de sí mismo y falso. Eso es lo que hace tan peligrosas las fechas en SQL: el bug no peta, no se pone rojo, no te avisa. Te da una cifra incorrecta con la misma cara de confianza que una correcta, y esa cifra viaja hasta una reunión de dirección.

Las fechas son la fuente número uno de bugs silenciosos en análisis de datos. Un bug (error de programa) normal te avisa: la consulta peta, sale un mensaje rojo, te enteras. El bug de fechas es peor porque no peta. Te da un total, un recuento, un porcentaje, y ese resultado se pega en una diapositiva y viaja hasta una reunión de dirección donde alguien toma una decisión con él. El detective que llevas dentro tiene que aprender a desconfiar de las fechas antes de que nadie le diga que hay un problema.

En esta lección vamos a desmontar los errores clásicos uno a uno: la diferencia entre guardar solo el día y guardar el día con su hora, el bug del rango que se come el último día, cómo agrupar por mes sin equivocarte, cómo sumar y restar fechas, y por qué un pedido de las 23:30 en Madrid puede aparecer en otro día cuando lo miras en otra zona horaria. Todo con DuckDB, la base de datos con la que estás practicando, que interpreta las fechas igual que PostgreSQL.

### DATE y TIMESTAMP: el día contra el instante

SQL guarda las fechas de dos formas, y confundirlas es el origen de la mitad de los bugs. Un DATE guarda solo el día: "2024-01-31", sin hora. Es como una casilla de un calendario de pared: cabe todo lo que pasa ese día, de las 00:00 a las 23:59. Un TIMESTAMP guarda el día y la hora exacta: "2024-01-31 20:15:00". Es un instante concreto, un punto clavado en la línea del tiempo, como la marca de hora de un tique de compra.

DATE es una casilla del calendario; TIMESTAMP es un instante con hora.

Cuando escribes una fecha entre comillas, como "2024-01-31", SQL la interpreta por defecto como el instante justo del comienzo del día: la medianoche, las 00:00:00. Esto no importa nada mientras tu columna sea DATE, porque ahí no hay horas. Pero en cuanto la columna es TIMESTAMP, esa medianoche se convierte en una frontera peligrosa, y ahí nace el bug del rango que veremos ahora.

1-- Una cadena de texto se convierte a fecha segun el tipo
2SELECT DATE '2024-01-31' AS solo_dia;
3SELECT TIMESTAMP '2024-01-31' AS con_hora; -- 2024-01-31 00:00:00
4SELECT TIMESTAMP '2024-01-31 20:15:00' AS instante;
5
6-- Al comparar, '2024-01-31' vale la medianoche de ese dia
7SELECT TIMESTAMP '2024-01-31 20:15:00' > '2024-01-31' AS despues_de_medianoche; -- true

Una fecha sin hora se interpreta como la medianoche (00:00:00) de ese día.

### El bug clásico del rango

Aquí está el caso del lunes por la mañana. Quieres los pedidos de enero y escribes lo que parece natural: WHERE creado_en BETWEEN "2024-01-01" AND "2024-01-31". BETWEEN significa "entre estos dos valores, ambos incluidos". Suena perfecto: del 1 al 31, enero entero. El problema es que "2024-01-31" no vale el 31 completo, vale la medianoche del 31. Así que tu filtro incluye solo los pedidos del 31 que ocurrieron exactamente a las 00:00:00, y descarta todo lo que pasó a partir de las 00:00:01. Un pedido de las 20:15 del 31 se queda fuera. Todo el día laborable del 31 desaparece del informe.

BETWEEN corta en la medianoche del 31; el rango medio abierto abarca enero entero.

La forma correcta es el rango medio abierto: mayor o igual que el primer día del mes, y estrictamente menor que el primer día del mes siguiente. Se escribe WHERE creado_en >= "2024-01-01" AND creado_en < "2024-02-01". El truco es el "menor que" en el límite de arriba: incluyes todo lo que pasa antes de la medianoche del 1 de febrero, es decir, hasta las 23:59:59.999 del 31 de enero. Enero entero, sin excepciones, sin importar la hora de cada pedido. Se llama medio abierto porque el límite de abajo está cerrado (lo incluye) y el de arriba está abierto (no lo incluye).

1-- INCORRECTO con columnas TIMESTAMP: se come el ultimo dia
2SELECT COUNT(*) AS pedidos_enero
3FROM pedidos
4WHERE creado_en BETWEEN '2024-01-01' AND '2024-01-31';
5
6-- CORRECTO: rango medio abierto
7SELECT COUNT(*) AS pedidos_enero
8FROM pedidos
9WHERE creado_en >= '2024-01-01'
10 AND creado_en < '2024-02-01';

El rango medio abierto (>= inicio, < inicio del periodo siguiente) captura el periodo completo.

No uses BETWEEN con columnas TIMESTAMP para filtrar por periodos. BETWEEN incluye ambos extremos, y el extremo de arriba se interpreta como la medianoche de ese día, así que pierdes casi todo el último día. La regla que nunca falla: >= inicio del periodo AND < inicio del periodo siguiente. Con columnas DATE (sin hora) BETWEEN sí funciona, pero acostúmbrate al rango medio abierto y no tendrás que preguntarte de qué tipo es la columna cada vez.

Consejo de senior: cuando heredes una consulta ajena, lo primero que miro en el WHERE de fechas es si usa BETWEEN sobre una columna con hora. Es el error más repetido que existe y casi nunca salta a la vista, porque el número que devuelve parece razonable. Un total "casi bien" es más peligroso que uno absurdo: el absurdo lo pillas, el "casi bien" se cuela hasta la reunión.

### Truncar y extraer: agrupar por mes y sacar partes

La pregunta de negocio más frecuente sobre fechas es "dámelo por mes". Tienes miles de pedidos con su instante exacto y quieres una fila por mes con el total. La herramienta es DATE_TRUNC, que significa "truncar la fecha": recorta la fecha hasta la unidad que le pidas y tira el resto. DATE_TRUNC("month", fecha) coge cualquier día de enero, a cualquier hora, y lo aplasta al 1 de enero a las 00:00. Todos los pedidos de enero acaban con la misma etiqueta, así que un GROUP BY los junta en un solo grupo.

DATE_TRUNC aplasta cada fecha a su mes; el GROUP BY suma dentro de cada cubo.

Su compañero es EXTRACT, que hace lo contrario: no agrupa, saca una parte concreta de la fecha como un número. EXTRACT(year FROM fecha) te da el año, EXTRACT(month FROM fecha) el número del mes (1 a 12), EXTRACT(day FROM fecha) el día del mes (1 a 31). Y para el día de la semana hay dos variantes que conviene no confundir: EXTRACT(dow FROM fecha) numera de 0 (domingo) a 6 (sábado), mientras que EXTRACT(isodow FROM fecha) usa el estándar ISO y numera de 1 (lunes) a 7 (domingo). Elige una y sé consistente, porque mezclarlas mueve tus totales un día entero.

1-- DATE_TRUNC: agrupar por mes (aplasta cada fecha al dia 1 del mes)
2SELECT DATE_TRUNC('month', creado_en) AS mes,
3 SUM(importe) AS total
4FROM pedidos
5GROUP BY mes
6ORDER BY mes;
7
8-- EXTRACT: sacar partes de la fecha como numero
9SELECT EXTRACT(year FROM creado_en) AS anio,
10 EXTRACT(month FROM creado_en) AS mes_num,
11 EXTRACT(isodow FROM creado_en) AS dia_semana -- 1=lunes ... 7=domingo
12FROM pedidos
13LIMIT 5;

DATE_TRUNC agrupa por unidad de tiempo; EXTRACT saca una parte como número.

### Aritmética de fechas: sumar, restar y medir distancias

Las fechas se pueden sumar y restar como si fueran números, y esto resuelve muchas preguntas de negocio. Para desplazarte en el tiempo se usa INTERVAL, que es una cantidad de tiempo con su unidad: INTERVAL 7 DAY son siete días, INTERVAL 1 MONTH es un mes. Sumar un intervalo a una fecha te da otra fecha: fecha + INTERVAL 30 DAY es "treinta días después". Restar dos fechas de tipo DATE te da directamente el número de días entre ellas, que es justo lo que necesitas para calcular la antigüedad de un cliente o cuántos días tardó en pagar una factura.

1-- Sumar y restar tiempo con INTERVAL
2SELECT DATE '2024-01-31' + INTERVAL 1 MONTH AS un_mes_despues; -- 2024-02-29
3SELECT DATE '2024-03-15' - INTERVAL 7 DAY AS hace_una_semana; -- 2024-03-08
4
5-- Restar dos DATE da el numero de dias entre ambas
6SELECT DATE '2024-06-01' - DATE '2024-01-01' AS dias_de_diferencia; -- 152
7
8-- DATEDIFF pide la unidad de forma explicita (mas legible)
9SELECT DATEDIFF('day', DATE '2024-01-01', DATE '2024-06-01') AS dias;
10SELECT DATEDIFF('month', DATE '2024-01-01', DATE '2024-06-01') AS meses;

INTERVAL desplaza en el tiempo; la resta y DATEDIFF miden la distancia entre dos fechas.

### El bug del último día del mes

Hay una variante del bug del rango que merece nombre propio, porque cae hasta gente con experiencia. Para filtrar un mes, mucha gente calcula "el último día del mes" y filtra hasta ahí: WHERE fecha <= "2024-01-31". El problema es doble. Primero, si la columna tiene hora, ya sabes que te comes el 31 entero. Y segundo, aunque no tuviera hora, calcular el último día del mes es frágil: febrero tiene 28 o 29 días según el año, los demás meses alternan entre 30 y 31, y tarde o temprano codificas mal ese número. Todo ese cálculo desaparece si usas el rango medio abierto: no necesitas saber qué día termina enero, solo que enero termina cuando empieza febrero.

Por eso el patrón >= inicio AND < inicio_del_siguiente es tan potente: no depende de cuántos días tiene el mes. "El mes que viene empieza el día 1" es cierto para todos los meses del calendario, siempre. Le pides a SQL el primer día del mes siguiente, que siempre existe y siempre es el día 1, y dejas que la base resuelva sola dónde termina el mes actual. Menos aritmética, menos casos especiales, cero bugs de febrero.

Consejo de senior: esto te lo van a preguntar en una entrevista de analista, casi con estas palabras: "¿cómo filtrarías los pedidos de un mes concreto?". La respuesta que separa al junior del que ya se ha quemado es el rango medio abierto, y saber explicar por qué: no depende de las horas de la columna ni del número de días del mes. Si además mencionas que evita el bug de febrero, has ganado puntos.

### Zonas horarias: el pedido que cambia de día

Último aviso, breve pero importante. Una zona horaria es el desfase de hora de un lugar respecto a UTC, la hora de referencia mundial (Madrid va una o dos horas por delante según sea invierno o verano). Un pedido hecho a las 23:30 del 31 de enero en Madrid ocurre a las 22:30 del 31 en UTC ese mismo día, pero un pedido de las 00:30 del 1 de febrero en Madrid es todavía el 31 de enero a las 23:30 en UTC. Es decir: el mismo instante puede caer en un día distinto según la zona horaria en la que lo mires.

Esto muerde justo cuando agrupas por día. Si tus datos vienen guardados en UTC y tu negocio funciona en horario de Madrid, los pedidos de la última media hora de cada día pueden aparecer contados en el día siguiente o anterior. No es un fallo de SQL, es un desajuste de zona horaria, y el síntoma típico es que los totales por día "no cuadran" con lo que ve el equipo comercial. La regla práctica de momento: averigua siempre en qué zona horaria están guardadas tus fechas antes de agrupar por día, y si hace falta, conviértelas a la zona del negocio antes de truncar.

Antes de agrupar ventas o eventos por día, pregunta en qué zona horaria están guardadas las fechas. Si están en UTC y tu negocio opera en Madrid, los movimientos de la franja de medianoche se contarán en el día equivocado, y tus totales diarios no cuadrarán con los del equipo comercial. Es un bug silencioso más: nadie ve un error, solo números que no encajan.

### Resumen: la lista del detective de fechas

  • DATE guarda solo el día; TIMESTAMP guarda día y hora. Una fecha entre comillas sin hora vale la medianoche (00:00:00) de ese día.
  • Nunca uses BETWEEN sobre columnas TIMESTAMP para periodos: se come casi todo el último día.
  • Filtra periodos con el rango medio abierto: >= inicio AND < inicio del periodo siguiente. Funciona para días, meses, trimestres y años.
  • DATE_TRUNC("month", fecha) agrupa por mes; EXTRACT saca partes de la fecha como número. Cuidado con dow (domingo=0) frente a isodow (lunes=1).
  • INTERVAL desplaza en el tiempo; restar dos DATE da los días entre ambas; DATEDIFF mide la distancia pidiendo la unidad.
  • El rango medio abierto evita el bug del último día del mes porque no necesita saber cuántos días tiene el mes.
  • Antes de agrupar por día, confirma la zona horaria de los datos: un evento de medianoche puede caer en otro día en UTC.

Regístrate para guardar tu progreso.

## comentarios

Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.

Inicia sesión para comentar y responder.

cargando comentarios...