Skip to content
Manejo de zonas horarias en backend — Guarda UTC, renderiza local

Fechas y hora

Manejo de zonas horarias en backend — Guarda UTC, renderiza local

Las zonas horarias son donde se nota la experiencia junior vs senior. La disciplina íntegra: guardar UTC, renderizar local, manejar DST, y las columnas de BD que evitan corrupción silenciosa.

El manejo de zonas horarias es uno de los divisores más claros entre ingenieros junior y senior en backend. La regla es corta, la disciplina larga, y los casos borde reales. Esta guía es la foto completa, organizada alrededor de una frase: guarda UTC, renderiza local.

La regla única

Guarda timestamps como UTC en la BD. Renderízalos en la zona local del usuario en el último momento posible — típicamente en la UI, o en la respuesta API si la UI no formatea.

Esa es la regla. Todo lo demás es matiz.

Por qué UTC específicamente: no tiene daylight saving, no tiene cambios políticos (ignorando la pedantería de leap seconds — mira la guía de Unix timestamps), y toda zona del mundo tiene un offset bien definido respecto a él. Si guardas UTC, convertir a cualquier otra zona es una operación de una línea. Si guardas hora local Europe/Madrid, convertir a otra zona te obliga a saber también las reglas exactas de daylight saving que estaban en vigor en ese instante, que cambian.

Para conversión interactiva de zona mientras depuras, el timezone converter es lo más rápido para chequear offsets.

Los tipos de columna en BD

Elegir el tipo de columna equivocado es donde esta disciplina se rompe. Las opciones en las tres grandes BDs OLTP:

BDMejor columnaGuardaNotas
PostgrestimestamptzInstante UTCAcepta cualquier zona al escribir, normaliza a UTC; renderiza en TZ de sesión al leer
MySQL (InnoDB)TIMESTAMPInstante UTC (con conversión a TZ de sesión)Limitado a 1970-2038 por defecto; DATETIME guarda wall clock sin zona
SQL ServerdatetimeoffsetInstante UTC + offsetPreserva offset original; usa datetime2 si manejas conversión tú
SQLiteTEXT (ISO 8601) o INTEGER (Unix segundos)Sin tipo nativo; disciplina tústrftime('%Y-%m-%dT%H:%M:%fZ', 'now') es el string UTC idiomático
MongoDBDate (BSON)Milisegundos UTCSin concepto de zona en el tipo

En Postgres específicamente: siempre usa timestamptz, nunca timestamp. El nombre timestamp (sin tz) es un amigo engañoso — guarda un wall clock sin info de zona y no te ayuda con la conversión. timestamptz guarda UTC internamente y maneja la conversión al leer y escribir. La parte “tz” no significa que la columna guarde una zona; significa que es zone-aware.

El TIMESTAMP de MySQL convierte entre TZ de sesión y UTC automáticamente, lo que quieres. Pero está limitado a 1970-2038 en la mayoría de instalaciones (problema del Año 2038), así que para fechas fuera de ese rango usa DATETIME y maneja la conversión en la aplicación.

Nunca guardes hora local

El antipatrón: guardar 2026-04-17 14:30:00 sin info de zona, asumiendo que quien lee “sabrá” que es Europe/Madrid.

Tres razones por las que falla:

  1. Los servidores se mueven. Los datos se replican a una máquina en otra región. El código se refactoriza para correr en un cluster Kubernetes que defaultea a UTC. Tus timestamps se desplazan silenciosamente 1-2 horas.
  2. DST hace “hora local” ambigua dos veces al año. La hora de 02:00 a 03:00 el último domingo de marzo en Europe/Madrid no existe (spring forward). La de 02:00 a 03:00 el último domingo de octubre ocurre dos veces (fall back). Un “timestamp local” durante esas transiciones no está bien definido.
  3. Los usuarios están en múltiples zonas. Una BD que guarde tiempo “local” no puede responder “enséñame todos los eventos de todos los usuarios en la última hora” sin reparsear cada fila.

Guarda UTC. Siempre.

Los casos borde de DST

Las transiciones Daylight Saving causan tres bugs específicos que debes manejar:

Spring forward — horas inexistentes. 2026-03-29 02:30 en Europe/Madrid no existe. El reloj salta de 01:59:59 a 03:00:00. Si un usuario intenta programar un recordatorio para las 02:30 ese día, tienes que elegir: redondear a 03:00, redondear a 01:30, o rechazar. La mayoría de librerías de scheduling redondean hacia arriba.

Fall back — horas ambiguas. 2026-10-25 02:30 en Europe/Madrid ocurre dos veces (primero a UTC+2, luego a UTC+1). Si un usuario guarda hora local sin desambiguación, no sabes qué instante quiso. Elige una convención (normalmente “la primera ocurrencia”) y aplícala consistentemente.

Eventos recurrentes a hora fija. “Todos los días a las 09:00 hora local” significa que la hora UTC se desplaza una hora dos veces al año. No lo guardes como hora UTC — guárdalo como (hora, minuto, zona) y computa la próxima ocurrencia UTC a demanda.

from zoneinfo import ZoneInfo
from datetime import datetime

# Correcto: wall-clock local → instante UTC
tz = ZoneInfo("Europe/Madrid")
local = datetime(2026, 4, 17, 14, 30, tzinfo=tz)
utc = local.astimezone(ZoneInfo("UTC"))
# 2026-04-17 12:30:00+00:00

# Correcto: instante UTC → wall-clock local para display
now_utc = datetime.now(ZoneInfo("UTC"))
now_madrid = now_utc.astimezone(tz)

Nombres IANA, no offsets

Usa identificadores IANA de zona como Europe/Madrid, America/New_York, Asia/Tokyo. No UTC+2 ni CEST ni EST.

  • Abreviaturas como CST son ambiguas (Central Standard Time US, China Standard Time, Cuba Standard Time — todas distintas).
  • Offsets fijos como UTC+2 no manejan DST. Europe/Madrid es UTC+1 en invierno y UTC+2 en verano. Guardar UTC+2 en un registro borra que debería seguir las transiciones de Madrid.
  • Los nombres IANA tienen una base de datos viva (tzdata) que rastrea cambios políticos — países adoptan o descartan DST, cambian offset, etc. Tu OS y runtime lo actualizan regularmente.

La lista autorizada está en tzdata, embarcada con todo sistema Unix en /usr/share/zoneinfo/. El zoneinfo de Python, el ZoneId de Java, y el moderno Intl.DateTimeFormat de JavaScript todos lo usan.

El contrato API

Cuando envías timestamps por una API, dos formatos razonables:

  • String ISO 8601 con offset: 2026-04-17T14:30:00Z o 2026-04-17T16:30:00+02:00. Legible, inequívoco, consumido por toda librería cliente.
  • Entero Unix milisegundos: 1776732600000. Compacto, inequívoco, sin parsing. Documenta la unidad en el nombre del campo (created_at_ms).

No envíes “hora local sin zona”. No envíes strings con abreviaturas (2026-04-17 14:30 CEST).

Si tu API necesita expresar una hora “flotante” (una hora que debe quedar atada a un wall clock concreto independientemente de la zona — como un evento recurrente), represéntala como campos separados: hour: 9, minute: 0, zone: "Europe/Madrid".

{
  "event_id": "evt_123",
  "starts_at": "2026-04-17T14:00:00Z",
  "recurring": {
    "rule": "FREQ=DAILY",
    "local_time": "09:00",
    "zone": "Europe/Madrid"
  }
}

La disciplina de testing

Dos tests baratos que pillan la mayoría de bugs:

  1. Corre tu suite de tests en una zona no-UTC. CI corre en UTC, lo que esconde muchos bugs. Añade un job que corra en America/Los_Angeles o Asia/Tokyo. Los bugs dependientes de zona saldrán a flote.
  2. Testea alrededor de una transición DST. Los sospechosos habituales: scheduling, cálculos de duración, “inicio del día”. Para librerías y schedulers tipo cron, añade tests explícitos para la hora inexistente del spring-forward y la ambigua del fall-back.

Conclusiones

Guarda UTC. Renderiza local. Usa timestamptz en Postgres, TIMESTAMP en MySQL, Unix ms o strings ISO 8601 Z en el borde API. Nombres IANA, no abreviaturas. Planea casos borde de DST. Para la vista escalar del tiempo, mira la guía de Unix timestamps; para la sintaxis cron específica que programa estos timestamps, la guía de sintaxis cron.

Herramientas relacionadas

Por ·