Timestamps sem timezone: a migração que levou três semanas
O erro silencioso de armazenar datetimes sem fuso horário: como acontece, por que ninguém percebe, e como evitar a migração dolorosa.
Trabalhei num sistema que guardava timestamps sem timezone por dois anos. A migração levou três semanas. Não precisava ser assim.
O sistema estava em produção, tinha dados históricos, tinha relatórios que dependiam de "created_at", tinha clientes em São Paulo e em Lisboa que viam horários diferentes sem entender por quê. Não havia nenhum erro no log. O banco aceitava, a aplicação aceitava, o ORM aceitava. O problema era silencioso porque nunca foi um bug — foi uma suposição.
Esse post é sobre essa suposição, por que ela destrói dados, e como evitar a migração de três semanas.
O que acontece quando você não especifica timezone
Quando você cria uma coluna DATETIME no MySQL ou TIMESTAMP WITHOUT TIME ZONE no PostgreSQL (que é o padrão quando você escreve só TIMESTAMP), está dizendo ao banco: "guarda esse número, mas não te preocupa com fuso horário".
O banco obedece. Ele grava exatamente o que você mandou — sem converter, sem normalizar, sem reclamar. Se a aplicação mandou 2026-03-15 10:00:00, o banco grava 2026-03-15 10:00:00. Ponto.
O problema começa quando você pergunta "10h de onde?".
Se a aplicação roda num servidor em UTC, 10:00:00 é UTC. Se o servidor muda para um container na AWS com timezone America/Sao_Paulo, 10:00:00 vira BRT — e todos os registros antigos, que foram gravados em UTC, agora parecem estar em BRT. O banco não sabe a diferença. Você acabou de corromper sua percepção histórica dos dados sem alterar uma linha sequer.
A suposição silenciosa
O problema não é técnico — é semântico. Um datetime sem timezone carrega uma suposição implícita: "quem lê sabe em que fuso isso está". Enquanto o sistema tem uma única região, um único servidor, e todo mundo é do mesmo time, a suposição aguenta. Quando qualquer uma dessas condições muda, ela quebra.
No sistema onde trabalhei, a suposição era "tudo é horário de Brasília". Funcionou por dois anos porque o servidor estava em São Paulo e todos os usuários também. Aí a empresa cresceu, mudou a infraestrutura para cloud, os containers subiram em us-east-1 com timezone UTC, e os relatórios de "pedidos hoje" começaram a mostrar três horas a menos.
Ninguém tocou na tabela. Ninguém mudou a query. O problema surgiu de uma mudança de infraestrutura que não tinha nada a ver com o banco de dados.
TIMESTAMP WITH TIME ZONE resolve — mas tem nuance
No PostgreSQL, TIMESTAMPTZ (alias de TIMESTAMP WITH TIME ZONE) normaliza tudo para UTC internamente e converte para o timezone da sessão na leitura. Isso significa:
-- Sessão com timezone America/Sao_Paulo
INSERT INTO events (created_at) VALUES ('2026-03-15 10:00:00');
-- O banco grava 2026-03-15 13:00:00 UTC internamente
-- Na leitura com sessão UTC, retorna 2026-03-15 13:00:00+00
-- Na leitura com sessão America/Sao_Paulo, retorna 2026-03-15 10:00:00-03
A suposição deixa de ser silenciosa — ela fica explícita no timezone da sessão. Você controla. Se quiser sempre UTC na leitura, configura SET timezone = 'UTC' na conexão.
No MySQL, o equivalente é DATETIME com armazenamento manual em UTC na aplicação, ou usar a coluna TIMESTAMP nativa (que tem suporte a timezone mas tem limitação de range até 2038 — o problema do Unix epoch). Para novos sistemas em MySQL, a convenção mais sólida é guardar como BIGINT em epoch Unix (milissegundos) e resolver o display na aplicação.
A migração que não deveria existir
Quando você precisa migrar uma tabela de TIMESTAMP WITHOUT TIME ZONE para TIMESTAMPTZ com dados históricos, o processo é:
- Descobrir em qual timezone os dados históricos estavam sendo gravados (reza para estar documentado em algum lugar)
- Converter cada linha multiplicando pela suposição de fuso
- Validar que os relatórios históricos batem com o que estava antes
- Mudar o timezone da aplicação para UTC de forma consistente
O passo 1 foi o que levou três semanas no meu caso. O sistema tinha passado por duas migrações de servidor, o README não mencionava timezone em lugar nenhum, e tinha um período de seis meses onde os dados foram gravados em UTC por engano durante um deploy. Tivemos que auditar os timestamps comparando com logs de aplicação para descobrir qual era qual.
-- Exemplo de migração quando você sabe que os dados estão em BRT (UTC-3)
ALTER TABLE orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ
USING created_at AT TIME ZONE 'America/Sao_Paulo';
Parece simples. Não é simples quando você não sabe o timezone de origem.
Inspecionando timestamps antes de gravar
Parte do problema é que timestamps são opacos — um epoch Unix como 1747900800 não diz nada à primeira vista. Antes de qualquer decisão sobre armazenamento, vale verificar o que o valor representa em diferentes fusos.
Para isso uso o Conversor de Timestamp — você cola o epoch e vê a data em UTC, no seu horário local, e em qualquer fuso que quiser. Útil especialmente quando você está debugando dados históricos e precisa confirmar se 1620000000 é de manhã ou de tarde em Brasília.
A regra que evita a migração
Guarde sempre em UTC. Exiba no timezone do usuário. Nunca guarde no timezone do servidor.
É simples de enunciar e fácil de errar na prática porque ORMs e frameworks nem sempre deixam claro o que estão fazendo. Django com USE_TZ = True converte automaticamente para UTC. Rails com config.time_zone pode te enganar se você não entender a diferença entre Time.now e Time.current. Node com new Date() retorna UTC por padrão mas serializa em ISO 8601 com offset — que pode ou não ser preservado dependendo do driver do banco.
A checagem que faço antes de qualquer novo modelo:
-- PostgreSQL: sempre use TIMESTAMPTZ
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- Verificar timezone da conexão antes de gravar
SHOW timezone;
-- deve retornar UTC em produção
E no postgresql.conf ou na connection string:
timezone = UTC
Não como sugestão. Como configuração obrigatória do ambiente de produção.
O que fica quando você ignora isso
Dados sem timezone não são dados neutros — são dados com timezone implícito e não documentado. Enquanto o sistema for pequeno e homogêneo, a suposição aguenta. Quando escala, quando muda de servidor, quando recebe usuários de outros fusos, quando alguém faz uma query direto no banco num laptop com timezone diferente — a conta chega.
A migração de três semanas foi cara. Mais caro foi o período antes dela, quando os relatórios estavam errados e ninguém sabia.
- 01 diff antes de abrir PR: o hábito que reduz comentários de revisão Ler o próprio diff antes de abrir um PR muda o que você vê no código. Como e por que esse hábito reduz ciclos de revisão e melhora a qualidade dos PRs.
- 02 `P@ssw0rd1` passa na sua validação. Esse é o problema. O que o NIST mudou em 2024 sobre senhas: comprimento bate complexidade, leet speak está no rockyou.txt e rotação obrigatória enfraquece senhas.