← Volver al blog

Hay errores que no explotan. No tiran producción, no llenan Sentry de trazas y no hacen que nadie te llame a las tres de la mañana. Son peores. Se quedan quietos durante meses, hasta que alguien cruza facturas con logs, eventos de Kafka con registros de auditoría, o datos del ERP con el sistema de pagos. Entonces aparece una diferencia de una hora. Solo una. Pero suficiente para que todo el mundo mire la base de datos como si acabara de confesar un crimen. Y muchas veces el culpable está en una línea aparentemente inocente: created_at AT TIME ZONE 'UTC'

Parece una conversión limpia a UTC. Parece exactamente lo que habría que hacer. De hecho, es una de esas expresiones que se copian de Stack Overflow, se meten en una vista y no se vuelven a mirar. Pero Postgres no hace lo que tú querías decir. Hace lo que le has pedido. Y no siempre es lo mismo. El problema: no todos los timestamps significan lo mismo En Postgres hay dos tipos que se parecen demasiado en el nombre y se comportan de forma muy distinta: - timestamp without time zone - timestamp with time zone, normalmente escrito como timestamptz La diferencia es clave. Un timestamptz representa un instante real en el tiempo. Algo que ocurrió en un momento concreto. Da igual si lo ves desde Madrid, Londres o Nueva York: el instante es el mismo, aunque la representación cambie. Un timestamp without time zone, en cambio, es una fecha y una hora sin contexto. Es un “2026-01-15 10:30:00” flotando en el vacío. Puede ser UTC, Madrid, Nueva York o la hora local de una fábrica en Zaragoza. El dato no lo dice. Y cuando el dato no lo dice, alguien acaba suponiéndolo. Ahí empieza la fiesta. Qué hace realmente AT TIME ZONE El operador AT TIME ZONE tiene dos comportamientos distintos, según el tipo de entrada. Si partes de un timestamptz: SELECT created_at AT TIME ZONE 'UTC' FROM events;

Postgres toma ese instante real y lo representa como hora UTC. Pero el resultado es un timestamp without time zone. Es decir: te devuelve una fecha que parece UTC, pero ya no lleva la información de zona. Si partes de un timestamp sin zona: SELECT '2026-01-15 10:00:00'::timestamp AT TIME ZONE 'UTC';

Postgres interpreta que ese valor naïf era UTC y lo convierte a un timestamptz. Mismo operador. Resultado conceptual distinto. Esta doble semántica es potente, pero también es una fuente maravillosa de errores discretos. De esos que no hacen ruido hasta que los datos importan. El falso UTC El patrón peligroso suele ser este: SELECT created_at AT TIME ZONE 'UTC' AS created_at_utc FROM events;

Si created_at era timestamptz, el resultado se llama created_at_utc, parece UTC y probablemente alguien lo documentó como UTC. Pero técnicamente ya es un timestamp sin zona. Eso significa que el siguiente sistema que lo lea tiene que adivinar la intención. Un BI puede tratarlo como hora local. Un ETL puede aplicarle la zona del servidor. Un script de Python puede serializarlo sin Z. Un data lake puede mezclarlo con eventos que sí venían en ISO 8601 con offset. Y entonces tienes dos columnas que visualmente se parecen, pero semánticamente no son lo mismo. Una representa un instante. La otra representa una convención. El cambio horario es donde se ve la sangre Durante buena parte del año, estos errores pasan desapercibidos. La diferencia parece constante, los reportes cuadran “más o menos” y nadie se preocupa demasiado. Hasta que llega el cambio de hora. En Europa, hay momentos del año donde una hora no existe y otros donde una hora ocurre dos veces. Si trabajas con timestamps sin zona, Postgres no puede saber cuál de las dos “02:30” querías decir. Tampoco puede saber si esa “02:30” era local, UTC o una convención inventada por una vista antigua. Y no, la base de datos no va a detenerte con una sirena. Simplemente va a devolver un valor válido. El problema es que quizá no sea el valor que tú creías. Dónde suele romperse Los casos habituales son bastante reconocibles. En auditoría, alguien guarda cambios con algo parecido a: now() AT TIME ZONE 'UTC'

La columna queda como timestamp. Meses después se intenta cruzar esa tabla con logs de aplicación que sí vienen con offset real. En días normales parece funcionar. En bordes horarios, no. En reporting financiero, una vista agrupa operaciones por día después de aplicar AT TIME ZONE 'UTC'. El problema es que negocio quizá quería día local, fiscal o comercial, no día UTC. Y esas tres cosas no siempre coinciden. En pipelines de datos, herramientas como Airbyte, Debezium, Spark o scripts propios leen columnas timestamp y aplican sus propias reglas. Muchas veces dependen de la zona horaria del proceso, del contenedor, de la JVM o del destino. Lo que tú llamabas UTC se convierte en otra cosa sin que nadie haya escrito explícitamente “conviértelo”. La raíz del problema no es Postgres. Postgres está siendo coherente. La raíz es usar un tipo sin zona para representar algo que sí necesitaba una zona. La regla buena: instantes reales van en timestamptz Para eventos, auditoría, pagos, logs, integraciones, colas, facturas, pedidos y casi cualquier cosa que haya ocurrido en un momento concreto, usa timestamptz. created_at timestamptz NOT NULL DEFAULT now()

Eso es lo sano. Postgres almacenará el instante de forma normalizada y lo mostrará según la zona horaria de la sesión. Si tu sesión está en UTC, lo verás en UTC. Si está en Europe/Madrid, lo verás en hora española. Pero el instante será el mismo. Para trabajar en UTC, configura las sesiones de aplicación en UTC: SET TIME ZONE 'UTC';

O, mejor aún, configúralo en la cadena de conexión, en el pool o en el rol de base de datos usado por la aplicación. Ejemplo: ALTER ROLE app_user SET timezone = 'UTC';

Así no necesitas ir llenando queries de conversiones que después alguien interpretará mal. Cuándo sí tiene sentido AT TIME ZONE AT TIME ZONE no es malo. Lo malo es usarlo sin pensar. Tiene sentido cuando quieres presentar un instante en una zona concreta: SELECT created_at AT TIME ZONE 'Europe/Madrid' AS created_at_madrid FROM events;

Eso puede estar perfecto para un reporte, una exportación o una pantalla donde negocio quiere ver hora local española. Pero debe entenderse como lo que es: una representación para consumo humano o analítico, no necesariamente un dato canónico para almacenar. También puede usarse en una migración única, cuando tienes una columna antigua timestamp que en realidad sabes que estaba en UTC y quieres convertirla a timestamptz: ALTER TABLE events ALTER COLUMN created_at TYPE timestamptz USING created_at AT TIME ZONE 'UTC';

Aquí sí tiene sentido, porque estás diciendo explícitamente: “Este timestamp sin zona debe interpretarse como UTC.” Pero eso debe hacerse una vez, de forma controlada, documentada y validada. No como parche permanente en cada vista. Cómo exportar en UTC sin perder la cabeza Si necesitas entregar fechas en UTC a otro sistema, lo ideal es mantener timestamptz hasta el borde y serializar en formato ISO 8601 con offset. Por ejemplo: SELECT to_jsonb(created_at) AS created_at FROM events;

O, si necesitas controlar el formato: SELECT to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS created_at_utc FROM events;

Aquí sí estás convirtiendo a texto final. Ya no estás fingiendo que un timestamp sin zona conserva semántica. Estás generando una representación explícita para salida. La diferencia es importante: el string con Z ya declara su intención. Qué revisar en una base existente Lo primero es buscar usos de AT TIME ZONE en vistas y funciones: SELECT schemaname, viewname, definition FROM pg_views WHERE definition ILIKE '%AT TIME ZONE%';

Y en funciones: SELECT proname, prosrc FROM pg_proc WHERE prosrc ILIKE '%AT TIME ZONE%';

Después hay que revisar columnas temporales: SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns WHERE data_type IN ('timestamp without time zone', 'timestamp with time zone') ORDER BY table_schema, table_name, column_name;

La pregunta no es “¿hay timestamps?”. La pregunta buena es: ¿Qué columnas representan instantes reales y por qué no son timestamptz? Si una columna se llama created_at, updated_at, deleted_at, event_time, paid_at, sent_at, received_at, processed_at o changed_at, debería levantar sospechas si es timestamp without time zone. Qué deberías hacer mañana 1. Busca todos los AT TIME ZONE en vistas, funciones, jobs y queries de reporting. 2. Clasifica cada caso: ¿es presentación, almacenamiento, migración o parche histórico? 3. Audita columnas timestamp without time zone que representen eventos reales. 4. Migra a timestamptz las columnas que modelan instantes absolutos. 5. Configura la zona horaria de sesiones de aplicación a UTC. 6. Deja AT TIME ZONE para presentación o migraciones controladas, no como normalizador universal. 7. Documenta la regla en tu guía SQL: “instantes reales en timestamptz; fechas humanas o locales en date, time o timestamp solo cuando tenga sentido”. Hay casos donde timestamp without time zone es correcto. Un cumpleaños, una fecha de vencimiento puramente administrativa, un horario de apertura o una planificación local pueden no necesitar zona. Pero un evento que ocurrió en producción, un pago, una factura, un log o una auditoría sí la necesitan. La trampa no está en Postgres. La trampa está en pensar que una fecha sin zona sigue sabiendo de dónde viene.

¿Reconoces este problema en tu empresa? Compártelo en LinkedIn

Compartir en LinkedIn

¿Revisas tus conversiones de zona horaria?

Ayudamos a equipos de datos a auditar esquemas y migraciones para evitar derivas silenciosas en producción.

Hablemos Ver más posts