SQL Server × PostgreSQL: dois otimizadores, duas filosofias e o que muda quando você administra os dois

Post 03 de 03 da série Estatísticas: SQL↳SQL64 conteúdosSQL Server – Como evitar SQL Injection?Data · mai 2019Azure SQL DB Managed InstanceData · abr 2019SQL Server – Como evitar SQL Injection? Pare de utilizar Query Dinâmica como EXEC(@Query)Data · abr 2019Ver tudo em Data → Server × PostgreSQL
📚 A série completa:
✅ 01 · Estatísticas no SQL Server
✅ 02 · Estatísticas no PostgreSQL
✅ 03 · O comparativo entre os dois 👈 você está aqui
O mesmo problema, dois mundos
O cenário é o mesmo que abriu esta série, uma query que rodava bem começa a ficar lenta. A tabela é grande. O plano de execução mostra uma estimativa de cardinalidade absurdamente errada o otimizador esperava 200 linhas e vieram 2 milhões.
Se a engine é SQL Server, você abre um DBCC SHOW_STATISTICS e vai ler o histograma. Se a engine é PostgreSQL, você consulta a pg_stats e vai ler os histogram_bounds.
Nos Posts 01 e 02 desta série, destrinchamos cada motor separadamente. E se você leu os dois, provavelmente saiu com a sensação de que é “a mesma coisa com nomes diferentes”. Não é. Os dois motores partiram do mesmo problema estimar cardinalidade sem ler a tabela inteira e tomaram decisões opostas em pontos-chave: quem tira a foto dos dados, quando ela é tirada, quanto da tabela entra na foto e o quanto disso fica visível para você.
Este post fecha a trilogia respondendo a pergunta que importa para quem administra os dois motores (uma realidade cada vez mais comum no mercado): o que muda na prática e onde cada filosofia te morde.
Para deixar o clima mais leve, organizei o comparativo em 5 rounds. Não é uma luta para declarar vencedor é um duelo para entender quando cada motor brilha e quando cada um precisa da sua intervenção.
A tabela de tradução definitiva
Antes do primeiro round, o material que você vai querer salvar: a versão consolidada e bidirecional do “Traduzindo para SQL Serverês” que usamos ao longo da série.
| Conceito | SQL Server | PostgreSQL | Observação crítica |
|---|---|---|---|
| Onde a estatística vive | BLOB interno (sys.stats) |
Catálogo pg_statistic |
No PG, a view pg_stats expõe tudo em formato legível |
| Como inspecionar | DBCC SHOW_STATISTICS |
SELECT * FROM pg_stats |
PG: consultável via SQL puro |
| Histograma | Máx. 200 steps (algoritmo maxdiff) | histogram_bounds com 100 buckets (default) |
No PG, buckets são equi-frequentes e o nº acompanha statistics_target |
| Valores frequentes | Embutidos nos steps (EQ_ROWS) |
most_common_vals + most_common_freqs (lista separada) |
O PG separa MCVs do histograma os MCVs saem do histograma |
| Seletividade média | Density vector | n_distinct |
PG: n_distinct negativo = proporção do total (escala com a tabela) |
| Criação automática | AUTO_CREATE_STATISTICS (_WA_Sys_*) |
ANALYZE cobre todas as colunas, sempre |
No PG não existe “coluna sem estatística” após um ANALYZE |
| Atualização automática | Threshold dinâmico: MIN(500 + 0,20n; √(1000n)) |
Autovacuum: 50 + 0,10n (scale factor 0,1) |
Filosofias opostas Round 2 |
| Quem dispara | A query (compilação), sync ou async | O daemon autovacuum, em background | A diferença mais profunda entre os dois modelos |
| Sample | Decidido pelo motor (decresce com o tamanho) | Fixo: 300 × statistics_target linhas | PG: 30.000 linhas por padrão, não importa o tamanho da tabela |
| Fixar sample | PERSIST_SAMPLE_PERCENT |
ALTER TABLE ... SET STATISTICS n (por coluna) |
Abordagens diferentes para o mesmo problema |
| Particionamento | 1 estatística agregada; INCREMENTAL = ON para baratear manutenção |
Stats por partição e na tabela-mãe | Round 3 o duelo que motivou a série |
| Stats multi-coluna | Density vector de índice composto; filtered stats | CREATE STATISTICS (dependências, ndistinct, MCVs compostos) |
Exclusivos de cada lado Round 5 |
| Ordem física | Fragmentação (conceito separado) | correlation (dentro da estatística!) |
O planner do PG usa correlação para custear index scan |
Guarde este quadro. Agora, ao ringue. 🥊
Round 1 – Quem tira a foto: sampling e granularidade
A estatística é uma foto dos dados. A primeira decisão de design de cada motor foi: quanto da tabela entra na foto?
SQL Server decide por você. O sample rate automático decresce conforme a tabela cresce numa tabela de milhões de linhas, o sample pode ficar abaixo de 1%. Como vimos no Post 01, foi exatamente isso que motivou a rotina de 70% de sample no ambiente de 4 bilhões de linhas e a conta dolorosa de 2,8 bilhões de linhas lidas por madrugada. O motor tenta equilibrar custo e qualidade sozinho, e em VLDB esse equilíbrio automático erra para baixo.
PostgreSQL é transparente e rígido: o ANALYZE lê 300 × default_statistics_target linhas. Com o default de 100, são 30.000 linhas tenha a tabela 100 mil ou 4 bilhões de linhas. A matemática por trás (baseada no estimador de Chaudhuri et al.) garante boa precisão para os buckets do histograma, mas tem um calcanhar de aquiles conhecido: n_distinct e caudas raras de distribuições com skew forte sofrem com amostras pequenas.
Ou seja: nenhum dos dois acerta sozinho em tabela gigante mas erram por motivos diferentes. O SQL Server erra porque o sample encolhe demais e o PostgreSQL erra porque o sample não cresce nunca.
🔬 Demo 1 – A estatistica enganosa
Vamos materializar isso com a mesma massa de dados nos dois motores (SQL Server e PostgreSQL), 5 milhões de linhas com skew severo. Traduzindo: um único valor concentra ~80% das linhas, e o resto se espalha em milhares de valores que aparecem pouquíssimas vezes cada um. Pensa num sistema de saúde municipal, a cidade sede responde por quase todos os atendimentos, enquanto centenas de cidades vizinhas aparecem com meia dúzia de registros cada. É exatamente o padrão que encontrei em dados públicos reais, como o campo de município no dataset HORUS/DATASUS (https://dados.gov.br/home) e é o pior pesadelo de qualquer amostragem, porque a foto captura fácil o gigante e quase sempre perde os pequenos.
Dados – SQL Server:
CREATE TABLE dbo.Atendimento (
Id INT IDENTITY PRIMARY KEY,
CodMunicipio INT NOT NULL,
Valor DECIMAL(10,2) NOT NULL
);
-- 4 milhões de linhas do município dominante (4205407 = Joinville 😉)
INSERT INTO dbo.Atendimento (CodMunicipio, Valor)
SELECT TOP (4000000) 4205407, ABS(CHECKSUM(NEWID())) % 1000
FROM sys.all_columns a CROSS JOIN sys.all_columns b;
-- 1 milhão de linhas espalhadas em 5.000 municípios raros
INSERT INTO dbo.Atendimento (CodMunicipio, Valor)
SELECT TOP (1000000)
1000000 + ABS(CHECKSUM(NEWID())) % 5000,
ABS(CHECKSUM(NEWID())) % 1000
FROM sys.all_columns a CROSS JOIN sys.all_columns b;
CREATE INDEX IX_Atendimento_Municipio ON dbo.Atendimento (CodMunicipio);Dados – PostgreSQL:
CREATE TABLE atendimento (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cod_municipio integer NOT NULL,
valor numeric(10,2) NOT NULL
);
-- 4 milhões do município dominante
INSERT INTO atendimento (cod_municipio, valor)
SELECT 4205407, (random() * 1000)::numeric(10,2)
FROM generate_series(1, 4000000);
-- 1 milhão espalhado em 5.000 municípios raros
INSERT INTO atendimento (cod_municipio, valor)
SELECT 1000000 + (random() * 5000)::int, (random() * 1000)::numeric(10,2)
FROM generate_series(1, 1000000);
CREATE INDEX ix_atendimento_municipio ON atendimento (cod_municipio);
ANALYZE atendimento;O teste: duas queries uma filtrando o valor dominante, outra filtrando um valor raro comparando estimativa × realidade.
Comportamento SQL Server:
-- SQL Server (plano real: estimativa vs. Actual Number of Rows)
SET STATISTICS XML ON;
SELECT COUNT(*) FROM dbo.Atendimento WHERE CodMunicipio = 4205407 -- dominante
OPTION (RECOMPILE);
SELECT COUNT(*) FROM dbo.Atendimento WHERE CodMunicipio = 1002500 -- raro
OPTION (RECOMPILE);A pegadinha que quase estragou a demo: na primeira vez que rodei esse teste, as duas queries mostraram a MESMA estimativa (4.000.000) inclusive a do valor raro, que retornou 189 linhas.
O que aconteceu? parametrização simples. O SQL Server transformou o literal em @1, cacheou o plano compilado com o primeiro valor e reutilizou na segunda query, sem consultar o histograma de novo. Isso não é a estatística errando é parameter sniffing entrando em cena antes dela. O OPTION (RECOMPILE) força cada execução a compilar com o próprio literal e ir ao histograma. Guarde essa lição, quando a estimativa parece ignorar completamente os dados, confira primeiro se o plano foi compilado para outro valor (procure =@1 no predicado).


O resultado no SQL Server (ambiente real, sample default):
| Filtro | Estimativa | Real | Erro |
|---|---|---|---|
Valor dominante (4205407) |
4.000.000 | 4.000.000 | Zero ✅ |
Valor raro (1002500) |
199,321 | 189 | ~5% ✅ |
Dois acertos mas por caminhos completamente diferentes dentro do histograma. E entender esses dois caminhos é entender como o SQL Server estima qualquer filtro de igualdade. Vamos por camadas:
Caminho 1 – o valor dominante tem “cadeira cativa” no histograma. O histograma tem no máximo 200 steps, e cada step guarda um valor de fronteira (RANGE_HI_KEY) com a contagem exata de linhas daquele valor (EQ_ROWS). Um valor que representa 80% da tabela garantidamente vira um step próprio o algoritmo de construção existe justamente para dar cadeira aos valores que mais importam. Resultado quando a query filtra 4205407, o otimizador acha o step, lê EQ_ROWS ≈ 4.000.000 e acerta na mosca.
Caminho 2 – o valor raro entra na “vala comum” (e tudo bem). Agora pensa: são 5.000 municípios raros disputando no máximo 200 cadeiras. Não tem cadeira para todo mundo. O valor 1002500 não tem step próprio ele cai entre dois steps, num intervalo. Para esses casos, cada intervalo guarda uma média: o AVG_RANGE_ROWS “em média, cada valor distinto deste intervalo tem X linhas”. A matemática da nossa massa fecha redonda: 1 milhão de linhas ÷ 5.000 municípios = ~200 linhas por município. O otimizador estimou 199,321; vieram 189. Erro de 5% usando uma média nada mal.
Dica de leitura de plano que vale ouro: repare que a estimativa do valor raro tem casas decimais (199,321). Contagem exata não tem vírgula média tem. Casas decimais na estimativa são a assinatura de que o otimizador não achou o valor no histograma e está trabalhando com AVG_RANGE_ROWS (ou density). Você identifica o caminho da estimativa num relance, sem abrir o DBCC SHOW_STATISTICS.
Quer ver as duas estruturas com os próprios olhos? É uma query:
DBCC SHOW_STATISTICS ('dbo.Atendimento', 'IX_Atendimento_Municipio') WITH HISTOGRAM;
Procure o step com RANGE_HI_KEY = 4205407 (lá está o EQ_ROWS de 4 milhões) e depois o intervalo onde 1002500 se encaixa (lá está o AVG_RANGE_ROWS de ~199).
E o detalhe que fecha a tese da série: compare o formato dos dois planos. A query do valor dominante ganhou Hash Match com paralelismo operadores parrudos, porque agregar 4 milhões de linhas justifica o investimento. A query do valor raro ganhou Stream Aggregate serial plano enxuto para meia dúzia de linhas. Mesma tabela, mesma query, planos diferentes. A estimativa não é um número decorativo ela escolheu os operadores. É a frase que abre e fecha esta série acontecendo na sua tela.
Comportamento no PostgreSQL
-- PostgreSQL
EXPLAIN ANALYZE
SELECT count(*) FROM atendimento WHERE cod_municipio = 4205407;
EXPLAIN ANALYZE
SELECT count(*) FROM atendimento WHERE cod_municipio = 1002500;

| Filtro | Estimativa | Real | Caminho |
|---|---|---|---|
Valor dominante (4205407) |
~4.000.000 | 4.000.000 | most_common_vals ✅ |
Valor raro (1002500) |
267 | 193 | Fórmula residual ⚠️ |
(Se você reparou que o município raro tem 189 linhas no SQL Server e 193 no Postgres as massas foram geradas com aleatoriedade independente em cada engine, então as contagens dos dados variam um pouco. O padrão da distribuição é o mesmo.)
Os mesmos dois caminhos do SQL Server, com outros nomes e duas lições novas que só aparecem rodando de verdade:
O dominante caiu nos MCVs. O valor 4205407 foi capturado pelos most_common_vals com frequência quase exata o papel análogo ao EQ_ROWS do step. Mas o plano tem uma pegadinha de leitura para quem está começando: o nó Parallel Seq Scan mostra rows=1668539, bem longe dos 4 milhões. Cadê o resto? Em plano paralelo, a estimativa exibida no nó é por worker. A conta vem da função get_parallel_divisor() (código-fonte, costsize.c): o divisor é o número de workers mais a contribuição do líder, calculada como 1 − (0,3 × workers) o planner assume que o líder gasta ~30% do tempo servindo cada worker. Com 2 workers: 2 + (1 − 0,6) = 2,4. E 1.668.539 × 2,4 ≈ 4.004.000 a estimativa está exata; ela só aparece “fatiada”. (Com 4+ workers a contribuição do líder zera e o divisor vira só o número de workers; e com parallel_leader_participation = off idem.).
O raro expôs o calcanhar do sample fixo. Pela nossa matemática, a estimativa “perfeita” seria ~200 (1 milhão ÷ 5.000 municípios). O planner estimou 267 inflado em ~33%. Por quê? O valor não está nos MCVs, então entra a fórmula residual as linhas que sobraram fora dos MCVs divididas pelos valores distintos restantes. E é aí que mora o erro com uma amostra fixa de 30.000 linhas numa tabela de 5 milhões, o ANALYZE subestimou o n_distinct enxergou ~3.700 municípios onde existem 5.000. Menos divisores, fatia maior para cada um: 267 em vez de 200. Confira você mesmo na pg_stats:
SELECT n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'atendimento' AND attname = 'cod_municipio';
Isso é a tese deste round acontecendo ao vivo o sample que não cresce nunca morde primeiro o n_distinct. O SQL Server, com sample maior nesta tabela, cravou 199,3; o PostgreSQL preso às 30 mil linhas, chegou a 267. Nenhum erro é grave aqui mas escale a tabela para bilhões de linhas e a distorção escala junto.
E o espelho dos planos fecha a demo o valor dominante ganhou Parallel Seq Scan (ler 80% da tabela? varredura paralela o gêmeo do Hash Match paralelo do SQL Server). O valor raro ganhou Index Only Scan serial (o gêmeo do Index Seek + Stream Aggregate). As duas engine, duas estruturas de estatística completamente diferentes e as mesmas decisões de formato de plano a partir das estimativas. Não tem demonstração melhor da lição da série.
Onde os dois quebram (o experimento extra) reduza a qualidade da estatistica e refaça. No SQL Server, UPDATE STATISTICS ... WITH SAMPLE 1 PERCENT o valor raro degrada primeiro. No Postgres, ALTER TABLE ... ALTER COLUMN cod_municipio SET STATISTICS 10 + ANALYZE o n_distinct desaba e a estimativa dos raros infla.
A lição do round: as duas engine acertam o “grosso” e tropeçam e o botão de ajuste é diferente em cada um (sample % de um lado, statistics target do outro).
Placar do Round 1: empate técnico com vantagem de transparência para o PostgreSQL (você sabe exatamente quantas linhas entram na foto) e vantagem de flexibilidade para o SQL Server (PERSIST_SAMPLE_PERCENT fixa a decisão por estatística).
Round 2 – Quem dispara a atualização: a query × o daemon
Aqui mora a diferença filosófica mais profunda entre as engines e a que mais muda o dia a dia operacional.
SQL Server: modelo pull (a query puxa). A estatística envelhece silenciosamente até que uma query precise dela na compilação. Nesse momento, o motor confere o modification_counter contra o threshold dinâmico MIN(500 + 0,20n; √(1000n)) desde o compat level 130, conforme documentado no artigo Statistics do Microsoft Learn (antes disso, o comportamento dinâmico existia via trace flag 2371) e, se estourou, atualiza a estatística ali, na hora, antes de compilar o plano (ou depois, se AUTO_UPDATE_STATISTICS_ASYNC estiver ligado). Consequência prática o custo da atualização aparece na cara do usuário aquela query que “às vezes demora 40 segundos do nada” pode ser um auto update síncrono de uma estatística grande.
PostgreSQL: modelo push (o daemon empurra). Nenhuma query dispara ANALYZE. Quem trabalha é o autovacuum a cada autovacuum_naptime (default 60s), ele acorda, varre as tabelas e dispara auto-analyze em quem cruzou autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × n (default: 50 + 0,10n fórmula documentada no capítulo Routine Vacuuming / The Autovacuum Daemon da documentação oficial). Consequência prática a query nunca paga o custo da atualização mas existe uma janela de defasagem entre cruzar o threshold e o daemon agir. E se o autovacuum estiver ocupado (ou mal configurado), essa janela vira horas.
Traduzindo:
- No SQL Server, o sintoma de estatística atualizando é latência pontual em query (ou plano ruim se async ligado e plano compilado com a stat velha).
- No Postgres, o sintoma de estatística defasada é plano ruim persistente até o autovacuum passar e o clássico tabela recém-carregada com estimativas absurdas porque ninguém rodou ANALYZE manual após o load.
🔬 Demo 2 – Quem percebe a mudança primeiro
Uma tabela de 100.000 linhas nos dois motores. Os thresholds ficam quase idênticos uma coincidência:
- SQL Server:
MIN(500 + 20.000; √(1000 × 100.000))=MIN(20.500; 10.000)= 10.000 modificações - PostgreSQL:
50 + 0,10 × 100.000= 10.050 modificações
SQL Server:
CREATE TABLE dbo.Demo2 (
Id INT IDENTITY PRIMARY KEY,
Categoria INT NOT NULL,
Valor DECIMAL(10,2) NOT NULL
);
INSERT INTO dbo.Demo2 (Categoria, Valor)
SELECT TOP (100000)
ABS(CHECKSUM(NEWID())) % 100,
ABS(CHECKSUM(NEWID())) % 1000
FROM sys.all_columns a CROSS JOIN sys.all_columns b;
CREATE INDEX IX_Demo2_Categoria ON dbo.Demo2 (Categoria);Agora confira os contadores:
-- SQL Server: contador da estatística do índice
SELECT s.name, sp.last_updated, sp.modification_counter, sp.rows
FROM sys.stats s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp
WHERE s.object_id = OBJECT_ID('dbo.Demo2')
AND s.name = 'IX_Demo2_Categoria';
Agora vamos fazer um update:
-- SQL Server
UPDATE dbo.Demo2 SET Categoria = Categoria + 1000 WHERE Id <= 9500;
PostgreSQL:
CREATE TABLE demo2 (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
categoria integer NOT NULL,
valor numeric(10,2) NOT NULL
);
INSERT INTO demo2 (categoria, valor)
SELECT (random() * 100)::int, (random() * 1000)::numeric(10,2)
FROM generate_series(1, 100000);
-- ANALYZE manual para fixar reltuples = 100.000 e zerar o contador.
-- Sem isso, o autovacuum vai analisar a tabela recém-carregada sozinho
-- e bagunçar nosso ponto de partida.
ANALYZE demo2;-- PostgreSQL: contador da tabela
SELECT last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'demo2';
Agora vamos fazer um update:
-- PostgreSQL
UPDATE demo2 SET categoria = categoria + 1000 WHERE id <= 9500;
E agora, cronômetro na mão:
- PostgreSQL: não faça nada. Em até ~60s (
autovacuum_naptime), olast_autoanalyzemuda sozinho e on_mod_since_analyzezera. O daemon percebeu. - SQL Server: espere o quanto quiser que nada acontece. A estatística só atualiza quando você rodar uma query que use aquela estatística na compilação. Rode o
SELECT COUNT(*)com filtro emCategoria(comOPTION (RECOMPILE)para garantir a compilação) e confira de novo o contador:last_updatedmudou naquele instante, e omodification_counterzerou.
Esse experimento de 5 minutos expõe a filosofia melhor do que qualquer tabela no PostgreSQL, a manutenção acontece nos bastidores; no SQL Server, ela acontece no palco.
Placar do Round 2: vantagem PostgreSQL para consistência de latência; vantagem SQL Server garantido no momento da compilação.
Round 3 – Particionamento: o duelo que motivou a série
Esta série nasceu de um caso real (Post 01): tabela particionada de ~4 bilhões de linhas, escrita concentrada na última partição, rotina varrendo bilhões de linhas congeladas. Como cada engine lida com estatísticas em tabelas particionadas?
SQL Server: a tabela tem N partições, mas a estatística é uma só, agregada um único histograma de 200 steps para 4 bilhões de linhas. O INCREMENTAL = ON (Post 01) barateia a manutenção mantém estatísticas por partição e mescla, permitindo UPDATE STATISTICS ... ON PARTITIONS(n) só onde houve mudança. Mas a ressalva que destaquei lá continua sendo o ponto central: o otimizador segue lendo o histograma mesclado. Você economiza janela de manutenção não ganha granularidade de estimativa.
PostgreSQL: o desenho é o inverso. Cada partição é uma tabela com estatísticas próprias e completas e o planner as usa diretamente quando faz partition pruning. Existe ainda a estatística da tabela-mãe (usada quando a query não poda partições). O problema histórico: o autovacuum não roda ANALYZE na tabela-mãe ela não recebe DML direto, então o contador nunca estoura. Durante anos, a resposta foi “agende ANALYZE manual na mãe”. O PostgreSQL 18 (Post 02) mudou o jogo com o ANALYZE recursivo (ONLY para controlar o comportamento) e as estatísticas sobrevivendo ao pg_upgrade dois avanços diretos para VLDB particionado.
O confronto honesto:
| Aspecto | SQL Server | PostgreSQL |
|---|---|---|
| Granularidade que o otimizador enxerga | Histograma mesclado (mesmo com INCREMENTAL) | Histograma por partição (com pruning) |
| Custo de manutenção | Baixo com INCREMENTAL bem implantado | Baixo por natureza (partição = tabela) |
| Ponto cego | 200 steps para a tabela inteira | Tabela-mãe órfã de autovacuum (mitigado no 18) |
| Esforço de implantação | Alto: 3 classes de stats, conversão via REBUILD (Post 01) | Baixo, mas exige disciplina com a mãe |
Placar do Round 3: vitória do Postgres em granularidade, vitória do SQL Server em tooling de monitoramento (sys.dm_db_incremental_stats_properties com modification_counter por partição é algo que o PG não entrega nativamente). Nenhum dos dois resolve 100% sozinho.
Round 4 – Visibilidade e diagnóstico: a inversão curiosa
No teaser do Post 02 eu prometi uma inversão curiosa, e ela fica evidente quando colocamos os dois lado a lado: em vários pontos, o “desafiante” open source↳Open source71 conteúdosComo o Open Source Está Liberando o Poder da Automação para TodosDev (Back & Front) · out 2025Código aberto: programadores criam software da NASA sem saberDev (Back & Front) · abr 2021N8N: O que é a ferramenta open source que está revolucionando a automação em TI?Dev (Back & Front) · dez 2025Ver tudo em Dev (Back & Front) → te dá mais visibilidade que o motor enterprise.
Onde o Postgres te dá MAIS:
pg_statsé SQL puro. Filtrável, joinável, scriptável. Quer todas as colunas comn_distinctsuspeito? Uma query. No SQL Server,DBCC SHOW_STATISTICSé um comando de saída fixa automatizar análise em massa exige malabarismo comWITH STATS_STREAMou tabelas temporárias.correlation. O PG guarda, dentro da estatística, o quanto a ordem física da coluna acompanha a ordem lógica e o planner usa isso para custear index scan × bitmap scan. O SQL Server trata ordem física como assunto separado (fragmentação) e o otimizador não a considera na estimativa.- MCVs legíveis.
most_common_valsemost_common_freqssão arrays que você lê diretamente. A informação equivalente no SQL Server está diluída nos steps do histograma.
Onde você sente falta do SQL Server:
OptimizerStatsUsageno plano de execução: o plano te conta quais estatísticas foram usadas, com sample e data. No Postgres, oEXPLAINmostra as estimativas mas não a proveniência delas.PERSIST_SAMPLE_PERCENT: fixou o sample, acabou a discussão. No PG o ajuste é por coluna (SET STATISTICS) e mexe no tamanho do histograma junto são botões acoplados.modification_counterpor partição via DMV o padrão-ouro para rotinas cirúrgicas em VLDB, sem equivalente nativo no PG.
Placar do Round 4: empate com estilos opostos o Postgres expõe o conteúdo da estatística; o SQL Server expõe o uso dela.
Round 5 – Os golpes exclusivos: o que não tem equivalente do outro lado
Exclusivos do SQL Server:
- Filtered statistics: estatística sobre um subconjunto (
WHERE Status = 'Ativo'). Poderosíssimo para skew conhecido e sem equivalente direto no PG (índices parciais geram stats, mas não é a mesma ferramenta). - O problema da ascending key + Legacy CE (Post 01): não é um recurso, é uma armadilha exclusiva datas novas caindo fora do último step do histograma. O New CE mitiga; o Legacy assume ~1 linha. O PG tem o problema análogo em menor grau (valores fora dos
histogram_bounds), mas a extrapolação do planner é menos traiçoeira. - Estatísticas de índice com density vector composto: todo índice multi-coluna traz densidades dos prefixos de graça.
Exclusivos do PostgreSQL:
- Extended statistics (
CREATE STATISTICS) o golpe mais subestimado do duelo. Três tipos:dependencies(dependências funcionais entre colunas cidade → estado),ndistinct(combinações multi-coluna para GROUP BY) emcvcomposto (valores frequentes de pares de colunas). É a resposta direta ao problema clássico de predicados correlacionados (WHERE cidade = 'Joinville' AND uf = 'SC'), que o SQL Server ataca apenas indiretamente (density de índice composto ou filtered stats artesanais). n_distinctconfigurável na mão:ALTER TABLE ... ALTER COLUMN ... SET (n_distinct = -0.05)quando você sabe mais que o ANALYZE, você sobrescreve. No SQL Server não existe “editar” estatística oficialmente.correlationde novo merece dupla menção por ser conceito que simplesmente não existe no modelo do SQL Server.
Placar do Round 5: vitória do Postgres em ferramentas de correção fina; vitória do SQL Server em cobertura automática (filtered stats + density de índices cobrem casos que no PG exigem CREATE STATISTICS deliberado).
Veredito por cenário: quando cada um brilha
Chega de rounds a pergunta real não é “quem ganha”, é “o que eu priorizo no ambiente que eu administro”:
| Cenário | Prioridade nº 1 | Por quê |
|---|---|---|
| VLDB particionado no SQL Server | Implantar INCREMENTAL = ON com rotina por modification_counter de partição |
É a diferença entre varrer bilhões de linhas ou milhões (Post 01) |
| VLDB particionado no PostgreSQL | Garantir ANALYZE da tabela-mãe (nativo no 18; agendado antes disso) e revisar statistics_target das colunas de filtro |
Sample fixo de 30 mil linhas em tabela bilionária degrada n_distinct |
| OLTP intenso no SQL Server | Avaliar AUTO_UPDATE_STATISTICS_ASYNC (+ a opção de wait do 2022) |
Tira o custo do auto update do caminho crítico da query |
| OLTP intenso no PostgreSQL | Tunar autovacuum por tabela (scale_factor menor nas tabelas quentes) |
O default de 10% é lento demais para tabelas grandes e quentes |
| Migração SQL Server → PG | Aprender pg_stats + EXPLAIN como novo par DBCC + plano; ANALYZE manual pós-carga sempre |
O erro nº 1 de migração: carga massiva sem ANALYZE = planos catastróficos no dia 1 |
| Ambiente misto (os dois motores) | Padronizar o processo de diagnóstico (sintoma → estimativa × real → inspecionar a estatística), não as ferramentas | O método é portável; os comandos, não |
É exatamente esse tipo de avaliação estatísticas, thresholds, rotinas de manutenção, particionamento que compõe o Health Check que aplico nos ambientes dos meus clientes na DataPlus. Se a sua rotina de estatísticas “roda porque sempre rodou”, talvez seja hora de perguntar o que ela está atualizando. 😉
Fechando a trilogia
Três posts, dois motores, uma lição unificadora:
O otimizador não conhece seus dados. Ele conhece o que a estatística deixou lá. Em qualquer engine.
O SQL Server e o PostgreSQL chegaram a essa verdade por caminhos opostos pull × push, sample adaptativo × sample fixo, histograma mesclado × histograma por partição, visibilidade do uso × visibilidade do conteúdo. Dominar os dois não é decorar duas sintaxes: é entender as duas filosofias e saber onde cada uma te morde.
Recapitulando a série:
- Post 01 – SQL Server: histograma, density vector, thresholds, INCREMENTAL e as 3 classes de estatísticas
- Post 02 – PostgreSQL: ANALYZE, pg_stats, autovacuum e as novidades do PG 18
- Post 03 – O comparativo: você acabou de ler 🥊
Me conta nos comentários: você administra os dois motores? Qual dessas diferenças mais te pegou de surpresa na prática?










Alex Hinckel
John Calistro
Airton Lira Junior
Luiz Fernando Duarte Junior





