sqlpostgresqltimestamptztimezone

timestamptz versus timestamp no SQL: guardar a hora certo

Por que eventos quase sempre devem ser timestamptz, como ele difere do timestamp ingenuo e como evitar perder a zona em silencio ao misturar os dois.

3 min de leituraReferencesql · postgresql · timestamptz · timezone · datetime

Um tipo guarda um momento real da historia; o outro guarda simples digitos numa parede, sem zona associada. Escolher entre timestamptz e timestamp decide se seus dados batem para usuarios de fusos diferentes ou se desviam em silencio. Vamos ao concreto.

Um instante versus um relogio de parede

timestamptz (apelido timestamp with time zone) guarda um instante absoluto: por dentro e UTC, e a zona da sessao e aplicada so na exibicao. timestamp (sem zona) e um "relogio de parede ingenuo": o numero 2024-03-15 10:00 sem responder "10 da manha, onde exatamente".

SET timezone = 'UTC';
SELECT
  now()::timestamptz                    AS instant,   -- absolute, stored as UTC
  '2024-03-15 10:00'::timestamp         AS wall_time; -- naive, no zone

O mesmo timestamptz aparece diferente conforme a sessao, mas e um unico instante:

SET timezone = 'America/New_York';
SELECT '2024-03-15 10:00+00'::timestamptz;  -- shows 2024-03-15 06:00:00-04

SET timezone = 'Europe/Moscow';
SELECT '2024-03-15 10:00+00'::timestamptz;  -- shows 2024-03-15 13:00:00+03

Por que eventos quase sempre sao timestamptz

Qualquer coluna do tipo "quando isso aconteceu", um cadastro, um pagamento, uma linha de log, e um momento da historia. Se voce a guarda como timestamp, perde a zona, e duas linhas de Lisboa e de Moscou ficam incomparaveis.

CREATE TABLE users (
  id         bigserial PRIMARY KEY,
  email      text NOT NULL UNIQUE,
  name       text,
  country    text,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
  id         bigserial PRIMARY KEY,
  user_id    bigint NOT NULL REFERENCES users(id),
  amount     numeric(12,2) NOT NULL,
  status     text NOT NULL DEFAULT 'new',
  created_at timestamptz NOT NULL DEFAULT now()
);

Assim as consultas por intervalo ficam corretas seja qual for a zona do servidor:

SELECT count(*)
FROM orders
WHERE created_at >= '2024-03-01 00:00+00'
  AND created_at <  '2024-04-01 00:00+00';

Um timestamp sem zona se justifica raramente: uma "data de nascimento", um "a loja abre as 09:00", coisas identicas em qualquer zona que nao nomeiam um instante concreto.

O bug silencioso ao misturar os tipos

A armadilha mais perigosa e atribuir uma string "crua" sem zona a uma coluna timestamptz. O PostgreSQL nao da erro: ele preenche a zona em silencio a partir da sessao atual. Mude a sessao e o significado dos dados muda junto.

SET timezone = 'America/New_York';
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2024-03-15 10:00');   -- interpreted as 10:00 New York

SET timezone = 'UTC';
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2024-03-15 10:00');   -- interpreted as 10:00 UTC

As duas linhas parecem identicas, mas sao instantes diferentes, separados por horas.

Pegadinha: um cast timestamptz::timestamp (e o inverso) nao converte o relogio, ele so remove ou acrescenta uma zona pelas regras da sessao. Comparar um timestamptz com um timestamp tambem coage um deles em silencio pela zona da sessao. Defina sempre a zona explicita no literal ('... +00') ou fixe SET timezone = 'UTC'.

Guardar em UTC, exibir na zona do cliente

A regra de trabalho: guarde tudo como timestamptz e faca a conversao de apresentacao com o operador AT TIME ZONE na saida.

SELECT
  id,
  created_at,                                  -- instant (UTC under the hood)
  created_at AT TIME ZONE 'Europe/Berlin' AS berlin_wall_time
FROM orders
ORDER BY created_at DESC
LIMIT 5;

AT TIME ZONE e um comutador de tipo:

  • timestamptz AT TIME ZONE 'zone' da um timestamp (o relogio de parede naquela zona);
  • timestamp AT TIME ZONE 'zone' da um timestamptz (trata a hora ingenua como local daquela zona).

Agrupar pelo "dia local" de um funcionario fica assim:

SELECT
  e.dept,
  date_trunc('day', o.created_at AT TIME ZONE 'Europe/Berlin') AS local_day,
  sum(o.amount) AS revenue
FROM orders o
JOIN employees e ON e.id = o.user_id
GROUP BY e.dept, local_day
ORDER BY local_day;

A tabela employees(id, name, manager_id, dept, salary) liga aqui cada pedido a um time.

Diferencas no MySQL e no ClickHouse

  • MySQL: o tipo TIMESTAMP guarda UTC e converte conforme @@session.time_zone, o que se aproxima mais do timestamptz, mas seu intervalo e limitado a 1970-2038. O tipo DATETIME nao guarda zona nenhuma (o analogo do timestamp ingenuo). Para eventos costuma-se escolher TIMESTAMP, ou DATETIME mais uma coluna de zona separada.
  • ClickHouse: DateTime guarda tempo Unix (UTC) e pode carregar um nome de zona como parametro do tipo, DateTime('Europe/Berlin'); a zona afeta o parsing e a exibicao, nao o valor guardado. Para precisao de fracoes de segundo use DateTime64.

Resumindo: eventos vao em timestamptz, o timestamp ingenuo e so para dados independentes de zona, a conversao vai com AT TIME ZONE na saida, e declare sempre a zona nos literais para nunca pegar um deslocamento silencioso.

Pratique com exercícios reais

Resolva exercícios no treinador de SQL com correção instantânea e dicas.

Abrir o treinador