sqlpostgresqldate-trunctime-series

DATE_TRUNC no SQL: truncar timestamps em periodos para series temporais

Como o DATE_TRUNC arredonda um timestamp para baixo ate hora, dia, semana, mes ou ano para agrupar series temporais.

2 min de leituraReferencesql · postgresql · date-trunc · time-series · mysql · clickhouse

DATE_TRUNC descarta qualquer parte de um timestamp mais fina que o periodo que voce pedir e devolve o inicio desse periodo. E o cavalo de batalha de toda analise temporal: linhas com precisao de milissegundos se agrupam em baldes limpos por hora, dia, semana, mes ou ano.

O que o DATE_TRUNC faz de fato

O primeiro argumento e o campo ('hour', 'day', 'week', 'month', 'year', etc.); o segundo e um timestamp ou uma date. O resultado e arredondado para baixo ate o inicio do periodo, zerando as unidades menores.

SELECT
  DATE_TRUNC('month', TIMESTAMP '2026-06-17 14:32:09') AS month_start,
  DATE_TRUNC('day',   TIMESTAMP '2026-06-17 14:32:09') AS day_start,
  DATE_TRUNC('hour',  TIMESTAMP '2026-06-17 14:32:09') AS hour_start;
-- 2026-06-01 00:00:00 | 2026-06-17 00:00:00 | 2026-06-17 14:00:00

Propriedades principais:

  • Sempre trunca para baixo, nunca para o mais proximo: 14:32 com 'hour' da 14:00.
  • Retorna o mesmo tipo timestamp, entao fica facil comparar e agrupar.
  • 'week' no PostgreSQL comeca na segunda-feira (ISO), uma surpresa comum se voce esperava domingo.

Agrupando uma serie temporal em baldes

O caso de uso principal e calcular uma metrica por periodo. Quantos usuarios se cadastraram a cada mes?

SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(*)                        AS signups
FROM users
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;

A mesma ideia para receita diaria, agora a partir de orders e filtrando por status:

SELECT
  DATE_TRUNC('day', created_at) AS day,
  SUM(amount)                   AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY DATE_TRUNC('day', created_at)
ORDER BY day;

Agrupe pela expressao DATE_TRUNC(...), e nao pelo created_at cru, senao cada segundo distinto vira uma linha propria.

Preenchendo lacunas com generate_series

GROUP BY so retorna periodos que tem dados. Se um dia nao teve pedidos, a linha simplesmente desaparece e seu grafico mostra uma linha quebrada. Para ter um eixo continuo, gere um calendario e faca um LEFT JOIN contra ele.

SELECT
  cal.day,
  COALESCE(SUM(o.amount), 0) AS revenue
FROM generate_series(
       DATE_TRUNC('day', DATE '2026-06-01'),
       DATE_TRUNC('day', DATE '2026-06-30'),
       INTERVAL '1 day'
     ) AS cal(day)
LEFT JOIN orders o
  ON DATE_TRUNC('day', o.created_at) = cal.day
 AND o.status = 'paid'
GROUP BY cal.day
ORDER BY cal.day;

Agora todos os 30 dias aparecem, e os dias sem pedidos relatam honestamente um 0.

Fusos horarios: a grande pegadinha

O DATE_TRUNC(campo, ts) de dois argumentos nao calcula em UTC. Quando ts e um timestamptz, o PostgreSQL trunca conforme o ajuste TimeZone da sessao, entao a mesma consulta pode colocar um pedido em dias diferentes do calendario dependendo de quem a executa. Um pedido as 2026-06-01 01:30 UTC ja pertence ao dia 1 de junho para uma sessao em Sao Paulo, mas para uma em UTC-5 ainda e 31 de maio. Isso desloca seus baldes mensais e diarios bem na fronteira do dia.

Por isso vale sempre fixar o fuso em vez de confiar no ajuste da sessao. Converta com AT TIME ZONE primeiro e so depois trunque:

SELECT
  DATE_TRUNC('day', created_at AT TIME ZONE 'America/Sao_Paulo') AS day_br,
  COUNT(*)
FROM orders
GROUP BY DATE_TRUNC('day', created_at AT TIME ZONE 'America/Sao_Paulo');

O PostgreSQL moderno (14+) tambem oferece uma forma de tres argumentos, DATE_TRUNC(campo, ts, fuso), que trunca um timestamptz no fuso que voce indicar e elimina qualquer duvida sobre o ajuste da sessao:

SELECT
  DATE_TRUNC('day', created_at, 'America/Sao_Paulo') AS day_br,
  COUNT(*)
FROM orders
GROUP BY DATE_TRUNC('day', created_at, 'America/Sao_Paulo');

Para um passo arbitrario (digamos 15 minutos) use date_bin('15 minutes', created_at, TIMESTAMP '2026-01-01').

Equivalentes em MySQL e ClickHouse

DATE_TRUNC nao e universal, e a sintaxe varia.

  • MySQL nao tem DATE_TRUNC. Trunque para mes com DATE_FORMAT e para dia com DATE().
SELECT
  DATE_FORMAT(created_at, '%Y-%m-01') AS month,
  COUNT(*)                            AS signups
FROM users
GROUP BY DATE_FORMAT(created_at, '%Y-%m-01');
  • ClickHouse traz funcoes dedicadas: toStartOfMonth, toStartOfDay, toStartOfHour, e toStartOfInterval para um passo arbitrario.
SELECT
  toStartOfMonth(created_at) AS month,
  count()                    AS signups
FROM users
GROUP BY month
ORDER BY month;

Lembre-se de uma coisa: a intencao e identica em todo lugar, zerar a cauda do timestamp para que as linhas se encaixem em baldes. So muda o nome da funcao.

Pratique com exercícios reais

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

Abrir o treinador