Postgres: AT TIME ZONE no convierte un timestamp sin zona

Guardas fechas en UTC en columnas timestamp without time zone (es lo que genera Prisma por defecto) y quieres mostrarlas en la hora local de usuarios que están en UTC-5. Aplicas AT TIME ZONE y el resultado parece incorrecto: con la sesión en UTC, el valor guardado 2026-10-11 15:00:00 sale como una hora posterior, y con la sesión en la zona local sale idéntico, como si no hubiera convertido nada:

SELECT creado_en AT TIME ZONE 'America/Bogota' FROM pedidos;

-- sesión en UTC:
2026-10-11 20:00:00+00
-- sesión en America/Bogota:
2026-10-11 15:00:00-05

La hora local correcta sería 10:00. Un problema emparentado: las filas que insertó una migración con now() aparecen 5 horas desfasadas en la aplicación, y los cálculos por días (ventanas de vigencia, informes por fecha) cambian de día cerca de la medianoche. Todo tiene el mismo origen.

Por qué pasa

La documentación de PostgreSQL (funciones de fecha y hora) define dos comportamientos distintos para AT TIME ZONE, según el tipo del valor:

  • timestamp without time zone AT TIME ZONE zona devuelve un timestamp with time zone y supone que el valor ya está expresado en esa zona.
  • timestamp with time zone AT TIME ZONE zona devuelve un timestamp without time zone: la hora que marcaría un reloj en esa zona.

Con una columna sin zona, AT TIME ZONE 'America/Bogota' no convierte desde UTC: declara que 15:00 ya es hora de Bogotá, lo que equivale a 20:00 UTC. El resultado es un timestamptz, y psql lo muestra con la zona de la sesión (TimeZone): 20:00+00 en una sesión UTC y 15:00-05 en una sesión de Bogotá. Es la dirección contraria a la que mucha gente espera.

El desfase de now() tiene la misma raíz. now() devuelve un timestamptz, y al guardarlo en una columna sin zona PostgreSQL aplica la regla general: las conversiones entre ambos tipos suponen que el valor sin zona está en la hora local de TimeZone. Si la migración corre con la sesión en America/Bogota, se guarda la hora de pared local. Una aplicación que interprete esa columna como UTC la lee 5 horas antes. PostgreSQL no guarda ninguna zona en esa columna, así que la convención es de la aplicación; Prisma, cuyo tipo DateTime se mapea por defecto a timestamp(3), suele tratar esos valores como UTC, aunque su documentación no lo garantiza de forma explícita.

Cerca de medianoche el desfase cambia el día: las 20:30 del día 11 en Bogotá son las 01:30 UTC del día 12. En la prueba, ese instante convertido a timestamp con la sesión en hora local se guardó como 2026-10-11 20:30:00 (fecha 11), mientras que en UTC es 2026-10-12 01:30:00 (fecha 12). Cualquier cálculo por días o por fecha sale distinto según qué valor se use.

Solución

  1. Para mostrar en hora local un valor guardado en UTC, usa dos veces AT TIME ZONE. La primera declara que el valor está en UTC (obtienes un timestamptz) y la segunda lo expresa en la zona deseada (obtienes un timestamp sin zona, que no depende de la sesión):

    SELECT creado_en AT TIME ZONE 'UTC' AT TIME ZONE 'America/Bogota' AS hora_local
    FROM pedidos;
    
    -- equivalente con la función timezone()
    SELECT timezone('America/Bogota', timezone('UTC', creado_en)) AS hora_local
    FROM pedidos;

    Con el ejemplo anterior devuelve 2026-10-11 10:00:00, tanto con la sesión en UTC como en America/Bogota.

  2. Al guardar, convierte now() a UTC de forma explícita si la columna es timestamp without time zone:

    INSERT INTO sesiones (creado_en) VALUES (timezone('UTC', now()));
    -- o, con la misma semántica:
    INSERT INTO sesiones (creado_en) VALUES (now() AT TIME ZONE 'UTC');
    
    -- valor por defecto de la columna
    ALTER TABLE sesiones ALTER COLUMN creado_en SET DEFAULT timezone('UTC', now());

    Aplícalo también en las migraciones y scripts manuales que escriban estas columnas: no usan el código de tu aplicación.

  3. En columnas nuevas, prefiere timestamptz. PostgreSQL guarda el instante normalizado a UTC y lo convierte a la zona de la sesión al mostrarlo, así que no hay una convención implícita que se pueda romper. En Prisma se declara con el atributo nativo @db.Timestamptz(3):

    model Pedido {
      id        Int      @id @default(autoincrement())
      creadoEn  DateTime @default(now()) @db.Timestamptz(3)
    }

    Para convertir una columna existente, indica siempre cómo interpretar los valores antiguos:

    ALTER TABLE pedidos
      ALTER COLUMN creado_en TYPE timestamptz
      USING creado_en AT TIME ZONE 'UTC';

    Sin la cláusula USING, PostgreSQL aplica la conversión implícita con la zona de la sesión. En la prueba, con la sesión en America/Bogota, el valor 15:00 se convirtió en 20:00+00; con USING ... AT TIME ZONE 'UTC' quedó en 15:00+00. Este cambio de tipo puede reescribir la tabla, así que planifícalo en tablas grandes.

  4. Fija la zona de la sesión de forma consciente. Consulta el valor actual con SHOW TimeZone; y cámbialo para la sesión con SET TimeZone = 'UTC';. Para que cada sesión nueva de un rol arranque en UTC:

    ALTER ROLE mi_usuario SET timezone = 'UTC';

    La documentación indica que el valor se aplica al iniciar sesión el rol y reemplaza al de postgresql.conf. En la prueba, SHOW timezone pasó de America/Bogota a UTC en la conexión siguiente.

Cómo verificar que quedó resuelto

Esta comprobación crea una tabla de prueba y deja la sesión en la zona local, que es el caso que provoca el desfase:

CREATE TABLE prueba_zona (id serial PRIMARY KEY, creado_en timestamp NOT NULL);
SET TimeZone = 'America/Bogota';
INSERT INTO prueba_zona (creado_en) VALUES (now()), (timezone('UTC', now()));

SELECT id,
       round(extract(epoch FROM ((creado_en AT TIME ZONE 'UTC') - now())) / 3600, 2) AS desfase_horas
FROM prueba_zona
ORDER BY id;

DROP TABLE prueba_zona;

La fila 1 (now() sin convertir) debe mostrar un desfase de -5.00 horas, y la fila 2 (timezone('UTC', now())) debe mostrar 0.00. Si tu aplicación calcula una caducidad, comprueba también que el tiempo restante justo después de crear el registro sea exactamente el esperado, sin horas de más ni de menos.

Variantes y errores relacionados

Valores con desplazamiento escritos como timestamp

Según la documentación, cuando un valor se interpreta como timestamp without time zone, PostgreSQL ignora en silencio cualquier indicación de zona de la entrada. Por eso '2026-10-11 15:00:00-05'::timestamp queda como 15:00, sin ajuste. Para conservar el instante, escribe el tipo completo: TIMESTAMP WITH TIME ZONE '2026-10-11 15:00:00-05'.

Obtener la fecha local, no la de UTC

Para agrupar por día local usa la zona de forma explícita: (now() AT TIME ZONE 'America/Bogota')::date. Si la columna es un timestamp guardado en UTC, convierte primero con la doble conversión de la solución 1 y después aplica ::date.

Prisma, timestamptz y adaptadores de controlador

Al escribir este artículo había una incidencia abierta en el repositorio de Prisma (prisma/orm, número 26786) que describe lecturas desplazadas con @db.Timestamptz al usar adaptadores de controlador cuando la zona horaria de PostgreSQL no es UTC. Revisa su estado y prueba con tu versión antes de migrar columnas.

timezone(zona, valor) equivale a AT TIME ZONE

La documentación indica que la función timezone(zona, valor) es equivalente a la construcción valor AT TIME ZONE zona. Fíjate en el orden de los argumentos: la zona va primero.

Resumen

  • En un timestamp without time zone, AT TIME ZONE 'X' supone que el valor ya está en la zona X y devuelve un timestamptz; no convierte desde UTC.
  • Para pasar de UTC a hora local: col AT TIME ZONE 'UTC' AT TIME ZONE 'America/Bogota'.
  • now() es timestamptz; al guardarlo en una columna sin zona se usa la zona de la sesión. Usa timezone('UTC', now()) o now() AT TIME ZONE 'UTC'.
  • Prefiere timestamptz en columnas nuevas y migra las antiguas con USING col AT TIME ZONE 'UTC'.
  • Revisa SHOW TimeZone en cada entorno que ejecute migraciones o scripts.

Referencias

Comentarios