Log de eventos, audit trail e ledger têm uma coisa em comum: só crescem.
Nos primeiros meses isso não importa. Depois de dois anos, você tem uma tabela de centenas de milhões de linhas, e algumas coisas param de funcionar de um jeito que ninguém associa ao tamanho.
O VACUUM demora horas. O índice não cabe mais na memória. Apagar dado antigo trava a tabela. Backup e restauração viram evento de fim de semana. E criar um índice novo passa a ser uma operação de risco.
Particionar resolve tudo isso. Particionar depois é bem mais caro que particionar antes.
Como fica
No Postgres, partição declarativa por faixa de tempo:
create table event (
id bigserial,
stream_type text not null,
stream_id uuid not null,
type text not null,
payload jsonb not null,
occurred_at timestamptz not null,
recorded_at timestamptz not null default now(),
primary key (id, occurred_at)
) partition by range (occurred_at);
create table event_2026_09 partition of event
for values from ('2026-09-01') to ('2026-10-01');
create table event_2026_10 partition of event
for values from ('2026-10-01') to ('2026-11-01');
Repare na chave primária: precisa incluir a coluna de particionamento. É uma restrição do Postgres e é a primeira coisa que surpreende quem está migrando.
Isso tem uma consequência: uma constraint única global, do tipo unique (hash), não é possível numa tabela particionada a menos que inclua a chave de partição. Se o seu desenho depende de unicidade global, como o de rastreabilidade com hash, você precisa decidir isso antes: ou a unicidade é por partição, ou vive numa tabela separada de índice.
O que você ganha
Consulta com poda. Uma busca com filtro em occurred_at toca só as partições relevantes. Consultar o último mês numa tabela de dois anos lê 1/24 do dado.
Isso só funciona se o filtro estiver na coluna de particionamento. Consulta por stream_id sem faixa de data varre todas as partições, e aí você não ganhou nada.
Remoção instantânea. Essa é a maior vantagem, e é a que mais gente subestima.
-- apagar um mês: metadado, sem tocar em linha
drop table event_2026_01;
-- ou tirar da tabela pai e arquivar
alter table event detach partition event_2026_01;
Comparado com delete from event where occurred_at < ..., que numa tabela grande gera lock longo, WAL enorme, replicação atrasada e um VACUUM doloroso depois, a diferença é entre segundos e um incidente.
Manutenção por pedaço. VACUUM e ANALYZE rodam por partição. Índice se cria numa partição por vez. A partição antiga, que não recebe escrita, pode até ficar em tablespace mais barato.
A automação obrigatória
O erro operacional clássico é esquecer de criar a partição do mês seguinte. Aí, no dia primeiro, todo insert falha.
create or replace function ensure_event_partitions(months_ahead int default 3)
returns void as $$
declare
d date := date_trunc('month', now())::date;
i int;
name text;
begin
for i in 0..months_ahead loop
name := 'event_' || to_char(d + (i || ' month')::interval, 'YYYY_MM');
if not exists (select 1 from pg_class where relname = name) then
execute format(
'create table %I partition of event for values from (%L) to (%L)',
name,
d + (i || ' month')::interval,
d + ((i+1) || ' month')::interval
);
end if;
end loop;
end;
$$ language plpgsql;
Isso roda num job diário e cria com meses de antecedência. E precisa ter alerta: se a função falhar por três dias seguidos e ninguém souber, você descobre na virada do mês, em produção.
Uma partição padrão, DEFAULT, evita a falha catastrófica ao custo de esconder o problema. Se usar, monitore: linha caindo na partição padrão é sinal de que a automação quebrou.
Escolher o tamanho da partição
A regra prática que eu uso: cada partição deve ficar entre alguns milhões e algumas dezenas de milhões de linhas.
Muito granular, particionando por dia com pouco volume, produz milhares de partições, e o planejador do Postgres fica lento com isso. Muito grosso, particionando por ano, não resolve o problema que motivou particionar.
Volume alto pede mês ou semana. Volume moderado pede trimestre. E vale revisar: uma tabela que cresce dez vezes muda a resposta.
Migrar uma tabela existente
O caminho sem downtime é chato e conhecido.
Crie a estrutura particionada com outro nome. Anexe a tabela antiga inteira como uma partição, cobrindo a faixa histórica. Crie as partições novas para frente. Renomeie, em transação, dentro de uma janela curta.
alter table event rename to event_historico;
create table event (...) partition by range (occurred_at);
alter table event attach partition event_historico
for values from ('2000-01-01') to ('2026-10-01');
O ATTACH precisa validar que todas as linhas cabem na faixa, o que numa tabela grande demora e pega lock. Adicionar uma constraint CHECK compatível antes, com NOT VALID e depois validada, permite que o ATTACH seja rápido.
Teste isso numa cópia do tamanho da produção. É uma operação que dá certo em tabela de teste e demora quarenta minutos na real.
O que não resolver com partição
Partição não substitui índice. Dentro de cada partição você continua precisando dos índices certos, e eles se criam por partição.
Partição não conserta consulta ruim. Se a query não filtra por tempo, ela varre tudo, agora em pedaços.
E partição não é escalabilidade horizontal. Continua sendo um banco. Se o gargalo é escrita concorrente, o problema é outro.
A decisão que eu tomaria
Se a tabela é append-only e tem qualquer expectativa de crescer, já nasça particionada. Custa uma tarde no começo e uma função de automação.
A alternativa é aquela conversa, dois anos depois, sobre como apagar dado antigo sem derrubar a produção, com o disco em 85% e o relógio correndo.