PostgreSQL 29 Jul 2026

Estatísticas no PostgreSQL: o que o planner enxerga

Post 02 da série “Estatísticas: SQL Server × PostgreSQL”

01 · Estatísticas no SQL Server

✅ · 02 · PostgreSQL (você está aqui)

03 · Comparativo (em breve)

Ambiente usado nos exemplos: PostgreSQL 18, tabela de 5 milhões de linhas gerada com generate_series. Todos os comandos estão no final para você reproduzir.

O sintoma

Você rodou ANALYZE. O plano continua ruim.

No SQL Server, a gente sabe o caminho: abre um DBCC SHOW_STATISTICS, olha o histograma, o density vector, a data da última atualização. No PostgreSQL, muita gente roda o ANALYZE, cruza os dedos… e para por aí.

Neste post vamos tentar entender o comportamento das estatisticas no PostgreSQL, o que exatamente o ANALYZE coleta, onde isso fica guardado, como o planner usa cada peça e quem decide quando essa foto é tirada (spoiler: não é você, é o autovacuum).

Como no Post 01, nada aqui é teoria de manual todos os números vieram de um PostgreSQL 18 real rodando na minha máquina.

E a escolha da versão não foi por acaso: o PostgreSQL 18 mexeu justamente em estatísticas, em dois pontos que mudam a rotina de quem administra base grande:

📌 O que mudou no PostgreSQL 18 (e importa para este post)

  1. Estatísticas sobrevivem ao upgrade de versão maior. Até o PG 17, o pg_upgrade descartava as estatísticas do planner o cluster subia “cego” e degradava até o ANALYZE geral terminar. No 18, elas são preservadas, e a janela de risco pós-upgrade praticamente desaparece.
  2. ANALYZE em tabela particionada ficou recursivo por padrão. Rodar na tabela-mãe agora processa também as partições filhas; a nova palavra ONLY restringe à estrutura-mãe. Quem cuida de VLDB particionado precisa revisar os scripts de manutenção antes de migrar.

O planner cego: o que acontece sem estatística nenhuma

Criei uma tabela vendas com 5 milhões de linhas e uma distribuição propositalmente desbalanceada o tipo de dado que a gente encontra na vida real:

  • 5 clientes “VIP” concentram 40% das vendas (~400 mil cada);
  • ~10 mil clientes dividem os outros 60% (~300 vendas cada).

Com o autovacuum temporariamente desligado na tabela (só para a demo!) e sem ANALYZE, perguntei ao planner sobre dois clientes de realidades opostas:

DROP TABLE IF EXISTS vendas;

CREATE TABLE vendas (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    cliente_id  integer      NOT NULL,
    data_venda  date         NOT NULL,
    valor       numeric(10,2) NOT NULL
) WITH (autovacuum_enabled = false);

INSERT INTO vendas (cliente_id, data_venda, valor)
SELECT
    CASE WHEN g % 10 < 4
         THEN (g % 5) + 1                       -- 40% → clientes 1..5
         ELSE 6 + (g % 9995)                    -- 60% → clientes 6..10000
    END,
    DATE '2024-01-01' + (g / 6850),             -- ~6.850 vendas/dia, sequencial
    round((random() * 1000)::numeric, 2)
FROM generate_series(1, 5000000) AS g;
EXPLAIN 
SELECT * FROM vendas WHERE cliente_id = 3;     -- VIP: ~400.000 linhas

Veja que o Planer estimou o retorno de 21657 linhas, mas a consulta retornou 500000.

EXPLAIN 
SELECT * FROM vendas WHERE cliente_id = 7777;

Com o cliente_id = 7777 o Planner estimou as mesmas 21657 linhas, mas a consulta retornou 250.

As duas consultas receberam a mesma estimativa. Sem estatísticas, o planner não tem como saber que o cliente 3 é um gigante e o 7777 é raro ele aplica uma seletividade padrão e torce para estar certo. Detalhe pequeno (como diria o rei da sexta-feira), o pg_class.reltuples fica em -1 numa tabela nunca analisada, sinalizando “não faço ideia, vou estimar pelo tamanho físico”.

Traduzindo para SQL Serverês: é o equivalente a uma tabela sem estatísticas e com AUTO_CREATE_STATISTICS desligado o otimizador chuta com guessed selectivity. A diferença é que no SQL Server o primeiro SELECT com predicado já cria a estatística automaticamente, no PostgreSQL, consulta nenhuma cria estatística só o ANALYZE (manual ou via autovacuum).

ANALYZE e o número mágico de 30.000 linhas

ANALYZE (VERBOSE) vendas;

Olha o que a mensagem entrega: numa tabela de 5 milhões de linhas, o ANALYZE leu uma amostra de 30.000. De onde vem esse número?

Amostra = 300 × statistics_target. O default_statistics_target padrão é 100, logo 300 × 100 = 30.000 linhas não importa se a tabela tem 1 milhão ou 4 bilhões de linhas. O fator 300 não é chute o comentário no código-fonte (src/backend/commands/analyze.c) referencia um paper de 1998 sobre amostragem aleatória para construção de histogramas, que deriva o limite estatístico mínimo (305, arredondado para 300).

Trinta mil linhas decidindo o destino de 5 milhões é pouco? para a maioria dos casos, não e é exatamente por isso que o ANALYZE é rápido mesmo em VLDBs. Mas guarde essa proporção ela explica muita estimativa torta em coluna com distribuição maluca.

Traduzindo para SQL Serverês: o papel do ANALYZE é o do UPDATE STATISTICS ... WITH SAMPLE. A diferença filosófica o SQL Server calcula a taxa de amostragem em função do tamanho da tabela, o Postgres usa amostra de tamanho (quase) fixo definida pelo target. E não existe FULLSCAN no ANALYZE o máximo que você consegue é subir o target (limite 10.000 → amostra de 3 milhões de linhas).

Dentro do pg_stats: as quatro peças que decidem tudo

O ANALYZE grava tudo no catálogo pg_statistic (formato interno, ilegível para humanos normais). A view pg_stats é a versão para gente como a gente:

SELECT n_distinct, correlation,
       most_common_vals, most_common_freqs, histogram_bounds
FROM pg_stats
WHERE tablename = 'vendas' AND attname = 'cliente_id';

As quatro peças principais:

most_common_vals + most_common_freqs (MCV)

A lista dos “famosos” os valores mais frequentes da coluna e a fração exata de linhas de cada um. Na demo os clientes 1 a 5 apareceram no topo com frequência ≈ 0,08 (8%) cada exatamente o skew que eu plantei na carga. Quando o predicado bate num MCV, o planner nem estima ele lê a frequência direto da lista.

histogram_bounds

Onde vivem os “não famosos”? depois de separar os MCVs o restante dos valores é distribuído num histograma de até statistics_target buckets equialtos cada bucket contém aproximadamente a mesma quantidade de linhas. Com target 100, cada bucket responde por ~1% das linhas não-MCV.

n_distinct

A cardinalidade estimada da coluna. Pegadinha clássica de leitura valor positivo é contagem absoluta, valor negativo é fração das linhas (-1 = todas distintas, -0.5 = 50% distintas). Colunas tipo PK aparecem como -1.

correlation

De -1 a 1 o quanto a ordem física das linhas no disco acompanha a ordem lógica do valor. Na demo, data_venda deu correlation ≈ 1 (inseri em ordem cronológica) e isso muda decisão de plano correlação alta barateia Index Scan em faixas, correlação baixa empurra o planner para Bitmap Heap Scan.

Traduzindo para SQL Serverês:

SQL ServerPostgreSQLObservação
DBCC SHOW_STATISTICSSELECT ... FROM pg_statsNo PG é uma view: dá pra filtrar, ordenar, cruzar.
Histograma (máx. 200 steps)histogram_bounds (target buckets, padrão 100)PG separa os frequentes ANTES (MCV); SQL Server embute no step (EQ_ROWS).
EQ_ROWS dos stepsmost_common_vals/freqsO MCV é mais explícito e pode ter até 10.000 entradas.
Density vectorn_distinctCuidado com a semântica do valor negativo.
(sem equivalente direto)correlationO SQL Server não expõe isso na estatística.

Uma inversão honesta em visibilidade de catálogo, o pg_stats é mais confortável que o DBCC SHOW_STATISTICS. Já em rastreabilidade de uso (qual estatística o plano usou), o SQL Server ganha.

O Planner com olhos mesma consulta, planos diferentes

Depois do ANALYZE, repeti as duas consultas do início:

EXPLAIN 
(ANALYZE, TIMING OFF)
SELECT * FROM vendas WHERE cliente_id = 3;

EXPLAIN
(ANALYZE, TIMING OFF)
SELECT * FROM vendas WHERE cliente_id = 7777;

Veja que agora sim o Planner estimou o rows mais proximo do valor real de linhas.

É a frase que abriu o Post 01, agora do lado de cá: todo plano nasce de uma estimativa, e toda estimativa nasce da estatística.

Comprando precisão: default_statistics_target

E quando 30 mil linhas não bastam? Você sobe o target de preferência por coluna, não globalmente:

ALTER TABLE vendas ALTER COLUMN cliente_id SET STATISTICS 1000;
ANALYZE (VERBOSE) vendas;

Target 1000 → amostra de 300 mil linhas, MCV com até 1000 entradas, histograma com até 1000 buckets, n_distinct mais preciso. O custo: ANALYZE mais lento (em toda execução, inclusive as do autovacuum, para sempre) e listas maiores que o Planner varre a cada plano. Estatística não é de graça em nenhuma das pontas.

Traduzindo para SQL Serverês: é o botão que a gente não tem. No SQL Server, o máximo de steps é 200 e ponto; no PG, o histograma escala até 10.000 buckets se você pagar o preço. Em contrapartida, o WITH FULLSCAN do SQL Server não tem equivalente.

Quem decide quando a estatística é atualizada: o autovacuum

Aqui mora a diferença cultural mais importante entre os dois mundos e ela merece ser explicada do zero.

Primeiro: quem é o autovacuum?

No SQL Server, a atualização automática de estatísticas acontece dentro da sua consulta o otimizador percebe que a estatística está velha e dispara o update na hora (sincrono ou assíncrono, conforme a configuração).

No PostgreSQL não existe nada disso embutido na consulta, quem cuida da manutenção é um processo separado que roda em segundo plano chamado autovacuum pense nele como um “SQL Agent de fábrica” que já vem ligado e tem duas missões:

  1. VACUUM → limpar linhas mortas (assunto para outro post);
  2. ANALYZE automático → atualizar as estatísticas (o nosso assunto).

De tempos em tempos (a cada autovacuum_naptime, padrão 1 minuto), é executado em background, olha tabela por tabela e pergunta: “essa aqui mudou o suficiente para merecer um ANALYZE novo?”

SHOW autovacuum_analyze_threshold;      -- [VALIDAR] 50 (padrão)
SHOW autovacuum_analyze_scale_factor;   -- [VALIDAR] 0.1 (padrão)
SHOW autovacuum_naptime;                -- [VALIDAR] 1min (padrão)

Segundo: o que é “mudou o suficiente”?

O Postgresql mantém para cada tabela, um contador de modificações desde o último ANALYZE você pode vê-lo com seus próprios olhos:

SELECT n_mod_since_analyze   -- INSERTs + UPDATEs + DELETEs acumulados
FROM pg_stat_user_tables
WHERE relname = 'vendas';

O autovacuum compara esse contador com um limite calculado assim:

limite = 50  +  10% do total de linhas da tabela
└─ autovacuum_analyze_threshold (padrão)
└─ autovacuum_analyze_scale_factor = 0.1 (padrão)

Se o contador passou do limite → ele roda o ANALYZE sozinho e zera o contador, se não passou → ele não faz nada e suas estatísticas continuam exatamente como estavam, por mais consultas que você rode.

Terceiro: a conta na nossa tabela

Para a vendas, de 5 milhões de linhas:

limite = 50 + (0,1 × 5.000.000) = 500.050 modificações

É isso mesmo que você viu: meio milhão de linhas podem ser inseridas, alteradas ou apagadas sem que o Postgresql atualize estatística nenhuma. Se o seu skew mudou nesse meio tempo um cliente pequeno virou VIP, uma faixa nova de datas entrou o planner segue tomando decisão com as estatisticas velha.

Quarto: vendo o gatilho disparar ao vivo

Faça o teste ai também, atualizei ~800 mil linhas (acima do limite de 500.050) e fotografei o contador antes e depois do ciclo do autovacuum:

SELECT n_mod_since_analyze, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'vendas';

UPDATE vendas SET valor = valor + 1 WHERE cliente_id <= 2; -- ~100000 mil linhas

O last_autoanalyze preenchido é a prova do crime: ninguém rodou nada o autovacuum foi executado, fez a conta, viu que 1 milhão > 500 mil e tirou a foto nova sozinho.

Quinto: por que isso é um problema em tabela grande (e como ajustar)

Os 10% são fixos, então o limite cresce junto com a tabela:

Tamanho da tabelaModificações até o auto-analyze
100 mil linhas10.050
5 milhões500.050
1 bilhão100.000.050

Numa tabela de 1 bilhão de linhas, 100 milhões de modificações passam despercebidas. Por isso o ajuste clássico em VLDB é baixar o percentual por tabela (nunca globalmente, para não sobrecarregar o autovacuum nas pequenas):

-- Nesta tabela, reanalise a cada 1% de mudança em vez de 10%
ALTER TABLE vendas SET (autovacuum_analyze_scale_factor = 0.01);

Traduzindo para SQL Serverês: essa história você já viveu. O SQL Server usava threshold de 20% + 500 linhas até o 2014 e sofria exatamente deste mal em tabela grande, tanto que existia a trace flag 2371 para ativar um threshold dinâmico. A partir do 2016, o dinâmico (√(1000 × linhas)) virou padrão e o problema sumiu. O Postgresql ainda vive no “modelo de porcentagem fixa”: funciona bem em tabela pequena e média, mas em VLDB a correção é manual, por tabela como era no seu SQL Server 2012. Déjà-vu completo.

Outra diferença de mentalidade: no SQL Server, a estatística velha é detectada no momento da consulta, no Postgresql, entre um ciclo e outro do autovacuum, nenhuma consulta provoca atualização se você fez uma carga gigante e vai consultar em seguida, rode ANALYZE manualmente no fim da carga. É o hábito nº 1 que um DBA SQL Server precisa adquirir ao herdar um Postgresql.

PostgreSQL 18 na prática: particionadas e upgrades

As duas novidades que anunciei na introdução merecem o mesmo tratamento em camadas porque as duas só fazem sentido quando você entende o que existia antes.

Primeiro: como uma particionada guarda estatísticas no Postgresql

No PostgreSQL, cada partição é uma tabela de verdade, com vida própria. Isso significa que uma particionada tem estatísticas em dois níveis:

Cada partição filha tem seu próprio conjunto no pg_statistic, histograma, MCVs, tudo como qualquer tabela comum;

A estrutura-mãe tem um conjunto SEPARADO de estatísticas “globais”, que enxerga o conjunto inteiro. É esse conjunto que o planner usa quando a consulta cruza várias partições (um JOIN por uma coluna que não é a chave de particionamento, por exemplo).

Traduzindo para SQL Serverês: repare que é o desenho INVERTIDO do nosso. No SQL Server, a estatística “oficial” é uma só, no nível da tabela inteira e a gente sofre para ter granularidade por partição (foi para isso que nasceram as estatísticas incrementais do 2014, tema do Post 01). No PostgreSQL, a granularidade por partição vem de graça, porque cada partição é uma tabela e o trabalho extra fica em manter a visão do conjunto (a mãe).

Segundo: o que mudou no 18

Até o PostgreSQL 17, rodar ANALYZE na tabela-mãe atualizava apenas as estatísticas globais da mãe ele amostrava as filhas para montar a visão do conjunto, mas não gravava as estatísticas individuais de cada filha. Quem quisesse tudo atualizado precisava rodar ANALYZE na mãe E em cada partição.

No 18, o comportamento virou o que todo mundo esperava intuitivamente ANALYZE na mãe processa a mãe e todas as filhas, uma a uma. E nasceu a palavra ONLY para quem quer o comportamento antigo:

Antes vamos criar a estrutura da tabela particionada: (explicar a estrutura fica para um próximo post)

CREATE TABLE vendas_part (
id bigint, cliente_id int NOT NULL, data_venda date NOT NULL
) PARTITION BY RANGE (data_venda);

CREATE TABLE vendas_part_2024 PARTITION OF vendas_part
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE vendas_part_2025 PARTITION OF vendas_part
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

INSERT INTO vendas_part
SELECT g, g % 100, DATE '2024-01-01' + (g % 700)
FROM generate_series(1, 1000000) g;

ANALYZE (VERBOSE) vendas_part;        -- PG 18: mãe + TODAS as filhas
ANALYZE (VERBOSE) ONLY vendas_part;   -- só as estatísticas globais da mãe

O recado prático se seus scripts de manutenção foram escritos para versões antigas assumindo “ANALYZE na mãe é barato, só atualiza o global”, revise antes de migrar para o 18 o mesmo comando agora percorre todas as partições, e numa tabela com centenas de filhas a diferença de duração é brutal.

Traduzindo para SQL Serverês: é como se o UPDATE STATISTICS na tabela particionada passasse, de uma versão para outra, a reconstruir também todas as estatísticas incrementais de cada partição. Ótimo para consistência, perigoso para a janela de manutenção de quem não leu o release notes.

Terceiro: o ponto cego que CONTINUA no PG 18

Uma coisa o PostgreSQL 18 não mudou, e é a pegadinha mais importante desta seção:

O autovacuum não roda auto-analyze na estrutura-mãe. Nunca.

As filhas, sim cada uma é uma tabela comum, tem seu contador de modificações e entra na matemática do tópico 6 normalmente. Mas a mãe não recebe INSERT/UPDATE/DELETE diretamente (os dados vivem nas filhas), então o contador dela nunca estoura o limite e as estatísticas globais do conjunto vão apodrecendo em silêncio, mesmo com o autovacuum funcionando perfeitamente.

SELECT relname, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname LIKE 'vendas_part%';

Consequência direta em ambiente particionado, ANALYZE agendado na mãe não é otimização, é obrigação via cron, pg_cron ou a ferramenta de agendamento da casa. Sem isso, as consultas que cruzam partições planejam com estatísticas do dia da carga inicial.

Traduzindo para SQL Serverês: lembra do problema do Post 01, em que o threshold de auto update olhava a tabela inteira e uma partição nova nunca “pesava” o suficiente para disparar a atualização? Aqui é o primo dele, ainda mais radical não é que o gatilho da mãe demora é que ele não existe. A solução é a mesma que aplicamos no mundo manutenção de estatísticas agendada e consciente da estrutura de partições.

Quarto: estatísticas agora sobrevivem ao upgrade

A segunda novidade do PG18 resolve um rito de passagem que todo DBA PostgreSQL conhecia e que surpreende quem vem do SQL Server.

Como era (até o PG 17): o pg_upgrade a ferramenta de upgrade de versão maior migrava os dados, mas jogava fora todas as estatísticas do planner. O cluster novo subia funcionando, porém “cego”: todo plano era chute até você rodar um ANALYZE geral na base inteira. Existia até um ritual pós-upgrade (vacuumdb --analyze-in-stages) que gerava estatísticas grosseiras primeiro e refinava depois, só para a base não agonizar nas primeiras horas.

Como ficou (PG 18): as estatísticas são preservadas durante o upgrade. O cluster novo executa enxergando o que o antigo enxergava, e a janela de degradação pós-upgrade praticamente desaparece.

Traduzindo para SQL Serverês: você provavelmente nunca pensou nisso, porque no SQL Server as estatísticas sempre sobreviveram a upgrade, restore e attach elas moram dentro do banco de dados. No PostgreSQL elas moram em catálogos do cluster que o pg_upgrade historicamente não levava junto. O PG18 corrige essa surpresa desagradável e tira um item inteiro do runbook de migração.

Resumo em uma tela

PerguntaResposta no PostgreSQL
Quem coleta?ANALYZE (manual) ou autovacuum (auto-analyze)
Quanto lê?300 × statistics_target linhas (padrão: 30.000)
Onde guarda?Catálogo pg_statistic → view pg_stats
O que guarda?MCV + freqs, histogram_bounds, n_distinct, correlation
Quando atualiza sozinho?modificações > 50 + 10% da tabela
Ponto cego clássico?Estrutura-mãe de particionada (sem auto-analyze)
Botão de precisão?ALTER TABLE ... SET STATISTICS n (por coluna)

No Post 03, coloco os dois lado a lado: histograma de 200 steps vs MCV+buckets, thresholds dinâmicos vs scale_factor, e o que cada engine podia aprender com a outra.

Reproduza em casa

Fontes: