Todos os artigos
74 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

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.

COVER · Tutoriais

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 é:

  1. Descobrir em qual timezone os dados históricos estavam sendo gravados (reza para estar documentado em algum lugar)
  2. Converter cada linha multiplicando pela suposição de fuso
  3. Validar que os relatórios históricos batem com o que estava antes
  4. 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.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil