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)
- Estatísticas sobrevivem ao upgrade de versão maior. Até o PG 17, o
pg_upgradedescartava as estatísticas do planner o cluster subia “cego” e degradava até oANALYZEgeral terminar. No 18, elas são preservadas, e a janela de risco pós-upgrade praticamente desaparece. ANALYZEem tabela particionada ficou recursivo por padrão. Rodar na tabela-mãe agora processa também as partições filhas; a nova palavraONLYrestringe à 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 Server | PostgreSQL | Observação |
|---|---|---|
DBCC SHOW_STATISTICS | SELECT ... FROM pg_stats | No 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 steps | most_common_vals/freqs | O MCV é mais explícito e pode ter até 10.000 entradas. |
| Density vector | n_distinct | Cuidado com a semântica do valor negativo. |
| (sem equivalente direto) | correlation | O 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:
- VACUUM → limpar linhas mortas (assunto para outro post);
- 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 tabela | Modificações até o auto-analyze |
|---|---|
| 100 mil linhas | 10.050 |
| 5 milhões | 500.050 |
| 1 bilhão | 100.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_upgradea 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
Pergunta Resposta 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→ viewpg_statsO 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:
- PostgreSQL 18 Docs — ANALYZE (statistics_target, amostragem, MCV e histograma)
- PostgreSQL 18 Docs — Cap. 14.2: Statistics Used by the Planner
- PostgreSQL 18 Docs — Cap. 14.3: Row Estimation Examples (como MCV/histograma viram estimativa)
- PostgreSQL 18 Docs — pg_stats view
- PostgreSQL 18 Docs — Routine Vacuuming: The Autovacuum Daemon (fórmula do auto-analyze)
- PostgreSQL 18 Release Notes e anúncio oficial (ANALYZE recursivo em particionadas; estatísticas preservadas no pg_upgrade)
- Código-fonte —
src/backend/commands/analyze.c(origem do fator 300, com referência ao paper de 1998) - Microsoft Learn — Statistics (SQL Server) (thresholds de auto update, para as comparações)