Particionamento de tabelas no PostgreSQL

Quando uma tabela de eventos ou logs tem bilhões de linhas, queries ficam lentas mesmo com índices perfeitos. Particionamento divide a tabela em partes menores que o PostgreSQL consulta seletivamente, queries filtradas por data tocam apenas as partições relevantes, e deletar dados antigos se torna um DROP instantâneo em vez de um DELETE lento.

Particionamento por RANGE (por data)

O padrão mais comum: particionar tabela de eventos por mês:

sql
-- Criar tabela particionada por mês (RANGE no campo data):
CREATE TABLE eventos (
  id          BIGSERIAL,
  ocorrido_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  tipo        TEXT NOT NULL,
  usuario_id  BIGINT,
  dados       JSONB
) PARTITION BY RANGE (ocorrido_em);

-- Criar partições mensais:
CREATE TABLE eventos_2026_05 PARTITION OF eventos
  FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

CREATE TABLE eventos_2026_06 PARTITION OF eventos
  FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE TABLE eventos_2026_07 PARTITION OF eventos
  FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

-- Partição para valores não cobertos (default):
CREATE TABLE eventos_outros PARTITION OF eventos DEFAULT;

-- Índices são criados POR PARTIÇÃO:
CREATE INDEX ON eventos_2026_06 (usuario_id);
CREATE INDEX ON eventos_2026_06 (tipo, ocorrido_em);
-- Ou criar índice na tabela pai (se aplica a todas as partições):
CREATE INDEX ON eventos (tipo, ocorrido_em);

-- Inserir funciona normalmente — PostgreSQL roteia para a partição correta:
INSERT INTO eventos (tipo, usuario_id) VALUES ('login', 123);

-- Query que filtra por data usa apenas a partição relevante:
EXPLAIN SELECT * FROM eventos WHERE ocorrido_em >= '2026-06-01' AND ocorrido_em < '2026-07-01';
-- "Append → Seq Scan on eventos_2026_06" (apenas 1 partição!)

Automatizar criação de partições

Script para criar partições automaticamente todo mês:

sql
-- Função para criar a próxima partição mensal:
CREATE OR REPLACE FUNCTION criar_particao_proxima()
RETURNS void AS $
DECLARE
  inicio DATE := DATE_TRUNC('month', NOW() + INTERVAL '1 month')::DATE;
  fim    DATE := DATE_TRUNC('month', NOW() + INTERVAL '2 months')::DATE;
  nome   TEXT := 'eventos_' || TO_CHAR(inicio, 'YYYY_MM');
BEGIN
  -- Verificar se a partição já existe:
  IF NOT EXISTS (
    SELECT 1 FROM pg_class c
    JOIN pg_namespace n ON n.oid = c.relnamespace
    WHERE c.relname = nome AND n.nspname = 'public'
  ) THEN
    EXECUTE format(
      'CREATE TABLE %I PARTITION OF eventos FOR VALUES FROM (%L) TO (%L)',
      nome, inicio::TEXT, fim::TEXT
    );
    EXECUTE format('CREATE INDEX ON %I (usuario_id)', nome);
    EXECUTE format('CREATE INDEX ON %I (tipo, ocorrido_em)', nome);
    RAISE NOTICE 'Partição % criada', nome;
  ELSE
    RAISE NOTICE 'Partição % já existe', nome;
  END IF;
END;
$ LANGUAGE plpgsql;

-- Agendar com pg_cron (extensão de cron para PostgreSQL):
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule('0 0 1 * *',  -- todo dia 1 à meia-noite
  $SELECT criar_particao_proxima()$);

-- Ou usar pg_partman (extensão especializada em particionamento):
-- SELECT partman.create_parent('public.eventos', 'ocorrido_em', 'native', 'monthly');
-- SELECT partman.run_maintenance();

Particionamento por LIST e HASH

Outros tipos de particionamento para casos específicos:

sql
-- PARTITION BY LIST: partições por valor específico (ex: por país ou status):
CREATE TABLE pedidos_por_pais (
  id         BIGSERIAL,
  criado_em  TIMESTAMPTZ DEFAULT NOW(),
  pais       TEXT NOT NULL,
  total      NUMERIC(10,2)
) PARTITION BY LIST (pais);

CREATE TABLE pedidos_brasil   PARTITION OF pedidos_por_pais FOR VALUES IN ('BR');
CREATE TABLE pedidos_mexico   PARTITION OF pedidos_por_pais FOR VALUES IN ('MX');
CREATE TABLE pedidos_outros   PARTITION OF pedidos_por_pais DEFAULT;

-- PARTITION BY HASH: distribuição uniforme (ex: sharding por user_id):
CREATE TABLE eventos_hash (
  id         BIGSERIAL,
  usuario_id BIGINT NOT NULL,
  tipo       TEXT,
  dados      JSONB
) PARTITION BY HASH (usuario_id);

-- 4 partições com distribuição de hash:
CREATE TABLE eventos_hash_0 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE eventos_hash_1 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE eventos_hash_2 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE eventos_hash_3 PARTITION OF eventos_hash FOR VALUES WITH (MODULUS 4, REMAINDER 3);

-- Hash partitioning: queries que filtram por usuario_id usam apenas 1 partição
-- Útil quando não há um campo de data mas precisa distribuir a carga

Excluir partições antigas (retenção de dados)

Deletar dados antigos de forma instantânea com DROP:

sql
-- Deletar dados com mais de 6 meses via DROP (instantâneo!):
-- vs DELETE: pode levar horas e criar dead tuples

-- Verificar partições existentes:
SELECT
  inhrelid::regclass AS particao,
  pg_size_pretty(pg_relation_size(inhrelid)) AS tamanho
FROM pg_inherits
WHERE inhparent = 'eventos'::regclass
ORDER BY particao;

-- Identificar partições para remover:
SELECT
  c.relname AS nome,
  pg_size_pretty(pg_relation_size(c.oid)) AS tamanho
FROM pg_class c
JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE i.inhparent = 'eventos'::regclass
  AND c.relname < 'eventos_' || TO_CHAR(NOW() - INTERVAL '6 months', 'YYYY_MM');

-- Desanexar (detach) e dropar:
ALTER TABLE eventos DETACH PARTITION eventos_2025_10;
DROP TABLE eventos_2025_10;
-- Toda operação leva milissegundos!

-- Automatizar via função de retenção:
CREATE OR REPLACE FUNCTION limpar_particoes_antigas(meses INT DEFAULT 6)
RETURNS void AS $
DECLARE
  nome TEXT;
BEGIN
  FOR nome IN
    SELECT c.relname
    FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
    WHERE i.inhparent = 'eventos'::regclass
      AND c.relname < 'eventos_' || TO_CHAR(NOW() - (meses || ' months')::INTERVAL, 'YYYY_MM')
  LOOP
    EXECUTE format('ALTER TABLE eventos DETACH PARTITION %I', nome);
    EXECUTE format('DROP TABLE %I', nome);
    RAISE NOTICE 'Partição % removida', nome;
  END LOOP;
END;
$ LANGUAGE plpgsql;

Particionar tabela existente sem downtime

Migrar tabela grande para particionada com mínimo impacto:

sql
-- Estratégia para tabelas existentes (sem downtime):

-- 1. Criar nova tabela particionada (nome temporário):
CREATE TABLE eventos_v2 (LIKE eventos INCLUDING ALL)
  PARTITION BY RANGE (ocorrido_em);

-- 2. Criar partições históricas + futuras:
-- (executar o script de criação de partições)

-- 3. Copiar dados em batches (sem bloquear):
-- pg_partman faz isso automaticamente, mas manualmente:
INSERT INTO eventos_v2
SELECT * FROM eventos
WHERE ocorrido_em >= '2026-01-01' AND ocorrido_em < '2026-02-01';
-- Repetir para cada mês histórico

-- 4. Sincronizar writes novos com trigger temporário na tabela original
-- (ou usar downtime mínimo — fora do horário de pico)

-- 5. Trocar as tabelas:
BEGIN;
ALTER TABLE eventos RENAME TO eventos_legado;
ALTER TABLE eventos_v2 RENAME TO eventos;
COMMIT;

-- 6. Redirecionar a aplicação para a nova tabela
-- (pode ser transparente se o nome não mudou)

-- 7. Verificar e dropar a tabela legada:
-- DROP TABLE eventos_legado;  -- após confirmação de que tudo funciona

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

Particionamento ajuda quando: (1) queries filtram pela coluna de particionamento (pruning acontece, apenas partições relevantes são consultadas); (2) a tabela tem > 100 milhões de linhas e cresce continuamente; (3) precisa deletar dados antigos frequentemente. NÃO ajuda (ou pode piorar): quando queries não filtram pela coluna de particionamento, tabelas pequenas (< 10M linhas), ou quando os índices já cobrem bem as queries.

No PostgreSQL 11+: índices criados na tabela pai propagam automaticamente para todas as partições (incluindo futuras). Nas versões anteriores: cada partição precisava de índice criado separadamente. PRIMARY KEY e UNIQUE em tabelas particionadas devem incluir a coluna de particionamento (limitação do particionamento nativo). Para PRIMARY KEY que não inclui a coluna de particionamento: use índices únicos parciais em cada partição.

Com limitações. Foreign keys para outras tabelas: funcionam normalmente em cada partição. Foreign keys DE outras tabelas para a tabela particionada: não são suportadas no PostgreSQL até a versão 16 (limitação do particionamento nativo). Workaround: usar CHECK constraints + triggers para validação, ou aplicar a constraint na aplicação.

Overhead mínimo, o planejador PostgreSQL identifica a partição correta em tempo de planejamento (constraint exclusion). Para tabelas com muitas partições (> 100): o overhead de planejamento aumenta. Para INSERT em batch: use COPY ou INSERT em partições específicas diretamente (mais rápido). pg_partman gerencia a criação automática e tem bom suporte para partições de alta ingestão.

Sim, DROP/DETACH é uma operação de metadados (milissegundos), independente do tamanho da partição. DELETE FROM eventos WHERE ocorrido_em < "2025-01-01" em uma tabela de 1 bilhão de linhas pode levar horas, criar dead tuples que precisam de VACUUM, e gerar WAL massivo. DETACH + DROP: instantâneo, sem dead tuples, sem VACUUM necessário. Esta é a principal vantagem prática do particionamento por tempo.