Casi todos los bugs de fechas empiezan confundiendo un momento con la lectura de un reloj de pared. Esta guía le pone nombre a cada tipo de tiempo y luego muestra exactamente qué guardar, qué enviar, cómo agendar y qué inspeccionar cuando producción está una hora o un día desfasada.
🎙️ Publicado y grabado: ·
Un instante es un punto en la línea de tiempo global. Una fecha-hora local es lo que muestra un reloj de pared en algún lugar. "El veinticinco de julio a las nueve" no es un instante hasta que le agregas una zona horaria. El mismo instante puede ser sábado por la tarde en Londres y domingo por la mañana en Tokio.
# One instant, three displays
2026-07-25T15:00:00Z UTC
2026-07-25T11:00:00-04:00 New York display
2026-07-26T00:00:00+09:00 Tokyo display
# Not enough information to identify an instant
2026-07-25 09:00
UTC es la línea de tiempo de referencia. Un offset, como menos cuatro horas, describe una relación con UTC en un instante. Una zona IANA, como America barra New York, es un reglamento con los cambios de offset históricos y futuros. Un offset no es una zona horaria. No puede decirte cuál será el offset en marzo próximo.
UTC reference: Z or +00:00
-04:00 fixed offset, no DST rules
America/New_York IANA zone with rule history
Etc/GMT+4 fixed offset; sign is reversed by convention
EST ambiguous abbreviation; do not store it
Usa identificadores IANA en las fronteras del producto. "CST" puede significar el Central de Norteamérica, la hora estándar de China o la de Cuba. Los nombres de zona de Windows son otro sistema de nombres y a menudo necesitan un mapeo explícito. Adivinar a partir del offset actual del usuario también está mal: muchas zonas comparten offset hoy y se separan más adelante.
Para un instante en una API, envía una cadena ISO ocho seis cero uno con Z o con un offset numérico. Z significa UTC. La letra T separa fecha y hora. Los segundos fraccionarios son opcionales. Una cadena de fecha-hora sin offset está incompleta a propósito, y cada runtime puede interpretarla como hora local, como UTC, o rechazarla.
2026-07-25T15:04:05Z # unambiguous UTC instant
2026-07-25T11:04:05-04:00 # same style, explicit offset
2026-07-25 # calendar date, not midnight UTC
2026-07-25 11:04:05 # ambiguous; no offset or zone
# JavaScript: serialize an instant in UTC
new Date("2026-07-25T11:04:05-04:00").toISOString()
"2026-07-25T15:04:05.000Z"
Cuando los relojes se adelantan, hay un rango de horas locales que nunca ocurre. Eso es un hueco. Cuando se atrasan, un rango ocurre dos veces. Eso es un solape. El ocho de marzo de dos mil veintiséis en Nueva York, el reloj salta de la una cincuenta y nueve a las tres. Las dos y media son imaginarias. El primero de noviembre, la una y media pasa dos veces con dos offsets distintos.
# America/New_York, 2026
2026-03-08 01:59:59-05:00
↓ next second
2026-03-08 03:00:00-04:00
2026-03-08 02:30 does not exist
2026-11-01 01:30:00-04:00 # first occurrence
2026-11-01 01:30:00-05:00 # second occurrence
Expected 09:00, got 10:00. Uno: registra el instante, la zona IANA y el offset resuelto en ambos caminos. Dos: revisa si un camino sumó veinticuatro horas fijas o reusó el offset de ayer. Tres: suma días de calendario en la zona destino para agendas de reloj de pared; suma segundos transcurridos solo para duraciones. Cuatro: prueba las dos transiciones del horario de verano. No parches la salida restando una hora.Para eventos que ya pasaron, guarda un instante en un tipo de base de datos con semántica UTC clara y convierte al mostrar. Conserva además el offset original cuando la auditoría o la presentación legal lo exijan. Mi postura sin adornos: "guarda todo en UTC" es buen consejo para eventos pasados y mal consejo para cumpleaños, horarios de atención y agendas locales futuras. Esos valores todavía no son instantes.
# PostgreSQL shapes
occurred_at timestamptz # instant; normalized internally
birth_date date # calendar date
opens_at time # local wall time, paired with business zone
starts_local timestamp # local intent
zone_id text # e.g. Europe/Paris
# Persist an explicit contract
{"occurredAt":"2026-07-25T15:04:05Z"}
{"startsLocal":"2027-03-28T09:00:00","timeZone":"Europe/Paris"}
El timestamp with time zone de PostgreSQL guarda un instante, no el nombre de zona que enviaste. MySQL y SQLite se comportan distinto. Lee las reglas reales de tipos de tu base de datos y fija la zona de la conexión o de la sesión de forma explícita. Nombres de columna como created_at_utc son documentación baratísima.
Un vuelo, una cita o un "cada día laboral a las nueve" pertenecen a una regla local en una zona con nombre. Guarda la fecha y hora local, la zona IANA y la política para huecos y solapes. Puedes cachear el próximo instante UTC para ejecutar rápido, pero recalcula las ocurrencias futuras cuando cambien los datos de zonas horarias. Los gobiernos cambian las reglas del reloj con menos aviso que tu periodo de retención de datos.
{
"localStart": "2027-10-31T01:30:00",
"timeZone": "Europe/London",
"foldPolicy": "later",
"gapPolicy": "shift-forward"
}
# Define recurrence in calendar terms
weekdays at 09:00 in Europe/London
not: every 86,400 seconds forever
Los cumpleaños, las fechas de factura y las fechas de salida de un hotel son fechas de calendario. Convertirlas a medianoche UTC inventa un instante. Muestra ese instante inventado al oeste de UTC y la fecha se corre hacia atrás. Mantén YYYY-MM-DD como tipo fecha o como cadena validada hasta que una regla de negocio real le asigne hora y zona.
# The classic browser bug in an America/Los_Angeles environment
new Date("2026-07-25").toString()
"Fri Jul 24 2026 17:00:00 GMT-0700 ..."
Expected 2026-07-25, got 2026-07-24
# Correct date-only handling
const birthday = "2026-07-25"; # validate, store, display as a date
Date. Tres: mantenlo en un tipo de solo fecha en toda la API y la base de datos. Cuatro: formatea año, mes y día sin aplicar conversión de zona. Sumar doce horas es un disfraz frágil, no una solución.Un timestamp Unix cuenta el tiempo transcurrido desde la época Unix, normalmente ignorando los segundos intercalares. Python suele aceptar segundos. El constructor Date de JavaScript acepta milisegundos. Hoy, un timestamp en segundos tiene unos diez dígitos; en milisegundos, unos trece. Deducir por la cantidad de dígitos sirve mientras depuras, pero el contrato de la API tiene que nombrar la unidad.
1753455845 # seconds
1753455845000 # milliseconds
# JavaScript
new Date(seconds * 1000)
new Date(milliseconds)
# Python, aware UTC datetime
datetime.fromtimestamp(seconds, tz=timezone.utc)
RangeError: Invalid time value aparece a menudo cuando una entrada inválida llega a toISOString(). Uno: registra el valor original y su tipo. Dos: rechaza null, texto vacío y NaN. Tres: confirma si son segundos o milisegundos. Cuatro: construye la fecha y verifica que Number.isNaN(date.getTime()) sea falso antes de formatear. No captures la excepción para emitir la fecha de hoy.No empieces por el formato. Captura el valor en cada frontera: texto crudo, tipo parseado, valor de época, offset, zona IANA, tipo de base de datos, zona de sesión y zona final de visualización. Compara instantes como instantes. Convierte solo en el borde. Una captura de pantalla que dice "tres de la tarde" es evidencia débil; una cadena ISO con zona y offset es evidencia útil.
# Python: create aware values
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("America/New_York"))
TypeError: can't compare offset-naive and offset-aware datetimes
# JavaScript: inspect the instant, then display in a named zone
console.log(date.toISOString(), date.getTime())
new Intl.DateTimeFormat("en", {timeZone:"America/New_York",
dateStyle:"full", timeStyle:"long"}).format(date)
TypeError: can't compare offset-naive and offset-aware datetimes, uno: imprime el repr y el tzinfo de cada valor. Dos: decide qué zona pretendía usar la fuente naive; no asumas UTC porque es cómodo. Tres: adjunta esa zona de origen con ZoneInfo, manejando la política de hueco o solape. Cuatro: convierte los dos valores a UTC y compáralos. replace(tzinfo=UTC) reetiqueta el reloj; no lo convierte.Orden sistemático: reproduce con un instante conocido; fija las zonas del proceso, de la base de datos y del navegador; registra la entrada cruda y la época; revisa el offset de la API; revisa la columna SQL y la zona de sesión; convierte una sola vez para mostrar; y luego agrega casos de regresión para la medianoche UTC y las dos transiciones del horario de verano.
Usa esta tabla de decisión antes de elegir un tipo o escribir una conversión.
Already happened? → instant; store UTC semantics
Display for a user? → instant + chosen IANA zone
Future local schedule? → local datetime + IANA zone + gap/fold policy
Birthday/invoice date? → date only; never invent midnight UTC
Recurring at 09:00? → calendar recurrence in its IANA zone
Elapsed for 24 hours? → duration, not “same time tomorrow”
API instant → 2026-07-25T15:04:05Z
API date → 2026-07-25
Zone → America/New_York, not EST
Unix input → unit stated: seconds or milliseconds
One hour wrong → DST rule, fixed offset, or double conversion
One day wrong → date-only parsed as an instant near UTC midnight
Wild historical result → seconds/milliseconds or stale zone data
Debug: raw → parsed type → epoch → offset → IANA zone
→ DB type/session zone → API string → display zone
La regla que conservo: nunca convertir un valor hasta poder decir qué significa. UTC es una línea de tiempo, no una interfaz universal para usuarios. Las zonas IANA son reglas, no adornos. Las fechas no son medianoches. En cuanto esas tres distinciones sobreviven a las fronteras de tu base de datos y de tu API, la mayoría de los "misterios" de fechas deja de ser misteriosa.