¿Por qué mi timestamp Unix está mal? Segundos, milisegundos y zonas horarias
Un timestamp que muestra la hora equivocada casi nunca es un número roto: es un error de categoría. Un timestamp Unix no es una fecha y no tiene zona horaria; es un único recuento de segundos desde un instante fijo. En cuanto separas el instante de cómo se representa, «desfasado por décadas», «desfasado por tres horas» y «desfasado por un día» se reducen cada uno a una causa concreta y localizable.
Registras un timestamp y sale mal. A veces de forma espectacular: un registro que creaste esta mañana afirma que ocurrió en enero de 1970, o en el año 55840. A veces de forma silenciosa: la hora es correcta pero va tres horas desfasada, o una fecha cae en el día 14 cuando querías el 15. Así que empiezas a parchear: multiplicar por algo, restar unas horas, meterlo dentro de otra conversión. Cada arreglo funciona con el único ejemplo que tienes delante y rompe el siguiente, porque estás tratando síntomas sin el único hecho que los explica todos a la vez.
Ese hecho es: un timestamp Unix no es una fecha y no tiene zona horaria. Es un solo entero —la cantidad de segundos transcurridos desde un instante fijo, 1970-01-01T00:00:00Z— y ese mismo entero nombra el mismo momento en cualquier parte de la Tierra. No sabe en qué año estamos, qué calendario usas ni si estás en Tokio o en Chicago. Todo ese significado se añade después, cuando representas el número como algo que un humano lee. Casi todos los bugs de «timestamp incorrecto» no son en absoluto un número incorrecto. Son un error de categoría: confundir el instante con la forma en que se muestra, o pasar un valor a una función que esperaba otra unidad.
Este artículo separa esas dos capas y las mantiene separadas. Primero, qué es de verdad un timestamp: un número, sin zona. Luego, el puñado de sitios donde ambas capas se cruzan: el intercambio segundos-por-milisegundos que te lanza a 1970, la confusión de zona horaria que desplaza un instante correcto unas horas, la regla de parseo de cadenas que mueve una fecha un día entero, y las trampas de aritmética de calendario en torno al horario de verano. Al final tendrás una lista corta que aísla casi cualquier bug de timestamp a una causa concreta.
La única idea: un instante es un número; una fecha es una representación de él
Hay dos capas, y casi todos los bugs viven en el hueco entre ellas.
- El instante. Un punto absoluto en la línea temporal del universo. En informática suele ser un timestamp Unix: un entero, sin zona horaria, el mismo valor para todos.
1700000000es un momento concreto; es ese momento en São Paulo y en Seúl a la vez. - La representación. Una fecha y hora legible para humanos —
"2023-11-14 22:13:20", o"14 nov, 17:13"— que solo significa algo una vez que también dices en qué zona horaria está expresada. El mismo instante se representa como una cadena de reloj distinta en cada zona.
El instante es la verdad que almacenas, transmites y comparas. La representación es una vista de él, producida bajo demanda para un humano, en una zona concreta, con un formato regional concreto. Ten presentes esas dos palabras —instante y representación— durante el resto del artículo, porque cada trampa de aquí abajo es alguna versión del mismo error: tratar una representación como si fuera el instante, o convertir entre ambos equivocando la unidad o la zona.
Qué es realmente un timestamp Unix
Quita las herramientas y un timestamp Unix es de una simplicidad casi vergonzosa: cuenta los segundos transcurridos desde 1970-01-01T00:00:00Z, un origen arbitrario llamado época Unix (Unix epoch). Un valor de 0 es esa medianoche de 1970; 1700000000 son unos 53,9 años después; las fechas anteriores a 1970 son sencillamente negativas.
Dos cosas que la gente confunde sin darse cuenta:
- No está «en UTC» del modo en que lo está una fecha representada. El número en sí no lleva ninguna zona. UTC aparece solo en dos sitios: como etiqueta de la época (el recuento arranca en la medianoche UTC de 1970) y como la zona neutral a la que normalmente representarás. El entero
1700000000no es una hora UTC ni una hora local: es un instante, y «UTC» no es más que el dialecto más cómodo para leerlo en voz alta. - Ignora los segundos intercalares. El UTC real ha tenido más de dos docenas de segundos intercalares añadidos desde 1972 para mantener los relojes alineados con la rotación algo irregular de la Tierra. La hora Unix hace como si nunca hubieran ocurrido: trata cada día como exactamente 86 400 segundos. Eso hace la aritmética limpia y reversible —puedes convertir un timestamp a fecha y de vuelta con una simple división— a costa de que el recuento no sea un cómputo perfectamente fiel de segundos físicos. En el trabajo diario esto nunca importa; conviene saberlo solo para que la frase «segundos desde la época» no te haga esperar precisión astronómica.
Esa simplicidad es todo su atractivo. Comparar dos instantes es comparar dos enteros; ordenar eventos es ordenar números. No hay calendario, ni zona, ni ambigüedad, hasta que conviertes hacia o desde una fecha humana, que es justo donde empiezan los bugs.
El bug número uno: segundos frente a milisegundos
Es la forma más común en que un timestamp sale mal, y merece su propia sección porque el fallo es de lo más espectacular. Existen dos convenciones rivales para «el recuento desde 1970»:
- Convención Unix / POSIX: segundos.
date +%sen la línea de comandos, los claimsexpeiatde un JWT, la mayoría de funciones de época de las bases de datos, la mayoría de llamadas al sistema de Unix: todo en segundos. - Convención de JavaScript: milisegundos.
Date.now()ynew Date().getTime()devuelven milisegundos desde la época, mil veces mayores.
Mezcla las dos y te desvías por un factor de 1000, que en la línea temporal es un factor de décadas. La versión clásica, en JavaScript:
// Un timestamp Unix en SEGUNDOS, p. ej. de una API o del claim exp de un JWT:
const ts = 1700000000;
new Date(ts); // Tue Jan 20 1970 — el constructor Date quiere MILISEGUNDOS
new Date(ts * 1000); // Tue Nov 14 2023 — correcto
new Date(1700000000) no da error. Interpreta alegremente tus segundos como milisegundos, aterriza 1700 millones de milisegundos (unos 20 días) después de la época y te entrega una fecha de enero de 1970. En sentido inverso, si necesitas segundos a partir de los milisegundos de JavaScript, debes dividir y aplicar floor:
Math.floor(Date.now() / 1000); // timestamp Unix en segundos
La señal, para la era actual, es el número de dígitos: un timestamp en segundos tiene 10 dígitos (1700000000) y uno en milisegundos tiene 13 (1700000000000). Si un valor cercano a 1700 millones produjo una fecha de 1970, le diste segundos a algo que quería milisegundos; si un valor cercano a 1,7 billones (en escala larga) produjo una fecha a decenas de miles de años, hiciste lo contrario. Cuando no sepas qué unidad tienes en la mano, pega el número crudo en un conversor de timestamps Unix: lo lee de las dos formas y te muestra cuál cae en una fecha plausible, así que resuelves la unidad mirando en lugar de adivinar.
El timestamp no tiene zona horaria: la confusión está en el formateo
Ahora el fallo más silencioso: la fecha está casi bien, pero desfasada unas horas. Aquí el instinto de «arreglar el número» es justo el equivocado, porque el número está bien. Estás representando un instante correcto en la zona horaria equivocada.
Recuerda que el instante no lleva zona. Cuando lo conviertes en texto, tú —o el valor por defecto de tu lenguaje— eliges una zona, y el mismo instante produce cadenas de reloj distintas:
const d = new Date(1700000000 * 1000);
d.toISOString(); // "2023-11-14T22:13:20.000Z" — siempre UTC, marcado por la Z final
d.toString(); // "Tue Nov 14 2023 17:13:20 GMT-0500 ..." — la zona local del runtime
d.toLocaleString(); // una representación específica de la configuración regional y la zona
Cada línea de arriba describe el mismo instante. 22:13:20Z y 17:13:20, cinco horas por detrás, no son dos horas distintas: son un solo momento nombrado en dos zonas. Así que cuando una entrada de log dice «tres horas antes de lo que debería», la causa habitual no es un timestamp corrupto; es que el valor se formateó en UTC donde esperabas hora local, o en la zona del servidor donde esperabas la del usuario. El arreglo es controlar la zona en el punto de visualización, no sumar o restar horas al valor almacenado: incrusta un desfase en el número y habrás corrompido lo único que estaba bien.
Esta es también la regla para almacenar y transmitir tiempo: mantén el instante neutral respecto a la zona —un timestamp Unix, o una cadena ISO 8601 con un desfase explícito como 2023-11-14T22:13:20Z— y aplica una zona solo en el borde final, cuando un humano necesita leerlo. (Cuál de esas dos notaciones —un timestamp entero o una cadena ISO— usar de verdad en una base de datos es un artículo aparte sobre esa concesión de almacenamiento.) Una hora escrita como texto de reloj local pelado, sin desfase ("2023-11-14 17:13:20"), ha tirado a la basura la información necesaria para saber qué instante era. Para ver un instante repartido por las zonas donde de verdad viven tus usuarios, un conversor de zonas horarias las coloca en paralelo, lo que también deja obvio un bug de «desfase por horas»: el momento es consistente, solo cambian las etiquetas.
La trampa del parseo: ¿a qué zona se refería la cadena?
Convertir una cadena de vuelta en un instante tiene su propio filo, y es responsable de una enorme parte de los bugs de «la fecha va un día por delante». En JavaScript —y este comportamiento está especificado, no es una rareza de un motor concreto— la zona que se asume para una cadena de fecha depende de si incluye una hora:
new Date("2026-07-15"); // solo fecha → se parsea como medianoche UTC
new Date("2026-07-15T00:00:00"); // fecha + hora, sin desfase → se parsea como hora LOCAL
Lee eso dos veces, porque es genuinamente contraintuitivo: añadir un componente de hora invierte la zona asumida. Una cadena ISO de solo fecha se interpreta como UTC. En cuanto le añades una hora sin desfase, el estándar cambia a interpretarla en la zona local del runtime. Cadenas de aspecto casi idéntico, instantes distintos.
El daño visible es el desplazamiento de calendario. Supón que un navegador en Los Ángeles (UTC−7 en verano) ejecuta:
new Date("2026-07-15").toLocaleDateString("en-US"); // "7/14/2026"
Parseaste «15 de julio» y la pantalla muestra el 14 de julio. Nada está roto —la cadena se leyó como 2026-07-15T00:00:00Z, que en hora del Pacífico son las 17:00 del día 14—, pero para el usuario parece que la app va un día por detrás. Cualquier zona de desfase negativo (toda América) puede producir esto, y por eso el bug tiende a llegar a producción sin ser detectado desde un equipo en UTC+algo.
La defensa es un solo hábito: nunca dependas de la zona por defecto; pon un desfase explícito en cada cadena de fecha-hora. Escribe 2026-07-15T00:00:00Z cuando quieras UTC, o 2026-07-15T00:00:00-07:00 cuando quieras un momento local concreto. Con el desfase presente, todos los parsers coinciden en el instante, y la trampa de solo-fecha-frente-a-fecha-hora desaparece por completo.
Desfasado por un día por otro motivo: aritmética de calendario y DST
Incluso cuando el parseo es limpio, hacer aritmética con fechas tiene trampas que un entero pelado no tiene. El instante es un número, así que las diferencias entre instantes son seguras: resta dos timestamps y obtienes un recuento exacto de segundos, sin zona. El peligro está en operar sobre el calendario representado.
Muerde de dos maneras:
- «Sumar 24 horas» no es «la misma hora de reloj mañana». En los días en que una región adelanta o atrasa por el horario de verano (DST), el día local dura 23 o 25 horas. Suma 86 400 segundos a las 9:00 del día previo a un adelanto de primavera y aterrizas en las 10:00, no en las 9:00. Si de verdad quieres «las 9 de mañana, sea cual sea el desfase», tienes que razonarlo en el calendario local, no sumando un número fijo de segundos.
- Algunas horas de reloj no existen, y otras ocurren dos veces. Cuando los relojes se adelantan, la hora saltada (digamos las 2:30) es una hora local que ese día no ocurre nunca; cuando se atrasan, la hora repetida ocurre dos veces, así que una hora local pelada en esa ventana es ambigua sobre qué instante significa.
El patrón fiable es el mismo al que vuelve todo este artículo: haz comparaciones y duraciones sobre el instante neutral, y toca el calendario local solo para mostrar o para preguntas genuinamente de calendario («¿cuántos días faltan para el día 1?»), usando para esas una librería consciente de zonas horarias en lugar de aritmética hecha a mano. Para la versión común y sin dramatismo de esto —cuántos días hay entre dos fechas, qué fecha es dentro de 90 días, cuántos años tiene algo— una calculadora de fechas hace el recuento sin que tengas que razonar sobre si un límite de DST cayó en medio.
En perspectiva: el problema del año 2038
Hay un muro superior integrado en muchos sistemas antiguos. Si un timestamp se almacena como un entero de 32 bits con signo de segundos, el mayor valor que puede contener es 2^31 − 1 = 2147483647, que como hora Unix es:
2038-01-19T03:14:07Z
Un segundo después, el contador se desborda más allá del tope de un entero de 32 bits con signo y da la vuelta hasta un número negativo grande, volteando la fecha de regreso a diciembre de 1901. Este es el problema del año 2038 («Y2038» o el «apocalipsis Unix»), descendiente directo del Y2K, y la razón por la que time_t en los sistemas modernos de 64 bits se amplió a 64 bits (lo que empuja el muro a unos 292 000 millones de años, cómodamente nunca). Sigue acechando en dispositivos embebidos, formatos binarios y de red antiguos, y bases de datos con una columna de época de 32 bits; por ejemplo, el tipo TIMESTAMP de MySQL históricamente topaba en 2038, mientras que DATETIME no. Si una fecha muy futura colapsa en silencio a 1901, un desbordamiento de 32 bits es el primer sospechoso.
El mismo signo corta hacia el otro lado para el pasado: como el entero tiene signo, las fechas anteriores a 1970 son legales y sencillamente negativas (-1000000000 es abril de 1938). El código que asume erróneamente que un timestamp no tiene signo destrozará cualquier fecha anterior a la época, una preocupación real para fechas de nacimiento, registros históricos y todo lo que se remonte antes de los años setenta.
En perspectiva: no todo cuenta desde 1970
Una última fuente de «desfase por una barbaridad» que no es un desliz de segundos/milisegundos: no todo sistema que llama a algo «timestamp» cuenta desde la época Unix. La unidad puede diferir y el origen también. Algunos que te encontrarás de verdad:
- Las hojas de cálculo (Excel, Google Sheets) cuentan días desde aproximadamente 1899–1900, no segundos desde 1970, así que una fecha pegada desde una hoja como número de serie crudo está en una escala y un origen completamente distintos.
- Windows cuenta en sus tiempos de archivo intervalos de 100 nanosegundos desde el año 1601.
- NTP, el protocolo de tiempo de red, cuenta segundos desde 1900.
Así que antes de fiarte de cualquier conversión, fija ambas coordenadas: la unidad (¿segundos? ¿milisegundos? ¿días? ¿ticks de 100 ns?) y la época (¿1970? ¿1900? ¿1601?). Un valor desfasado por décadas de un modo que un ×1000 no explica suele ser un desajuste de época, no de unidad. Cuando no distingas qué tienes en la mano, pasar el número crudo por un conversor de timestamps y comprobar si la interpretación con época Unix cae en una fecha sensata es lo más rápido para descartar o confirmar el caso común.
La lista para un timestamp incorrecto
La próxima vez que una hora salga mal, no empieces a multiplicar y restar al azar. Recorre esto en orden: aísla casi todos los casos.
- ¿Qué unidad es? Cuenta los dígitos. En esta era, 10 ≈ segundos, 13 ≈ milisegundos. Si un valor de «ahora» produjo una fecha de 1970, diste segundos a algo que quería milisegundos (multiplica por 1000); si produjo una fecha a decenas de miles de años, haz lo contrario.
- ¿Instante o representación? El número almacenado no tiene zona horaria. Si la hora está desfasada por horas completas, casi con seguridad estás formateando un instante correcto en la zona equivocada: corrige la zona al mostrar, nunca sumando horas al valor almacenado.
- ¿Parseando una cadena? ¿Lleva desfase? Una cadena de solo fecha se lee como UTC; una de fecha-hora sin desfase se lee como local, el origen de la mayoría de los bugs de «desfase por un día». Pon una
Zexplícita o±hh:mmen cada cadena de fecha-hora. - ¿Haciendo aritmética de fechas? Restar dos instantes es seguro; sumar cantidades de calendario («mañana», «el mes que viene») sobre hora local no lo es, por los días de 23/25 horas del DST. Hazlo sobre el instante, o con una librería consciente de zonas, y deja el recuento simple de días a una herramienta.
- ¿Desfasado por décadas y la unidad es correcta? Sospecha de otra época —un número de serie de hoja de cálculo, un tiempo de archivo basado en 1601, un valor NTP—: no es en absoluto un timestamp Unix. Y si una fecha muy futura colapsó a 1901, es un desbordamiento de 32 bits con signo.
Bajo las cinco está la única división del principio: el instante es un número neutral respecto a la zona; la fecha en tu pantalla es una representación de él en alguna zona. Lo que está mal casi nunca es el número. Lo que está mal es una unidad cruzada a la entrada, o una zona cruzada a la salida, y en cuanto puedes decir cuál de las dos ocurrió, el timestamp deja de ser un misterio y se convierte en un arreglo de una línea.