Programação Por Equipe DevBras

Capa do artigo: O relatório de "hoje" comeu três horas de ontem: grave em UTC, mostre no fuso local

O relatório de "hoje" comeu três horas de ontem: grave em UTC, mostre no fuso local

Guarde instantes em UTC, converta para America/Sao_Paulo só na exibição e calcule "hoje" com os limites do dia local, em PostgreSQL e JavaScript.

A loja migrou o banco para um servidor novo, configurado em UTC. Na manhã seguinte, o relatório de vendas de "ontem" tinha 47 pedidos a menos e o de "hoje" já começou com vendas. Ninguém mexeu no código. Um pedido feito às 22h30 de 15 de outubro em São Paulo é 01h30 de 16 de outubro em UTC, e o CURRENT_DATE do servidor novo o colocou no dia seguinte. A resposta curta: grave o instante em UTC, ou num tipo que guarda o instante, e converta para o fuso de quem lê só na hora de mostrar. Quando a pergunta for "pedidos de hoje", calcule o começo e o fim do dia no fuso do negócio e filtre por esse intervalo.

Datas sem fuso, como "2026-10-15 21:00", não dizem de onde é esse 21. Elas funcionam enquanto todos os servidores e pessoas estão no mesmo fuso, e quebram em silêncio no dia em que algum deles muda.

Por que o dia muda de dono

Um instante é um ponto único na linha do tempo. "01h30 UTC de 16/10" e "22h30 de 15/10 em São Paulo" são o mesmo instante escrito de dois jeitos. O problema começa quando o sistema guarda só os números do relógio, sem dizer de qual fuso eles são, e cada parte do sistema completa a informação com o fuso que tiver à mão.

O banco completa com o fuso da sessão. A aplicação completa com o fuso do processo, que vem da variável TZ ou da configuração do sistema operacional. O navegador completa com o fuso do aparelho da pessoa. Basta um deles mudar, como numa migração para a nuvem ou num container com imagem mínima, que costuma vir em UTC, e o mesmo dado passa a significar outro horário.

O ponto mais frágil é a meia-noite. Durante 21 horas do dia, UTC e São Paulo concordam sobre a data. Nas três horas entre 21h e meia-noite de Brasília, discordam. Teste que roda às 14h nunca vê o problema.

Como guardar e mostrar horários sem erro

  1. Escolha um tipo que guarda o instante. No PostgreSQL, timestamptz: ele converte a entrada para UTC e devolve no fuso da sessão. O timestamp sem fuso guarda só os números do relógio.
  2. Envie horários com fuso explícito. Em JSON, use ISO 8601 com Z ou com deslocamento: 2026-10-16T01:30:00Z ou 2026-10-15T22:30:00-03:00.
  3. Converta só na exibição, para o fuso que a pessoa escolheu ou para o fuso do negócio, usando o nome IANA (America/Sao_Paulo), não um deslocamento fixo.
  4. Defina o fuso do negócio para relatórios. "Vendas de hoje" de uma loja em São Paulo é o dia de São Paulo, não o do servidor.
  5. Filtre por intervalo de instantes, do começo do dia local até o começo do dia seguinte, em vez de comparar datas truncadas.
  6. Teste perto da meia-noite, com um pedido às 22h30 de Brasília.

O pedido das 22h30 no PostgreSQL

CREATE TABLE pedidos (
  id        bigserial PRIMARY KEY,
  total     integer NOT NULL,
  criado_em timestamptz NOT NULL DEFAULT now()
);

INSERT INTO pedidos (total, criado_em)
VALUES (15990, '2026-10-15 22:30:00-03');

SET TIME ZONE 'UTC';
SELECT criado_em FROM pedidos;
-- 2026-10-16 01:30:00+00

SET TIME ZONE 'America/Sao_Paulo';
SELECT criado_em FROM pedidos;
-- 2026-10-15 22:30:00-03

O dado gravado é o mesmo. Só a forma de mostrar mudou com o fuso da sessão. Agora o relatório de "hoje" no fuso da loja, sem depender do fuso do servidor:

SELECT count(*), sum(total)
FROM pedidos
WHERE criado_em >= date_trunc('day', now() AT TIME ZONE 'America/Sao_Paulo') AT TIME ZONE 'America/Sao_Paulo'
  AND criado_em <  (date_trunc('day', now() AT TIME ZONE 'America/Sao_Paulo') + interval '1 day') AT TIME ZONE 'America/Sao_Paulo';

Leia de dentro para fora. now() AT TIME ZONE 'America/Sao_Paulo' dá o relógio de São Paulo agora, sem fuso. O date_trunc corta para a meia-noite local. O segundo AT TIME ZONE interpreta essa meia-noite como horário de São Paulo e devolve o instante correspondente. A comparação acontece entre instantes, e um índice em criado_em continua sendo usado, o que não aconteceria com WHERE criado_em::date = ....

No MySQL

O DATETIME guarda os números do relógio, sem fuso. O TIMESTAMP converte do fuso da sessão para UTC ao gravar e de volta ao ler, mas tem limite de data em 2038. Se usar DATETIME, combine que todo valor gravado está em UTC e garanta isso na aplicação, por exemplo configurando o fuso da conexão. No PHP, o equivalente está em data e fuso no PHP.

Na tela, com JavaScript

const criadoEm = new Date('2026-10-16T01:30:00Z');

const formato = new Intl.DateTimeFormat('pt-BR', {
  timeZone: 'America/Sao_Paulo',
  dateStyle: 'short',
  timeStyle: 'short'
});

console.log(formato.format(criadoEm));
// 15/10/2026, 22:30

Atenção a uma armadilha do Date: uma string só com data, como '2026-10-15', é interpretada como meia-noite UTC. Exibida em São Paulo, vira 14/10 às 21h. Já '2026-10-15T21:00', com hora e sem fuso, é interpretada no fuso local do ambiente que executa o código. São duas regras diferentes para strings parecidas, e por isso a API deve sempre mandar o fuso.

Quando UTC não basta: compromissos futuros

Para coisas que já aconteceram (pedido, login, pagamento), UTC é a resposta. Para compromissos no futuro marcados por pessoas, como "consulta dia 10 de março às 9h em São Paulo", guarde também o horário local e o nome do fuso. Regras de fuso mudam por decisão de governo: o Brasil, por exemplo, deixou de adotar o horário de verão em 2019. Se as regras mudarem de novo, o instante em UTC calculado hoje ficaria errado, enquanto "9h em America/Sao_Paulo" continua significando o que a pessoa combinou. Mantenha o banco de fusos do sistema e do runtime atualizados.

Erros comuns

  • Gravar horário sem fuso. Cada camada completa com o fuso que tem, e uma migração de servidor muda o significado de todos os dados.
  • Usar CURRENT_DATE ou a data do servidor como "hoje". Entre 21h e meia-noite de Brasília, um servidor em UTC já está no dia seguinte.
  • Guardar deslocamento fixo (-03:00) no lugar do nome do fuso. Funciona até a regra mudar, e não serve para quem está em outro estado ou país.
  • Filtrar com criado_em::date. Além de usar o fuso da sessão, impede o uso do índice em tabelas grandes.
  • Mandar datas sem fuso no JSON. O navegador interpreta cada formato de um jeito e a tela mostra outro dia.
  • Testar só em horário comercial. O erro mora nas três horas antes da meia-noite.

Checklist

  • Colunas de instante usam timestamptz ou têm convenção documentada de UTC.
  • A API envia e aceita horários com Z ou deslocamento explícito.
  • A conversão para o fuso local acontece só na exibição.
  • Relatórios por dia usam o fuso do negócio, com nome IANA.
  • Filtros de "hoje" comparam intervalos de instantes.
  • Existe teste com um registro às 22h30 de Brasília.
  • Compromissos futuros guardam horário local e nome do fuso.

Rode agora a consulta do seu relatório de "hoje" às 22h e de novo às 10h da manhã seguinte: os pedidos das 22h aparecem no dia em que a pessoa fez a compra ou no dia do servidor?

Avalie este artigo

Referência: https://www.w3.org/International/wiki/WorkingWithTimeZones