PostgreSQL 14 Jul 2026

Conversão implícita no PostgreSQL: por que o comportamento é diferente do SQL Server (e onde a performance realmente morre)

Conversão implícita: duas filosofias, dois caminhos — SQL Server × PostgreSQL

Uma novidade antes de começar

Se você acompanha o blog há algum tempo, já percebeu que praticamente tudo por aqui gira em torno de SQL Server. E faz sentido são mais de 15 anos vivendo esse banco em produção, entre troubleshooting, corrupções de banco, migrações, tuning e muita madrugada de plantão.

Mas o mercado de dados mudou, e o PostgreSQL deixou de ser “o outro banco” para se tornar presença obrigatória na vida de qualquer DBA, está nas nuvens (RDS, Azure Database, Cloud SQL), nas startups, nas migrações de redução de custo de licenciamento e cada vez mais, nas grandes empresas.

Há alguns meses venho estudando PostgreSQL a fundo, com foco de administração e performance montando laboratório, quebrando, corrigindo e documentando.

Então, a partir deste post, o blog também terá conteúdo de PostgreSQL. E vou fazer do jeito que gostaria que tivessem feito comigo quando comecei, explicando cada comando novo que aparecer e sempre que possível, comparando com o equivalente no SQL Server porque aprender um banco novo fica muito mais fácil quando você ancora no que já conhece. Então se você é DBA SQL Server curioso sobre o mundo Postgres, este blog agora também é pra você.

E nada melhor para estrear do que dar sequência a um assunto que já conhecemos bem por aqui.

O problema

No post anterior, vimos como no SQL Server uma tipagem errada o clássico VARCHAR vs NVARCHAR gera conversões implícitas silenciosas que transformam Index Seeks em Index Scans. A pergunta natural de quem trabalha com os dois bancos é: e no PostgreSQL, isso acontece igual?

A resposta curta e grossa (=D) não igual mas acontece, e em lugares diferentes dos que você espera.

O sistema de tipos do PostgreSQL é mais rígido que do SQL Server, onde o SQL Server tenta “adivinhar” e ajustar convertendo em segundo plano, o PostgresSQL muitas vezes simplesmente recusa a query com erro e isso é uma proteção. Mas quando a conversão implícita passa e cai na coluna e aí o índice B-Tree morre do mesmo jeito.

Neste post vou mostrar, com EXPLAIN ANALYZE real em um PostgreSQL 18, onde o índice sobrevive, onde ele morre, e um recurso do Postgres que o SQL Server não tem para resolver o problema.

Montando o ambiente de teste

Um detalhe didático importante, não adianta testar isso com meia dúzia de linhas. Com uma tabela de 4 registros, o planner escolhe Seq Scan sempre, porque a tabela inteira cabe em uma página e você vai concluir errado. Vamos popular com pelo menos 100 mil linhas com generate_series.

CREATE TABLE clientes (
    id SERIAL PRIMARY KEY,
    codigo_cliente VARCHAR(20) NOT NULL,
    nome VARCHAR(100) NOT NULL,
    criado_em TIMESTAMP NOT NULL DEFAULT now()
);

INSERT INTO clientes (codigo_cliente, nome, criado_em)
SELECT 'C' || LPAD(g::text, 6, '0'),
       'Cliente ' || g,
       now() - (g || ' minutes')::interval
FROM generate_series(1, 100000) g;

CREATE INDEX idx_clientes_codigo ON clientes(codigo_cliente);
CREATE INDEX idx_clientes_criado_em ON clientes(criado_em);

ANALYZE clientes;

Traduzindo para “SQL Serverês”:

  • SERIAL é o IDENTITY(1,1) do Postgres (por baixo, ele cria uma sequence em versões novas, prefira GENERATED ALWAYS AS IDENTITY, mas SERIAL ainda funciona muito bem);
  • generate_series(1, 100000) gera uma tabela virtual de números a famosa tally table que sempre improvisamos no SQL Server (que, aliás, ganhou GENERATE_SERIES nativo só no SQL Server 2022);
  • :: é o operador de cast do PostgresSQL, g::text equivale a CAST(g AS VARCHAR). Guarde esse operador ele é o protagonista deste post.

E o ANALYZE clientes? É o equivalente ao UPDATE STATISTICS do SQL Server coleta estatísticas da tabela (cardinalidade, valores mais comuns, histogramas) para o planner estimar corretamente os custos. Como acabamos de encher uma tabela que nasceu vazia, rodá-lo explicitamente garante que o seu plano saia igual ao do post, sem esperar o autovacuum disparar a coleta automática. Cuidado para não confundir o ANALYZE com o EXPLAIN ANALYZE das queries a seguir lá, ANALYZE significa “execute a query de verdade e mostre os tempos reais”.

Antes de começar: como ver os planos (psql e pgAdmin)

Todos os planos deste post foram gerados com EXPLAIN ANALYZE em modo texto que funciona igual no psql e no pgAdmin, escreva EXPLAIN ANALYZE antes da query e execute normalmente (F5 no pgAdmin). O plano volta como texto na coluna QUERY PLAN.

Mas o pgAdmin também tem um plano gráfico, no estilo do plano de execução visual do SSMS e aqui mora uma pegadinha que confunde muita gente vinda do SQL Server. (inclusive apanhei no começo :=))

  • Plano em texto → query com EXPLAIN ANALYZE escrito + execução normal (F5);
  • Plano gráfico → query sem EXPLAIN nenhum + os botões de Explain do Query Tool: F7 para o plano estimado (o Ctrl+L do SSMS) ou Shift+F7 para o plano real (o Ctrl+M do SSMS).

Os dois modos são mutuamente exclusivos: se você escrever EXPLAIN ANALYZE na query e apertar Shift+F7, o pgAdmin adiciona um segundo EXPLAIN por baixo dos panos (EXPLAIN (ANALYZE, FORMAT JSON) EXPLAIN ANALYZE SELECT...), o servidor devolve erro de sintaxe e nada aparece. É o mesmo motivo pelo qual, no SSMS, você não escreve SET SHOWPLAN_XML ON junto com o Ctrl+M botão já faz isso por você.

No modo gráfico, leia o diagrama da direita para a esquerda (como no SSMS) e explore as sub-abas: Graphical (setas mais grossas = mais linhas fluindo seta gorda saindo de um Seq Scan é alerta), Analysis (compare rows estimado vs real: divergência grande é sinal de estatística desatualizada lembre do ANALYZE) e Statistics. No dropdown do botão, habilite Buffers para ter o equivalente ao SET STATISTICS IO ON: páginas vindas do cache (shared hit) vs lidas do disco (read).

EXPLAIN ANALYZE
SELECT id, nome
FROM clientes
WHERE codigo_cliente = 'C001003';

Index Scan, 0.046 ms. Perfeito.

Cuidado para não confunfir o SCAN no PostgreSQL, Index Scan é a busca pontual pela árvore do índice ou seja, o que no SQL Server chamamos de Index Seek. O equivalente ao Index Scan do SQL Server (varrer o índice inteiro) existe, mas o PostgreSQL também pode aparecer como Bitmap Index Scan ou, para varredura completa da tabela, Seq Scan (o nosso Table Scan). Todo DBA SQL Server estranha isso nos primeiros EXPLAIN.

Mas repare em um detalhe que quase ninguém nota: o Index Cond mostra (codigo_cliente)::text. O cast para text está ali mesmo na query “ideal”.

Isso acontece porque VARCHAR no PostgreSQL não tem operadores de comparação próprios ele usa os operadores de text através de um cast binary-coercible (os dois tipos têm a mesma representação em disco). Esse cast é gratuito e não impede o uso do índice.

Cenário 2 – O mito: ::text na coluna NÃO quebra o índice

Aqui está uma pegadinha que circula por aí (inclusive em respostas geradas por IA), a ideia de que qualquer CAST na coluna do WHERE anula o índice. No PostgreSQL, isso não é verdade para casts binary-coercible:

EXPLAIN ANALYZE
SELECT id, nome
FROM clientes
WHERE codigo_cliente::text = 'C001003'::text;

Mesmo plano, mesmo índice, mesma performance. VARCHAR ↔ TEXT é o “casal feliz” do Postgres é o oposto do drama VARCHAR vs NVARCHAR do SQL Server.

A regra correta não é “cast na coluna quebra índice”. A regra é o índice quebra quando a expressão sobre a coluna produz algo que o índice não indexou. Vamos ver onde isso acontece de verdade.

Cenário 3 – Onde o índice morre de verdade: INTEGER vs NUMERIC

A coluna id é INTEGER e tem índice (PK). Agora imagine que a aplicação ou o ORM manda o parâmetro como decimal:

EXPLAIN ANALYZE
SELECT id, nome
FROM clientes
WHERE id = 1003.0;

Ali está a conversão implícita clássica, em pleno PostgreSQL: o literal 1003.0 é NUMERIC, não existe operador integer = numeric na família de operadores do índice B-Tree, então o planner converte a coluna ((id)::numeric) e o índice da PK vira paisagem. Seq Scan, 99.999 linhas descartadas, 244x mais lento (14,2 ms vs 0,058 ms). Em uma tabela de 100 milhões de linhas, isso é a diferença entre milissegundos e minutos.

E o famoso INT vs BIGINT que assusta muita gente? esse funciona bem, sem conversão de dados.

EXPLAIN ANALYZE
SELECT id, nome
FROM clientes
WHERE id = 1003
;

Os tipos de dados inteiros (smallint, integer, bigint) compartilham a mesma família de operadores B-Tree (integer_ops), com operadores cross-type nativos. O problema é sempre quando entra NUMERIC, REAL ou DOUBLE PRECISION na comparação com coluna inteira.

Cenário 4 – A rigidez que protege

E se compararmos VARCHAR com número, o cenário que no SQL Server gera conversão silenciosa (e às vezes erro em runtime no meio da produção)?

SELECT id 
FROM clientes 
WHERE codigo_cliente = 1003;

O PostgreSQL nem executa. Enquanto o SQL Server converteria a coluna inteira silenciosamente, o Postgres devolve o problema para o desenvolvedor em tempo de desenvolvimento. É chato e mata o dev de raiva? Sim. Mas esse “chato” já salvou muita madrugada inclusive o DBA agradece!!!!

Cenário 5 – O assassino mais comum no dia a dia: função/cast que transforma o tipo

Agora o caso que eu mais encontro em consultoria, filtrar um TIMESTAMP por dia, usando um cast para date.

EXPLAIN ANALYZE
SELECT count(*)
FROM clientes
WHERE criado_em::date = '2026-06-15';

Seq Scan, mesmo com índice em criado_em. Diferente do varchar::text, o cast timestamp::date não é binary-coercible ele transforma o valor em outra coisa. O índice indexou timestamps, a query pergunta sobre date. O planner não tem como usar o índice.

A solução clássica (válida também no SQL Server) é reescrever como intervalo, um predicado SARGable:

WHERE criado_em >= '2026-06-15'
  AND criado_em <  '2026-06-16'

Mas o PostgreSQL tem uma segunda saída que o SQL Server não tem de forma tão direta.

PostgreSQL vs SQL Server: resumo das diferenças

ComportamentoSQL ServerPostgreSQL
VARCHAR vs NVARCHAR / TEXTConverte implicitamente pela precedência (NVARCHAR ganha) → coluna convertida → Index ScanVARCHAR e TEXT são binary-coercible → índice funciona normalmente
Coluna string vs literal numéricoConverte a coluna silenciosamente → índice morre + risco de erro em runtimeErro imediato: operator does not exist
Coluna INT vs parâmetro BIGINTConverte a coluna (precedência de tipos) → pode degradarOperadores cross-type nativos → índice funciona
Coluna INT vs literal/parâmetro NUMERICConverte a coluna → degradaConverte a coluna ((id)::numeric) → Seq Scan
Aviso no planoCONVERT_IMPLICIT + warning no plano XMLNenhum warning — você precisa ler o Filter no EXPLAIN
Indexar uma expressãoNo SQL Server você não pode criar um índice diretamente sobre uma expressão.

A alternativa é usar uma Computed Column.
Expression Index nativo: CREATE INDEX ON t ((expr))

Resumo

  1. A rigidez do Postgres protege, comparações de tipos incompatíveis viram erro em desenvolvimento, não Seq Scan silencioso em produção.
  2. ::text em coluna varchar não quebra índice, casts binary-coercible são gratuitos.
  3. O índice morre quando a expressão muda o tipo, int vs numeric, timestamp::date, lower(), json::jsonb.
  4. No Postgres não há warning de conversão no plano, aprenda a ler o Filter: do EXPLAIN ANALYZE.
  5. Expression Index é o superpoder do PG, quando não dá para mudar a query, indexe a expressão.

Gostou desse conteudo?
Então continue nos acompanhando que sempre teremos novidades por aqui =D