TIMESTAMPTZ vs TIMESTAMP: tableros y APIs en PostgreSQL
TIMESTAMPTZ vs TIMESTAMP en PostgreSQL: cómo el tipo que elijas afecta tableros, respuestas de API, conversiones de zona horaria y errores por horario de verano.

El problema real: un evento, muchas interpretaciones
Un evento ocurre una vez, pero se reporta de docenas de formas. La base de datos guarda un valor, una API lo serializa, un tablero lo agrupa y cada persona lo ve en su propia zona horaria. Si cualquier capa hace una suposición diferente, la misma fila puede parecer dos momentos distintos.
Por eso TIMESTAMPTZ vs TIMESTAMP no es solo una preferencia de tipo. Decide si un valor almacenado representa un instante específico en el tiempo, o una hora de reloj que solo tiene sentido en un lugar concreto.
Esto es lo que suele romperse primero: un tablero de ventas muestra totales diarios diferentes en Nueva York y Berlín. Un gráfico horario tiene una hora faltante o duplicada durante los cambios de horario de verano (DST). Un log de auditoría parece desordenado porque dos sistemas “coinciden” en la fecha pero no en el instante real.
Un modelo simple te mantiene fuera de problemas:
- Almacenamiento: lo que guardas en PostgreSQL y lo que representa.
- Visualización: cómo lo formateas en una UI, exportación o informe.
- Localidad del usuario: la zona horaria y las reglas de calendario del espectador, incluyendo DST.
Si mezclas eso, obtienes bugs sutiles en los reportes. Un equipo de soporte exporta “tickets creados ayer” desde un tablero y luego lo compara con un informe de la API. Ambos parecen razonables, pero uno usó el límite de medianoche local del espectador mientras el otro usó UTC.
El objetivo es simple: para cada valor temporal, toma dos decisiones claras. Decide qué almacenas y decide qué muestras. Esa misma claridad debe mantenerse en tu modelo de datos, respuestas de API y tableros para que todos vean la misma línea de tiempo.
Qué significan realmente TIMESTAMP y TIMESTAMPTZ
En PostgreSQL, los nombres son engañosos. Parecen describir lo que se almacena, pero en su mayoría describen cómo PostgreSQL interpreta la entrada y formatea la salida.
TIMESTAMP (alias timestamp without time zone) es solo una fecha de calendario y tiempo de reloj, como 2026-01-29 09:00:00. No se adjunta ninguna zona horaria. PostgreSQL no la convertirá por ti. Dos personas en distintas zonas horarias pueden leer el mismo TIMESTAMP y asumir momentos reales distintos.
TIMESTAMPTZ (alias timestamp with time zone) representa un punto real en el tiempo. Piénsalo como un instante. PostgreSQL lo normaliza internamente (efectivamente a UTC), y luego lo muestra en la zona horaria que use tu sesión.
El comportamiento detrás de la mayoría de sorpresas es:
- En la entrada: PostgreSQL convierte valores
TIMESTAMPTZa un único instante comparable. - En la salida: PostgreSQL formatea ese instante usando la zona horaria de la sesión actual.
- Para
TIMESTAMP: no ocurre conversión automática en entrada ni en salida.
Un pequeño ejemplo muestra la diferencia. Supón que tu app recibe 2026-03-08 02:30 de un usuario. Si lo insertas en una columna TIMESTAMP, PostgreSQL almacena exactamente ese valor de reloj. Si esa hora local no existe por un salto de DST, podrías no notarlo hasta que fallen los reportes.
Si lo insertas en TIMESTAMPTZ, PostgreSQL necesita una zona horaria para interpretar el valor. Si proporcionas 2026-03-08 02:30 America/New_York, PostgreSQL lo convierte a un instante (o lanza un error según las reglas y el valor exacto). Más tarde, un tablero en Londres mostrará una hora de reloj local distinta, pero es el mismo instante.
Un concepto erróneo común: la gente ve “with time zone” y espera que PostgreSQL almacene la etiqueta de la zona horaria original. No lo hace. PostgreSQL guarda el momento, no la etiqueta. Si necesitas la zona original del usuario para mostrarla (por ejemplo, “mostrar en la hora local del cliente”), almacena la zona por separado como texto.
Zona horaria de sesión: la configuración oculta detrás de muchas sorpresas
PostgreSQL tiene una configuración que cambia silenciosamente lo que ves: la zona horaria de la sesión. Dos personas pueden ejecutar la misma consulta sobre los mismos datos y obtener horas de reloj distintas porque sus sesiones usan zonas horarias diferentes.
Esto afecta sobre todo a TIMESTAMPTZ. PostgreSQL almacena un momento absoluto y luego lo muestra en la zona horaria de la sesión. Con TIMESTAMP (sin zona), PostgreSQL trata el valor como tiempo de calendario plano. No lo desplaza para la visualización, pero la zona horaria de la sesión aún puede causarte problemas cuando lo conviertes a TIMESTAMPTZ o lo comparas con valores conscientes de la zona.
Las zonas de sesión a menudo se establecen sin que lo notes: configuración al arrancar la aplicación, parámetros del driver, pools de conexión que reutilizan sesiones antiguas, herramientas BI con sus propios valores por defecto, jobs ETL que heredan la configuración de locale del servidor o consolas SQL manuales usando las preferencias de tu portátil.
Así es como los equipos terminan discutiendo. Supón que un evento está almacenado como 2026-03-08 01:30:00+00 en una columna TIMESTAMPTZ. Una sesión de tablero en America/Los_Angeles lo mostrará como la noche anterior en hora local, mientras que una sesión de API en UTC muestra otra hora de reloj. Si un gráfico agrupa por día usando el día local de la sesión, puedes obtener totales diarios distintos.
-- Make your output consistent for a reporting job
SET TIME ZONE 'UTC';
SELECT created_at, date_trunc('day', created_at) AS day_bucket
FROM events;
Para cualquier cosa que produzca informes o respuestas de API, haz explícita la zona horaria. Establécela al conectar (o ejecuta SET TIME ZONE primero), elige un estándar para salidas de máquina (a menudo UTC) y para reportes en “hora de negocio local” fija la zona de negocio dentro del job, no en el portátil de alguien. Si usas conexiones en pool, restablece las configuraciones de sesión cuando se saque una conexión.
Cómo se rompen los dashboards: agrupación, buckets y huecos por DST
Los dashboards parecen simples: contar pedidos por día, mostrar registros por hora, comparar semanas. Los problemas empiezan cuando la base de datos almacena un “momento” pero el gráfico lo convierte en muchos “días” distintos según quién mire.
Si agrupas por día usando la zona horaria local de un usuario, dos personas pueden ver fechas distintas para el mismo evento. Un pedido realizado a las 23:30 en Los Ángeles ya es “mañana” en Berlín. Y si tu SQL agrupa por DATE(created_at) sobre un TIMESTAMP plano, no estás agrupando por un instante real. Estás agrupando por una lectura de reloj sin zona adjunta.
Los gráficos horarios se complican alrededor de DST. En primavera, una hora local no ocurre, así que los gráficos pueden mostrar un hueco. En otoño, una hora local ocurre dos veces, y puedes obtener un pico o buckets duplicados si tu consulta y el tablero no están de acuerdo sobre a cuál 01:30 te refieres.
Una pregunta práctica ayuda: ¿estás graficando instantes reales (seguros de convertir), o un horario local programado (que no debe convertirse)? Los dashboards casi siempre quieren instantes reales.
Cuándo agrupar por UTC vs una zona de negocio
Elige una regla de agrupamiento y aplícala en todas partes (SQL, API, herramienta BI), de lo contrario los totales derivarán.
Agrupa por UTC cuando quieras una serie global y consistente (salud del sistema, tráfico de API, registros globales). Agrupa por una zona de negocio cuando “el día” tenga un significado legal u operativo (día de tienda, SLAs de soporte, cierre financiero). Agrupa por la zona del espectador solo cuando la personalización importe más que la comparabilidad (feeds de actividad personal).
Aquí está el patrón para un agrupamiento consistente por “día de negocio":
SELECT date_trunc('day', created_at AT TIME ZONE 'America/New_York') AS business_day,
count(*)
FROM orders
GROUP BY 1
ORDER BY 1;
Etiquetas que previenen desconfianza
La gente deja de confiar en los gráficos cuando los números saltan y nadie puede explicar por qué. Etiqueta la regla directamente en la UI: “Pedidos diarios (America/New_York)” o “Eventos por hora (UTC)”. Usa la misma regla en exportaciones y APIs.
Un conjunto de reglas simple para reportes y APIs
Decide si estás almacenando un instante o una lectura de reloj local. Mezclar esos dos es donde dashboards y APIs empiezan a discrepar.
Un conjunto de reglas que mantiene los reportes predecibles:
- Almacena eventos del mundo real como instantes usando
TIMESTAMPTZ, y considera UTC como la fuente de verdad. - Almacena conceptos de negocio como “día de facturación” por separado como
DATE(o un campo de hora local si realmente necesitas la hora de reloj). - En APIs, devuelve timestamps en ISO 8601 y sé consistente: siempre incluye un offset (como
+02:00) o usa siempreZpara UTC. - Convierte en los bordes (UI y capa de reportes). Evita convertir de ida y vuelta dentro de la lógica de base de datos y jobs en segundo plano.
Por qué esto se mantiene: los dashboards agrupan y comparan rangos. Si almacenas instantes (TIMESTAMPTZ), PostgreSQL puede ordenar y filtrar eventos de forma fiable incluso cuando hay cambios de DST. Luego eliges cómo mostrarlos o agruparlos. Si almacenas una hora de reloj local (TIMESTAMP) sin zona, PostgreSQL no puede saber qué significa, por lo que la agrupación puede cambiar cuando la zona de sesión cambie.
Mantén las “fechas de negocio locales” separadas porque no son instantes. “Entregar el 2026-03-08” es una decisión de fecha, no un instante. Si lo fuerzas a un timestamp, los días con DST pueden crear horas locales faltantes o duplicadas, que luego aparecen como huecos o picos.
Paso a paso: elegir el tipo adecuado para cada valor temporal
Elegir entre TIMESTAMPTZ vs TIMESTAMP empieza con una pregunta: ¿este valor describe un momento real que ocurrió, o una hora local que quieres conservar exactamente como está?
1) Separa eventos reales de horarios programados locales
Haz un inventario rápido de tus columnas.
Eventos reales (clics, pagos, inicios de sesión, envíos, lecturas de sensores, mensajes de soporte) deberían normalmente almacenarse como TIMESTAMPTZ. Quieres un instante inequívoco, incluso si la gente lo ve desde distintas zonas.
Los horarios programados locales son diferentes: “La tienda abre a las 09:00”, “Ventana de recogida de 16:00 a 18:00”, “Facturación el día 1 a las 10:00 hora local”. Estos suelen ser mejores como TIMESTAMP más un campo de zona horaria separado, porque la intención está ligada al reloj local de una ubicación.
2) Elige un estándar y escríbelo
Para la mayoría de productos, un buen valor por defecto es: almacenar tiempos de evento en UTC, presentarlos en la zona horaria del usuario. Documenta esto en lugares que la gente realmente lea: notas del esquema, documentación de la API y descripciones de los tableros. También define qué significa “día de negocio” (día UTC, día en la zona de negocio o día local del espectador), porque esa elección determina los reportes diarios.
Una lista de verificación corta que funciona en la práctica:
- Marca cada columna temporal como “instante de evento” o “horario local”.
- Por defecto, los instantes de evento a
TIMESTAMPTZalmacenados en UTC. - Al cambiar esquemas, rellena datos antiguos con cuidado y valida filas de ejemplo a mano.
- Estandariza los formatos de API (siempre incluye
Zo un offset para instantes). - Establece la zona horaria de sesión explícitamente en jobs ETL, conectores BI y workers.
Ten cuidado con el trabajo de “convertir y backfill”. Cambiar el tipo de columna puede cambiar silenciosamente el significado si los valores antiguos fueron interpretados con una zona de sesión diferente.
Errores comunes que causan desfaces de un día y bugs de DST
La mayoría de bugs de tiempo no son “PostgreSQL siendo extraño”. Vienen de almacenar un valor que parece correcto pero con el significado equivocado, y luego dejar que distintas capas adivinen el contexto que falta.
Error 1: Guardar una hora de reloj como si fuera absoluta
Una trampa común es almacenar horas de reloj locales (como “2026-03-29 09:00” en Berlín) en un TIMESTAMPTZ. PostgreSQL lo trata como un instante y lo convierte según la zona de la sesión actual. Si la intención era “siempre a las 9 AM hora local”, la acabas de perder. Ver la misma fila bajo otra zona de sesión desplaza la hora mostrada.
Para citas, almacena la hora local como TIMESTAMP más una zona horaria separada (o localización). Para eventos que ocurrieron en un momento (pagos, inicios de sesión), almacena el instante como TIMESTAMPTZ.
Error 2: Entornos diferentes, suposiciones diferentes
Tu portátil, staging y producción pueden no compartir la misma zona horaria. Un entorno corre en UTC, otro en hora local, y los informes “agrupar por día” empiezan a discrepar. Los datos no cambiaron, cambió la configuración de la sesión.
Error 3: Usar funciones de tiempo sin saber qué prometen
now() y current_timestamp son estables dentro de una transacción. clock_timestamp() cambia en cada llamada. Si generas timestamps en varios puntos de una transacción y mezclas estas funciones, el orden y las duraciones pueden parecer extraños.
Error 4: Convertir dos veces (o cero veces)
Un bug frecuente en APIs: la app convierte una hora local a UTC, la envía como una cadena naive, y luego la sesión de la base de datos la convierte de nuevo porque asume que la entrada era local. También ocurre lo contrario: la app envía una hora local pero la etiqueta con Z (UTC), desplazándola al mostrarla.
Error 5: Agrupar por fecha sin declarar la zona horaria prevista
“Totales diarios” depende de qué límite de día quieres. Si agrupas con date(created_at) sobre un TIMESTAMPTZ, el resultado sigue la zona horaria de la sesión. Eventos de tarde pueden moverse al día anterior o siguiente.
Antes de enviar un dashboard o API, verifica lo básico: elige una zona de reporte por gráfico y aplícala consistentemente, incluye offsets (o Z) en payloads de API, mantén staging y producción alineados en la política de zonas horarias y sé explícito sobre qué zona usas al agrupar.
Chequeos rápidos antes de publicar un dashboard o API
Los bugs de tiempo rara vez vienen de una sola consulta mala. Ocurren porque almacenamiento, reporte y API hacen suposiciones ligeramente diferentes.
Usa una lista corta antes del lanzamiento:
- Para eventos del mundo real (registros, pagos, pings de sensores), almacena el instante como
TIMESTAMPTZ. - Para conceptos locales de negocio (día de facturación, fecha de reporte), almacena un
DATEoTIME, no un timestamp que pienses “convertir después”. - En jobs programados y runners de reportes, establece la zona horaria de la sesión a propósito.
- En respuestas de API, incluye un offset o
Z, y confirma que el cliente lo parsea como consciente de la zona horaria. - Prueba la semana de transición de DST para al menos una zona objetivo.
Una validación rápida end-to-end: elige un evento límite conocido (por ejemplo, 2026-03-08 01:30 en una zona con DST) y síguelo por almacenamiento, salida de consulta, JSON de la API y la etiqueta final del gráfico. Si el gráfico muestra el día correcto pero el tooltip la hora incorrecta (o viceversa), tienes una descoordinación de conversiones.
Ejemplo: por qué dos equipos discrepan sobre los números del mismo día
Un equipo de soporte en Nueva York y otro de finanzas en Berlín miran el mismo tablero. El servidor de base de datos corre en UTC. Todos insisten en que sus números son correctos, pero “ayer” es distinto según a quién preguntes.
Aquí está el evento: un ticket de cliente se crea a las 23:30 en Nueva York el 10 de marzo. Eso es 04:30 UTC el 11 de marzo, y 05:30 en Berlín. Un mismo instante, tres fechas de calendario distintas.
Si la hora de creación del ticket se almacena como TIMESTAMP (sin zona) y tu app asume que es “local”, puedes reescribir la historia en silencio. Nueva York puede tratar 2026-03-10 23:30 como hora de Nueva York, mientras Berlín interpreta ese mismo valor almacenado como hora de Berlín. La misma fila cae en días distintos para distintos espectadores.
Si se almacena como TIMESTAMPTZ, PostgreSQL guarda el instante de forma consistente y solo lo convierte cuando alguien lo ve o lo formatea. Por eso TIMESTAMPTZ vs TIMESTAMP cambia lo que significa “un día” en los reportes.
La solución es separar dos ideas: el instante en que ocurrió el evento y la fecha de reporte que quieres usar.
Un patrón práctico:
- Almacena el tiempo del evento como
TIMESTAMPTZ. - Decide la regla de reporte: local del espectador (dashboards personales) o una zona de negocio (finanzas de la empresa).
- Calcula la fecha de reporte en tiempo de consulta usando esa regla: convierte el instante a la zona elegida y luego toma la fecha.
Próximos pasos: estandarizar el manejo del tiempo en tu stack
Si el manejo del tiempo no está documentado, cada nuevo informe se convierte en un juego de adivinanzas. Apunta a un comportamiento temporal que sea aburrido y predecible en la base de datos, APIs y tableros.
Escribe un breve “contrato de tiempo” que responda tres preguntas:
- Estándar de tiempo de evento: almacena instantes de evento como
TIMESTAMPTZ(normalmente en UTC) salvo que tengas una razón fuerte para no hacerlo. - Zona horaria de negocio: elige una zona para reportes y úsala consistentemente cuando definas “día”, “semana” y “mes”.
- Formato de API: siempre envía timestamps con un offset (ISO 8601 con
Zo+/-HH:MM) y documenta si los campos significan “instante” o “hora de reloj local”.
Añade pruebas pequeñas alrededor del inicio y fin de DST. Detectan bugs costosos temprano. Por ejemplo, valida que una consulta de “total diario” sea estable para una zona de negocio fija a través de un cambio de DST, y que entradas de API como 2026-11-01T01:30:00-04:00 y 2026-11-01T01:30:00-05:00 se traten como dos instantes distintos.
Planifica las migraciones con cuidado. Cambiar tipos y suposiciones en el lugar puede reescribir silenciosamente la historia en los gráficos. Un enfoque más seguro es añadir una columna nueva (por ejemplo, created_at_utc TIMESTAMPTZ), rellenarla con una conversión revisada, actualizar las lecturas para usar la columna nueva y luego actualizar las escrituras. Mantén informes viejos y nuevos en paralelo brevemente para que los cambios en los totales diarios sean evidentes.
Si quieres un lugar para hacer cumplir este “contrato de tiempo” a través de modelos de datos, APIs y pantallas, una configuración de build unificada ayuda. AppMaster (appmaster.io) genera backend, web app y APIs desde un solo proyecto, lo que facilita mantener reglas de almacenamiento y visualización de timestamps consistentes a medida que tu app crece.
FAQ
Usa TIMESTAMPTZ para cualquier cosa que haya ocurrido en un instante real (registros, pagos, inicios de sesión, mensajes, lecturas de sensores). Almacena un instante inequívoco y se puede ordenar, filtrar y comparar de forma segura entre sistemas. Usa TIMESTAMP solo cuando el valor deba ser una hora de reloj local que se mantenga exactamente como está escrito, normalmente acompañada de un campo separado de zona horaria o localización.
TIMESTAMPTZ representa un instante real en el tiempo; PostgreSQL lo normaliza internamente y luego lo muestra en la zona horaria de la sesión. TIMESTAMP es solo una fecha y hora de reloj sin zona adjunta, por lo que PostgreSQL no la desplaza automáticamente. La diferencia clave es el significado: instante frente a hora de reloj local.
Porque la zona horaria de la sesión controla cómo se formatea TIMESTAMPTZ en la salida y cómo se interpretan algunas entradas. Dos herramientas pueden consultar la misma fila y mostrar horas de reloj distintas si una sesión está en UTC y otra en America/Los_Angeles. Para informes y APIs, establece explícitamente la zona horaria de la sesión para que los resultados no dependan de valores ocultos por defecto.
Porque “un día” depende del límite de la zona horaria. Si un dashboard agrupa por la zona horaria del espectador mientras otro agrupa por UTC (o por una zona de negocio), los eventos de noche pueden caer en fechas distintas y cambiar los totales diarios. Arréglalo eligiendo una regla de agrupamiento por gráfico (UTC o una zona de negocio específica) y usándola de forma consistente en SQL, BI y exportaciones.
El horario de verano crea horas locales faltantes o duplicadas, lo que puede producir huecos o buckets dobles cuando se agrupa por tiempo local. Si tus datos representan instantes reales, almacénalos como TIMESTAMPTZ y elige una zona horaria clara para el bucket del gráfico. También prueba la semana de transición de DST para tus zonas objetivo para atrapar sorpresas pronto.
No, PostgreSQL no conserva la etiqueta original de zona horaria con TIMESTAMPTZ; almacena el instante. Cuando lo consultas, PostgreSQL lo muestra en la zona horaria de la sesión, que puede diferir de la zona original del usuario. Si necesitas “mostrarlo en la zona del cliente”, almacena esa zona por separado en otra columna.
Devuelve timestamps en formato ISO 8601 que incluyan un offset y sé consistente. Un buen defecto simple es devolver siempre UTC con Z para instantes de evento, y dejar que los clientes conviertan para la visualización. Evita enviar cadenas “naive” como 2026-03-10 23:30:00 porque los clientes adivinarán la zona de forma diferente.
Convierte en los bordes: almacena instantes de evento como TIMESTAMPTZ, luego convierte a la zona deseada cuando muestres o agrupe para reportes. Evita convertir de ida y vuelta dentro de triggers, jobs en segundo plano y ETL a menos que tengas un contrato claro. La mayoría de problemas de reporte vienen de conversiones dobles o de mezclar valores naive y con zona horaria.
Usa DATE para conceptos de negocio que son realmente fechas, como “día de facturación”, “fecha de reporte” o “fecha de entrega”. Usa TIME (o TIMESTAMP más una zona horaria separada) para horarios como “abre a las 09:00 hora local”. No fuerces estos en TIMESTAMPTZ a menos que realmente signifiquen un único instante, porque DST y cambios de zona pueden desplazar el significado deseado.
Primero, decide si es un instante (TIMESTAMPTZ) o una hora de reloj local (TIMESTAMP más zona). Añade una nueva columna en lugar de reescribir en sitio. Rellénala (backfill) con una conversión revisada bajo una zona horaria de sesión conocida y valida filas de ejemplo alrededor de medianoche y los límites de DST. Ejecuta informes antiguos y nuevos en paralelo brevemente para que cualquier cambio en los totales sea obvio antes de eliminar la columna antigua.


