DevKitLab Logo DevKitLab
Zonas horarias / Fecha y hora / DST / Depuración

¿Por qué mi fecha cambia de día? Zonas horarias y fechas de calendario

Guardas un cumpleaños como 1 de mayo y se muestra el 30 de abril. Programas un evento para el 15 de julio y el calendario marca el 14. El informe diario cuenta la fila equivocada. La causa casi siempre es la misma: una fecha de calendario no es un punto en el tiempo, y el bug ocurre en la frontera donde una fecha sin zona se cruza con un instante con zona.

Guardas el cumpleaños de un usuario como 1 de mayo y se muestra como 30 de abril. Programas un evento para el 15 de julio y el calendario enseña el 14. Ejecutas un informe diario y la fila que ocurrió justo antes de medianoche cuenta bajo el día equivocado. Todos son el mismo bug con una apariencia distinta, y la respuesta habitual —sumar unas horas, restar un día, envolverlo en otra conversión— arregla el caso que tienes delante y rompe dos más.

Aquí está el hecho que hay debajo de todos: una fecha de calendario no es un punto en el tiempo. «2026-07-15» no nombra un momento; nombra un día entero —el lapso de una medianoche local a la siguiente—, y ese lapso empieza en un instante distinto en cada zona horaria. La medianoche del día 15 en Tokio llega dieciséis horas antes que la medianoche del 15 en Los Ángeles. Extiende eso por el planeta y una sola fecha de calendario es «hoy» en algún lugar de la Tierra durante unas 50 horas: desde que empieza en las zonas UTC+14 del extremo oriental hasta que por fin termina en UTC−12. Una fecha es un lapso; un instante es un punto. El bug de «un día de desfase» es lo que pasa en la frontera entre ambos: cuando conviertes una fecha sin zona en un instante con zona, o exprimes un instante con zona de vuelta a una fecha, usando una zona horaria que no querías.

Este artículo recorre esa frontera en las dos direcciones. Convertir una fecha en un instante (la cadena de solo fecha que se vuelve ayer). Convertir un instante en una fecha (toISOString informando mañana). El arreglo de fondo: reconocer las fechas que nunca debieron ser instantes, como cumpleaños y fechas de vencimiento. Luego el horario de verano, que hace resbalar hasta la aritmética de fechas dentro de una misma zona; y la agrupación por día en informes, donde la zona en que truncas decide en qué día cae una fila. Es la tercera pieza junto a por qué un timestamp Unix da la hora equivocada y el repaso de UTC / GMT / ISO 8601; esas dos explican el instante y la zona, y esta se centra en la fecha de calendario con la que chocan una y otra vez.

La única idea: una fecha es un lapso, no un momento

Mantén dos cosas separadas y casi toda la confusión se despeja:

  • Un instante es un único punto en la línea temporal: un timestamp Unix, una hora anclada con Z. El mismo para todos, en todas partes.
  • Una fecha de calendario —«2026-07-15»— es una etiqueta del calendario civil para el lapso de una medianoche local a la siguiente. Suele durar unas 24 horas, pero un cambio de horario de verano puede acortarlo o alargarlo; por ejemplo, puede durar 23 o 25 horas. No tiene un instante único, porque empieza y termina en un momento distinto en cada zona.

Son tipos de cosa diferentes, y cada bug de desfase de fecha es una conversión mal hecha entre ambos. Lo complicado es que la conversión suele ser invisible: en cuanto metes una fecha en un objeto Date, en una cadena ISO con hora, o en una columna timestamp cuyo tipo u ORM aplica semántica de zona, una zona horaria puede colarse sin avisar; y si es UTC cuando querías local, o local cuando querías UTC, la fecha cae al lado equivocado de alguna medianoche. (Las bases de datos difieren aquí —timestamp with time zone, without time zone y cada ORM se comportan distinto—, así que comprueba la tuya.) Nada se «corrompe». Solo cruzaste la frontera fecha/instante en la zona equivocada.

Dirección uno: convertir una fecha en un instante

La versión más común. Tienes una fecha simple —"2026-07-15"— y la haces pasar por un tipo basado en momentos. En JavaScript:

new Date("2026-07-15");     // se parsea como 2026-07-15T00:00:00Z — medianoche UTC

En el parser Date de JavaScript, una cadena ISO de solo fecha se parsea como medianoche UTC (otros lenguajes, bases de datos y librerías difieren, así que confírmalo en cada stack). Ese es un instante concreto, y ahora su fecha de calendario depende de dónde lo representes. En cualquier sitio con offset negativo, ese instante sigue siendo la tarde anterior:

// navegador en Los Ángeles (UTC−7 en verano):
new Date("2026-07-15").toLocaleDateString("en-US"); // "7/14/2026"

Guardaste el 15; el usuario ve el 14. El instante estaba bien —2026-07-15T00:00:00Z es justo lo que pediste—, pero «el 15 a medianoche UTC», visto desde América, sigue siendo el 14 en la pared. El arreglo es no lavar una fecha simple a través de un instante: mantenla como la cadena "2026-07-15" (o un tipo de fecha real) y formatéala sin conversión de zona. Si de verdad necesitas un instante, interpreta la hora de pared local en una zona IANA elegida explícitamente con una librería que gestione correctamente las zonas horarias (o Temporal): un 2026-07-15T00:00:00 a secas se lee en la zona del propio runtime (la del servidor, si corre ahí), no la de un usuario cualquiera. Es la misma trampa que el artículo del timestamp trata desde el lado del parseo; aquí es el lado del almacenamiento de la misma moneda.

Dirección dos: convertir un instante en una fecha

Ahora el caso inverso, y sorprende a quien «lo hizo todo en UTC como le dijeron». Tienes un instante correcto y quieres su fecha, así que echas mano del corte más rápido:

// un momento real: 22:00 del 15 de julio en Nueva York (UTC−4 en verano)
const d = new Date("2026-07-15T22:00:00-04:00");
d.toISOString();            // "2026-07-16T02:00:00.000Z"
d.toISOString().slice(0,10);// "2026-07-16" — mañana

toISOString() siempre representa en UTC, así que cortar los primeros diez caracteres te da la fecha UTC. Para una tarde de Nueva York, UTC ya pasó al día siguiente, y tu «fecha» es mañana. Extraer una fecha de un instante es una conversión, y la zona por defecto es UTC lo pidieras o no. El arreglo es formatear el instante en la zona que realmente necesitas:

new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" }).format(d);
// "2026-07-15" — la fecha que era en Nueva York

(en-CA resulta en YYYY-MM-DD en la práctica, pero la salida de Intl es una cadena localizada, no un formato de máquina garantizado; para un YYYY-MM-DD estable, ensámblalo desde las piezas de formatToParts().) Elige la zona a conciencia —la del usuario, la del negocio, UTC si es lo que buscas— pero nunca dejes que toISOString().slice(0,10) la decida por ti.

El arreglo de fondo: algunas fechas nunca fueron instantes

Las dos direcciones de arriba son conversiones entre una fecha y un instante. Pero el arreglo más fuerte suele ser notar que algunos valores son fechas y nada más, y no deberían tocar un tipo de instante. Un cumpleaños, una fecha de vencimiento, un festivo, la fecha de una factura, un evento de «todo el día»: son fechas de calendario flotantes. El 1 de mayo de 1990 es el mismo 1 de mayo en Tokio y en Chicago; no tiene hora ni zona.

Guarda una de esas como timestamp y habrás fabricado el desfase: 1990-05-01 se vuelve 1990-05-01T00:00:00Z, que al oeste de Greenwich es la tarde del 30 de abril, y el cumpleaños ahora se muestra un día antes para una parte de tus usuarios. El arreglo es cuestión de tipo, no de conversión: guarda las fechas de calendario en un tipo date (el DATE de SQL, o simplemente la cadena "1990-05-01"), compáralas y muéstralas como fechas, y nunca las hagas dar la vuelta por un instante con zona. La regla que evita la mayoría de estos bugs es una pregunta hecha pronto: ¿este valor es un instante (algo que ocurrió en un momento) o una fecha de calendario (un día del calendario civil)? Guárdalo como lo que es:

cumpleaños  → DATE          "1990-05-01"                 (una fecha de calendario — sin hora ni zona)
creado_en   → timestamp     "2026-07-15T22:00:00-04:00"  (un instante)

Este «decide primero el tipo y luego qué guardar» es justo lo que recorre de principio a fin ¿Guardo una fecha como timestamp o como cadena ISO? —con las concesiones entre un timestamp entero, una cadena ISO y los tipos de columna nativos.

Y para un evento futuro o recurrente —«las 9:00 del día 3, el año que viene»— guarda el nombre de zona IANA (America/New_York) junto a la hora local, no un offset fijo: el offset que aplique entonces puede cambiar cuando cambien las reglas de DST de la zona.

Horario de verano: el día que no tiene 24 horas

Incluso dentro de una sola zona, la aritmética de fechas resbala, porque un «día» es una idea de calendario, no 86 400 segundos fijos. Cuando una región adelanta el reloj una hora, el día local dura 23 horas; cuando lo atrasa una hora, 25. Así que «sumar un día» implementado como sumar 24 horas se desvía al cruzar un límite de DST:

// Nueva York adelanta el 2026-03-08
const before = new Date("2026-03-07T12:00:00-05:00"); // sábado mediodía
new Date(before.getTime() + 86400000);
// → domingo 2026-03-08 13:00 en Nueva York — una hora tarde, porque ese día perdió una hora

Acumula suficientes de esos, o deja caer uno cerca de medianoche, y la fecha misma puede resbalar. Dos peligros relacionados vienen de la mano: al adelantar, algunas horas de reloj no existen (la hora saltada, así que una «medianoche» o «2:30» ingenua puede ser inválida); al atrasar, algunas ocurren dos veces (ambiguas). Por eso «el inicio del día», «sumar un mes» y «la misma hora la semana que viene» deben calcularse sobre el calendario con una librería que gestione correctamente las zonas horarias, no sumando segundos a un timestamp. Para la versión de cada día —cuántos días entre dos fechas, qué fecha es dentro de 30 días— una calculadora de fechas hace el recuento sin que tengas que razonar si un límite de DST cayó en medio.

Agrupar por día: la zona decide en qué día cae

El último desfase común aparece en informes y analítica. Agrupas timestamps «por día», y el día en que cae una fila depende por completo de la zona en que truncas:

const evt = new Date("2026-07-15T23:30:00-05:00"); // 23:30 en Chicago
evt.toISOString().slice(0,10);        // "2026-07-16"  ← agrupado en UTC
// pero en Chicago sigue siendo: 2026-07-15

Trunca el timestamp de ese evento en UTC y cuenta bajo el 16; el informe del negocio lo cuenta bajo el 15. Haz esto en todo un conjunto de datos y cada fila cercana a la medianoche local cae en el día vecino, así que «el total de ayer» está silenciosamente mal —no por mucho, que es justo por lo que sobrevive hasta producción—. El arreglo es elegir una zona de informe (normalmente la del negocio, a veces la de cada usuario) y truncar cada timestamp en esa zona, de forma consistente. Para ver cómo un mismo instante se asigna a una fecha en las zonas donde de verdad viven tus usuarios, échalo a un conversor de zonas horarias y mira cómo cambia la fecha de calendario mientras el instante no se mueve.

La lista para una fecha que se movió

Cuando una fecha sale un día antes o después, no empieces a sumar horas. Pregunta esto en orden:

  1. ¿Este valor es una fecha o un instante? Un cumpleaños, un festivo o un vencimiento es una fecha de calendario: guárdalo como DATE/cadena y mantenlo lejos de los tipos de instante. Un «creado en» es un instante. La mayoría de los bugs de desfase son una fecha que se guardó o parseó como instante.
  2. ¿Conviertes una fecha en un instante? En JavaScript, una cadena de solo fecha se parsea como medianoche UTC, así que se lee como el día anterior en cualquier zona de offset negativo. No hagas pasar una fecha simple por new Date(...); si debes, fija la zona explícitamente.
  3. ¿Conviertes un instante en una fecha? toISOString().slice(0,10) da la fecha UTC, que es mañana para timestamps de tarde en América. Formatea en la zona que quieres —new Intl.DateTimeFormat("en-CA", { timeZone }).format(d), o arma YYYY-MM-DD desde formatToParts().
  4. ¿Haces aritmética de fechas? «Sumar un día» no es «sumar 86 400 segundos» al cruzar un cambio de DST. Usa una librería consciente de zonas, o una herramienta para contar días a secas.
  5. ¿Agrupas por día? Trunca cada timestamp en una zona de informe elegida a propósito, o las filas cercanas a medianoche se dispersan al día equivocado.

Bajo las cinco está la única distinción: un instante es un punto, una fecha de calendario es un lapso, y solo se encuentran a través de una zona horaria. Decide, para cada valor con forma de fecha, cuál de los dos es —y nombra la zona siempre que cruces entre ellos—. Hazlo y la fecha deja de derivar, porque ya no dejas que una zona por defecto elija el día por ti.