Blog

Fuso horário que corrompe relatório: por que o fechamento do mês não bate entre regiões

O financeiro fechou setembro com quatro milhões e duzentos, o time de dados reportou quatro milhões e cento e oitenta e sete, e a diferença de treze mil apareceu de novo em outubro com outro valor. Ninguém errou conta: os dois estavam somando o mesmo conjunto de linhas com duas definições diferentes do que é setembro. Este artigo mostra por que o problema de fuso não é conversão de exibição e sim definição de intervalo, por que guardar tudo em UTC resolve metade do problema e cria a outra metade, o que é a janela deslizante de quarenta e oito horas em que uma mesma venda pertence a dois meses ao mesmo tempo, por que o horário de verão faz um dia ter vinte e três ou vinte e cinco horas e o que isso quebra em agregação por hora, qual é a diferença entre instante e data civil e por que misturar os dois tipos na mesma coluna é a causa raiz, como escrever a consulta de fechamento que produz o mesmo número em qualquer região, e quais cinco verificações detectam a corrupção antes de o relatório sair.

2026-09-21 / Arquitetura / 18 min

01

Dois relatórios corretos que discordam em treze mil reais

A conversa sempre começa do mesmo jeito. Alguém mostra dois números que deveriam ser idênticos e pergunta qual está errado. A resposta desconfortável é que nenhum dos dois está errado: eles respondem a perguntas diferentes que foram escritas com as mesmas palavras. O relatório do financeiro pergunta quanto foi faturado no mês de setembro no horário de São Paulo. O painel de dados pergunta quanto foi faturado entre o primeiro instante de setembro em UTC e o último instante de setembro em UTC. São dois intervalos distintos, deslocados em três horas, e a diferença entre eles é exatamente o volume de vendas que aconteceu nessas três horas de fronteira.

O que torna esse erro difícil de encontrar é que ele não produz sintoma nenhum na maior parte do tempo. Em um mês de movimento constante, a diferença é pequena o bastante para ser confundida com arredondamento ou com uma venda estornada. Em um mês com campanha que termina à meia-noite do dia trinta, a diferença explode, porque a última hora de uma promoção concentra um volume desproporcional e essa hora cai justamente na fronteira disputada. É por isso que o problema aparece primeiro no mês em que o negócio deu certo, e não no mês tranquilo em que alguém teria tempo de investigar.

FRONTEIRA DE SETEMBRO, DUAS DEFINICOES

  Sao Paulo (UTC-3)        30/set 21:00 ---- 30/set 23:59 ---- 01/out 00:00
  UTC                      01/out 00:00 ---- 01/out 02:59 ---- 01/out 03:00

  Venda registrada em 2026-10-01T01:30:00Z
    -> em UTC:        outubro
    -> em Sao Paulo:  30 de setembro, 22:30

  Relatorio financeiro  (mes civil de Sao Paulo):  a venda entra em setembro
  Painel de dados       (mes civil em UTC):        a venda entra em outubro

  JANELA DE AMBIGUIDADE POR FECHAMENTO

    inicio: 30/set 21:00 UTC
    fim:    01/out 03:00 UTC
    duracao: 3h para UTC-3, ate 14h quando ha regiao em UTC+11 no mesmo relatorio

  Todo registro dentro dessa janela pertence a dois meses ao mesmo tempo,
  e o mes que ele recebe depende de qual consulta o leu primeiro.

A regra que sai desse desenho vale para qualquer agregação por período: o fuso não é uma propriedade de formatação aplicada no fim, é o parâmetro que define quais linhas entram na soma. Trocar o fuso de exibição muda como uma data aparece na tela e não muda nenhum total. Trocar o fuso do recorte muda o conjunto de linhas somadas e, portanto, muda o total. Times inteiros perdem semanas tratando o primeiro caso quando o problema é o segundo, porque a palavra fuso é usada para os dois.

02

Instante e data civil são dois tipos diferentes na mesma coluna

A causa raiz quase sempre está no esquema, não na consulta. Existem dois tipos temporais distintos e a maioria dos bancos permite guardar os dois na mesma coluna sem reclamar. O instante é um ponto absoluto na linha do tempo: o momento exato em que o pagamento foi autorizado. Ele é o mesmo em qualquer lugar do mundo e só faz sentido comparado com outros instantes. A data civil é uma etiqueta de calendário: a data de vencimento da fatura, o dia da competência contábil, a data de nascimento. Ela não tem instante associado, porque o vencimento no dia dez é o dia dez em qualquer região, e forçá-la a virar instante é o que faz a data de nascimento andar um dia para trás quando o servidor muda de região.

Quando os dois tipos ocupam colunas com o mesmo formato, a diferença desaparece do código e cada leitor aplica a interpretação que achar natural. O serviço de cobrança lê a data de vencimento como instante em UTC, converte para o fuso local e mostra o dia nove para o cliente do Acre. O relatório de inadimplência lê a mesma coluna como data civil e considera o dia dez. Ninguém escreveu um bug: cada lado escolheu uma leitura defensável para um dado que nunca declarou qual delas era a correta.

DadoTipo corretoO que quebra com o tipo errado
Momento do pagamento autorizadoInstante com fuso (timestamptz)Ordem de eventos invertida entre regiões e conciliação com o adquirente sem bater
Data de vencimento da faturaData civil (date), sem horaVencimento anda um dia ao mudar a região do servidor
Competência contábil do lançamentoAno e mês explícitos, não derivadosLançamento migra de mês quando a consulta troca de fuso
Início do expediente da lojaHora local mais identificador de fusoLoja abre no horário errado após o horário de verão
Agendamento futuro recorrenteHora local mais fuso, resolvido na execuçãoReunião das nove pula para as dez quando o país muda a regra
Prazo de retenção de trinta diasInstante mais duraçãoExclusão adiantada ou atrasada em uma hora duas vezes por ano

A decisão que resolve a ambiguidade não é escolher um fuso padrão, é declarar o tipo no nome e no esquema. Uma coluna chamada pago_em do tipo timestamptz é inequivocamente um instante. Uma coluna chamada vence_em do tipo date é inequivocamente uma data civil. Uma coluna chamada data, do tipo timestamp sem fuso, é uma armadilha que vai custar um fechamento inteiro para alguém descobrir. O custo dessa correção é uma migração de tipo e uma revisão de nomes, e ela é a única que remove a classe inteira de erro em vez de corrigir um relatório por vez.

03

A consulta de fechamento que produz o mesmo número em qualquer região

Com o tipo certo no esquema, a consulta de fechamento passa a ser um problema simples de expressar, desde que uma regra seja respeitada: o fuso de recorte é um parâmetro explícito da consulta, nunca uma configuração de sessão herdada do ambiente. Consulta que depende do fuso da sessão produz um resultado no laptop do analista, outro no servidor de relatórios e um terceiro no job noturno, e as três execuções são igualmente defensáveis porque nenhuma delas declarou qual mês estava sendo pedido.

-- reports/monthly_close.sql
-- Fechamento mensal com fuso de recorte explicito. O parametro nao e
-- decoracao: ele define quais linhas entram na soma, e duas execucoes
-- com fusos diferentes produzem totais diferentes por definicao.

-- ERRADO: depende do fuso da sessao, que muda entre ambientes.
-- SELECT date_trunc('month', pago_em) AS mes, sum(valor)
--   FROM pagamentos GROUP BY 1;

-- CERTO: converte o instante para o fuso do recorte antes de truncar,
-- e devolve o intervalo usado junto do total para que o numero possa
-- ser auditado sem reexecutar a consulta.
WITH parametros AS (
  SELECT
    $1::text AS fuso,            -- 'America/Sao_Paulo'
    $2::date AS mes_referencia   -- '2026-09-01'
),
janela AS (
  SELECT
    fuso,
    mes_referencia,
    -- O timestamp local do primeiro instante do mes vira instante
    -- absoluto aplicando o fuso. AT TIME ZONE sobre timestamp sem fuso
    -- produz timestamptz, que e o que o indice de pago_em compara.
    (mes_referencia::timestamp AT TIME ZONE fuso) AS inicio,
    ((mes_referencia + interval '1 month')::timestamp AT TIME ZONE fuso) AS fim
  FROM parametros
)
SELECT
  j.mes_referencia,
  j.fuso,
  j.inicio,
  j.fim,
  count(*)                         AS quantidade,
  coalesce(sum(p.valor), 0)        AS total
FROM janela j
LEFT JOIN pagamentos p
  -- Comparacao meio aberta: inclui o inicio, exclui o fim. E o que
  -- impede que o instante exato da virada seja contado nos dois meses
  -- quando os dois relatorios rodam em sequencia.
  ON p.pago_em >= j.inicio
 AND p.pago_em <  j.fim
 AND p.estorno_em IS NULL
GROUP BY j.mes_referencia, j.fuso, j.inicio, j.fim;

Três detalhes dessa consulta carregam quase todo o valor. O primeiro é a conversão acontecer no cálculo das bordas e não em cada linha: aplicar a função de fuso sobre a coluna dentro do WHERE desabilita o índice e transforma um fechamento de dois segundos numa varredura de tabela inteira. O segundo é o intervalo meio aberto, que é a única forma de garantir que meses consecutivos particionem o conjunto sem sobreposição e sem buraco. O terceiro é devolver as bordas junto do total, porque um número de fechamento sem o intervalo que o gerou não é auditável e vira exatamente a discussão de treze mil reais que motivou o artigo.

Quando o relatório precisa somar operações de várias regiões ao mesmo tempo, não existe um fuso de recorte único que seja correto. A saída é decidir a política e escrevê-la: ou o grupo inteiro fecha no fuso da matriz, e cada filial aceita que o mês dela termina em um horário local esquisito, ou cada filial fecha no fuso local, e o consolidado é a soma dos fechamentos locais, não um recorte global. As duas opções são válidas e produzem números diferentes. O erro é não escolher, porque aí cada consulta escolhe sozinha.

04

Horário de verão: o dia que tem vinte e três horas

A transição de horário de verão quebra uma suposição que está espalhada por todo código de agregação temporal: a de que um dia tem vinte e quatro horas e que somar vinte e quatro horas a um instante avança um dia civil. No dia em que o relógio adianta, o dia civil local tem vinte e três horas e existe uma hora local que simplesmente não aconteceu. No dia em que atrasa, o dia tem vinte e cinco horas e existe uma hora local que aconteceu duas vezes, com dois instantes absolutos distintos mapeando para o mesmo texto de hora.

O sintoma prático é um gráfico de vendas por hora com uma barra vazia ou uma barra com o dobro do volume, duas vezes por ano, em países que adotam a prática. Como o Brasil suspendeu o horário de verão, é comum o time concluir que o problema não se aplica, e essa conclusão é errada por dois motivos. O primeiro é que os dados históricos anteriores à suspensão continuam no banco e continuam sendo reagregados por consultas novas. O segundo é que qualquer cliente, fornecedor ou integração em região que ainda pratica a mudança traz o problema de volta pela borda, e nesse caso ele aparece só na fatia daquele cliente, o que é muito mais difícil de perceber.

// time/civil.js
// Aritmetica de calendario nao e aritmetica de milissegundos. Somar
// 24h a um instante avanca 24h reais, que nem sempre e um dia civil.

const MS_POR_DIA = 24 * 60 * 60 * 1000;

// Errado: assume que todo dia tem 24h. Na virada do horario de verao o
// resultado cai no mesmo dia civil ou pula um dia inteiro.
export const proximoDiaErrado = (instante) =>
  new Date(instante.getTime() + MS_POR_DIA);

// Certo: opera sobre os campos civis no fuso alvo e deixa a conversao
// de volta para instante resolver a transicao.
export const partesCivis = (instante, fuso) => {
  const formatador = new Intl.DateTimeFormat('en-CA', {
    timeZone: fuso,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  });

  const partes = Object.fromEntries(
    formatador.formatToParts(instante).map(({ type, value }) => [type, value]),
  );

  return {
    data: `${partes.year}-${partes.month}-${partes.day}`,
    hora: Number(partes.hour) % 24,
    minuto: Number(partes.minute),
  };
};

// Duracao real de um dia civil no fuso alvo. Retorna 23, 24 ou 25 horas.
// Qualquer media por hora que divida por 24 fixo erra nesses dois dias.
export const horasNoDiaCivil = (dataCivil, fuso) => {
  const inicio = inicioDoDiaCivil(dataCivil, fuso);
  const fim = inicioDoDiaCivil(somarDiasCivis(dataCivil, 1), fuso);
  return (fim.getTime() - inicio.getTime()) / (60 * 60 * 1000);
};

export const somarDiasCivis = (dataCivil, dias) => {
  const [ano, mes, dia] = dataCivil.split('-').map(Number);
  // Date.UTC faz aritmetica de calendario sem fuso envolvido: e seguro
  // porque aqui a data civil e apenas uma etiqueta, nao um instante.
  const movido = new Date(Date.UTC(ano, mes - 1, dia + dias));
  return movido.toISOString().slice(0, 10);
};

// Resolve a data civil local para o instante absoluto correspondente,
// tratando os dois casos patologicos da transicao.
export const inicioDoDiaCivil = (dataCivil, fuso) => {
  const palpite = new Date(`${dataCivil}T00:00:00Z`);

  // Duas passagens: a primeira estima o deslocamento, a segunda corrige
  // quando a estimativa caiu do outro lado da transicao.
  let instante = palpite;
  for (let i = 0; i < 2; i += 1) {
    const local = partesCivis(instante, fuso);
    const deslocamentoMin =
      (Date.parse(`${local.data}T${String(local.hora).padStart(2, '0')}:${String(local.minuto).padStart(2, '0')}:00Z`) -
        instante.getTime()) /
      60000;
    instante = new Date(palpite.getTime() - deslocamentoMin * 60000);
  }

  // Hora inexistente (relogio adiantou): o inicio do dia civil passa a
  // ser a primeira hora que de fato existiu naquele dia.
  const conferencia = partesCivis(instante, fuso);
  if (conferencia.data !== dataCivil) {
    return new Date(instante.getTime() + 60 * 60 * 1000);
  }

  return instante;
};

A consequência menos óbvia é a comparação ano contra ano. Se o relatório compara o faturamento de uma segunda-feira com o da segunda-feira do ano anterior e uma das duas está dentro do horário de verão, as duas janelas cobrem quantidades diferentes de tempo real e a variação percentual embute um erro que não tem nada a ver com o negócio. A correção não é ajustar o número, é declarar no relatório qual janela foi usada e deixar visível quando as duas não têm a mesma duração.

05

Guardar o instante e a competência: dois campos, dois propósitos

A correção que elimina a classe de erro em definitivo é parar de derivar o mês do instante no momento da consulta e passar a gravar a competência junto do registro. Isso parece redundância e não é: são duas informações diferentes. O instante diz quando a transação aconteceu na linha do tempo absoluta, e ele é imutável e auditável. A competência diz a qual período contábil aquela transação pertence, e essa é uma decisão de negócio que pode divergir do instante de propósito, como acontece com um lançamento feito no dia dois de outubro com competência de setembro porque o serviço foi prestado em setembro.

Com a competência gravada, o fechamento deixa de depender de fuso, de função de conversão e de configuração de sessão. Ele vira um filtro de igualdade sobre uma coluna indexada, que produz exatamente o mesmo número em qualquer região, em qualquer ferramenta, para qualquer pessoa. O fuso continua sendo usado no momento de decidir qual competência atribuir, que é onde a decisão pertence, e essa decisão é tomada uma vez, na escrita, por um código só, em vez de ser retomada em cada consulta por leitores que não sabem que estão tomando uma decisão.

-- migrations/0042_competencia.sql
-- Separa o instante absoluto da competencia contabil. A competencia e
-- atribuida uma vez, na escrita, com o fuso da operacao explicito.

ALTER TABLE pagamentos
  ADD COLUMN competencia char(7),          -- 'AAAA-MM'
  ADD COLUMN fuso_operacao text NOT NULL   -- 'America/Sao_Paulo'
    DEFAULT 'America/Sao_Paulo';

-- Carga historica: deriva a competencia dos registros existentes usando
-- o fuso da operacao de cada um, nao um fuso unico para todos. Rodar em
-- lotes para nao segurar lock longo na tabela inteira.
UPDATE pagamentos
   SET competencia = to_char(pago_em AT TIME ZONE fuso_operacao, 'YYYY-MM')
 WHERE competencia IS NULL
   AND id IN (
     SELECT id FROM pagamentos WHERE competencia IS NULL
      ORDER BY id LIMIT 50000
   );

-- Depois da carga completa, a coluna vira obrigatoria e ganha indice.
ALTER TABLE pagamentos ALTER COLUMN competencia SET NOT NULL;
CREATE INDEX CONCURRENTLY idx_pagamentos_competencia
  ON pagamentos (competencia)
  WHERE estorno_em IS NULL;

-- Verificacao de consistencia: nenhum registro pode ter competencia que
-- diverge do instante em mais de um mes. Divergencia de ate um mes e
-- legitima (servico prestado em setembro, pago em outubro); mais do que
-- isso e sinal de carga historica com fuso errado.
SELECT competencia,
       to_char(pago_em AT TIME ZONE fuso_operacao, 'YYYY-MM') AS derivada,
       count(*)
  FROM pagamentos
 WHERE competencia <> to_char(pago_em AT TIME ZONE fuso_operacao, 'YYYY-MM')
 GROUP BY 1, 2
 ORDER BY 3 DESC;

O campo de fuso da operação é o detalhe que torna a carga histórica correta em vez de aproximada. Sem ele, a migração precisa assumir um fuso único para todos os registros, e essa suposição está errada para qualquer operação que atendeu mais de uma região. Com ele, cada linha carrega o contexto em que foi criada e a competência derivada é a que o time local teria atribuído. Quando esse dado não existe no histórico, a honestidade é derivar com o fuso predominante e marcar as linhas derivadas, para que uma conciliação futura saiba quais números são reconstruídos e quais são originais.

06

Cinco verificações que detectam a corrupção antes do relatório

A maior parte dos erros de fuso é detectável de forma automática e barata, porque eles têm assinaturas estatísticas muito específicas. O que falta não é capacidade de detecção, é alguém ter escrito a verificação. Estas cinco cobrem a grande maioria dos casos e rodam em segundos sobre uma tabela de milhões de linhas.

  1. Soma dos meses contra o total do ano. Se os doze fechamentos mensais não somam exatamente o total anual calculado com o mesmo fuso, existe sobreposição ou buraco na fronteira e o culpado costuma ser um intervalo fechado nos dois lados.
  2. Histograma por hora local na virada do mês. Se as horas entre vinte e uma e vinte e três do último dia têm volume próximo de zero e a hora zero do primeiro dia do mês seguinte tem um pico, o recorte está em UTC enquanto o negócio opera em UTC menos três.
  3. Contagem de registros com competência divergente do instante em mais de trinta e um dias. Divergência pequena é legítima, divergência grande é carga histórica feita com o fuso errado.
  4. Duração dos dias civis agregados. Qualquer dia do conjunto que não tenha vinte e quatro horas precisa coincidir com uma transição conhecida de horário de verão naquele fuso; se não coincide, o identificador de fuso usado está desatualizado.
  5. Reexecução do fechamento com dois fusos diferentes. Se os totais são idênticos, a consulta está ignorando o parâmetro e provavelmente usando o fuso da sessão, o que é pior do que usar o fuso errado porque muda silenciosamente entre ambientes.

A quinta verificação é a mais valiosa e a menos intuitiva. Ela testa a consulta, não os dados, e detecta a falha mais perigosa dessa área: o parâmetro de fuso que existe na assinatura, aparece na documentação e não influencia o resultado. Uma consulta assim passa em toda revisão de código, porque o parâmetro está lá, e produz números diferentes conforme onde roda. Rodar o mesmo fechamento com dois fusos distantes e exigir que os totais sejam diferentes é um teste de duas linhas que fecha essa porta.

Vale também manter uma verificação sobre a base de dados de fusos do ambiente. As regras de fuso mudam por decisão política, com pouca antecedência, e um contêiner com a base congelada há dois anos converte corretamente para todo mundo, exceto para os países que mudaram a regra desde então. O sintoma é um deslocamento de exatamente uma hora que afeta só uma região e só a partir de uma data específica, e ele é praticamente impossível de diagnosticar sem suspeitar da base de fusos primeiro.

FAQ

Perguntas frequentes

Guardar tudo em UTC não resolve o problema de uma vez?

Resolve a metade do problema que diz respeito ao armazenamento e não toca na metade que causa a divergência de relatório. Guardar instantes em UTC garante que dois registros gravados por máquinas em regiões diferentes sejam comparáveis entre si e que a ordem dos eventos seja preservada, o que é indispensável. Mas nenhum relatório de negócio pergunta quanto foi faturado em UTC: ele pergunta quanto foi faturado em setembro, e setembro é um conceito civil que só existe dentro de um fuso. O recorte continua precisando de um fuso explícito, e é exatamente aí que os dois números divergem. Existe ainda o caso em que UTC é a resposta errada até para o armazenamento, que são as datas civis puras e os agendamentos futuros. Converter a data de vencimento para um instante em UTC faz o vencimento andar um dia dependendo de onde o código roda, e converter uma reunião recorrente das nove da manhã para um instante fixo faz a reunião mudar de horário quando o país altera a regra de fuso, porque o que foi guardado é o instante e não a intenção. A regra prática é: instante em UTC, data civil como data sem hora, agendamento futuro como hora local mais identificador de fuso resolvido na execução.

Como corrigir relatórios históricos que já foram publicados com o recorte errado?

O primeiro passo é medir o tamanho do erro antes de decidir qualquer coisa, e a medida certa é o volume que cai dentro da janela de ambiguidade em cada período, não o total do período. Some apenas as transações entre a borda do recorte antigo e a borda do recorte correto: se isso representa menos de um décimo de por cento do mês e nenhum desses meses foi usado em declaração fiscal ou comunicação a investidor, republicar costuma gerar mais confusão do que corrige. O segundo passo, quando o valor é material, é nunca sobrescrever o número publicado: gere a série corrigida ao lado da original, com o fuso de recorte declarado em cada uma, e mantenha as duas acessíveis. Alguém vai encontrar uma apresentação antiga com o número antigo, e a única forma de essa pessoa não concluir que os dados estão quebrados é existir um registro explicando a diferença. O terceiro passo é congelar o passado: assim que a competência estiver gravada por registro, os fechamentos anteriores deixam de ser recalculáveis por consulta e passam a ser fatos guardados, o que impede que uma mudança futura de fuso mexa em número já auditado. Para o período anterior à existência do campo de competência, materialize o resultado em uma tabela de fechamento em vez de deixá-lo dependente da consulta.

Vale a pena usar uma biblioteca de datas ou dá para resolver com o que a linguagem oferece?

A resposta mudou nos últimos anos e depende mais do tipo de operação do que da linguagem. Para converter um instante em texto no fuso do usuário, a API de internacionalização nativa já resolve bem e não justifica dependência. Para aritmética de calendário, que é somar meses, encontrar o primeiro dia da semana ou resolver o início de um dia civil em um fuso, a API tradicional de data do JavaScript é insuficiente e propensa a erro, porque ela mistura instante e data civil no mesmo objeto e usa o fuso do ambiente como padrão implícito. Nesse caso, uma biblioteca com tipos separados para instante, data civil e hora local paga o próprio peso, e a API moderna de tempo que está chegando às plataformas adota justamente essa separação de tipos. O critério de escolha que importa mais do que o nome da biblioteca é este: ela precisa obrigar a passar o fuso explicitamente e falhar quando ele não é informado, em vez de assumir o fuso do ambiente em silêncio. Qualquer função que aceita fuso opcional com padrão implícito reintroduz o problema inteiro, porque o ambiente do servidor de produção nunca é o mesmo do laptop onde a consulta foi escrita. Vale ainda garantir que a base de regras de fuso seja atualizada junto com as dependências, porque uma biblioteca correta com uma base velha erra do mesmo jeito.

O mês é um parâmetro, não um dado que já está no banco

Relatório que discorda de relatório quase nunca é erro de conta: é a mesma soma feita sobre dois recortes que ninguém declarou. Posso revisar como o seu sistema representa tempo e definir a separação entre instante e data civil no esquema, a consulta de fechamento com fuso de recorte explícito e intervalo meio aberto, a gravação da competência na escrita para tornar o fechamento independente de fuso, o tratamento das transições de horário de verão na agregação por hora e por dia, e as verificações automáticas que detectam a divergência antes de o número chegar ao financeiro.