Numeric do PostgreSQL é lento por decisões de design que vêm de 1998
Um estudo publicado em 8 de outubro no Planet PostgreSQL mostra que agregações com numeric podem levar quase três vezes mais tempo e consumir nove vezes mais memória do que o equivalente em double precision. O motivo está em quatro decisões de arquitetura tomadas há mais de 25 anos.

O teste que expõe o problema
No dia 8 de outubro, Andrei Lepikhov publicou no Planet PostgreSQL↳PostgreSQL11 conteúdosPostgreSQL via SSL com GolangData · abr 20195 itens legais sobre data types do PostgreSQLData · mar 20195 serviços gratuitos na cloud para bancos de dados PostgresData · fev 2025Ver tudo em Data → um estudo comparando dois EXPLAIN praticamente idênticos. O primeiro agrupa uma tabela por 12 colunas numeric; o segundo faz o mesmo GROUP BY, mas com as colunas convertidas para double precision.
O resultado: a versão com numeric levou 4512 ms contra 1620 ms da versão com double precision. Quase três vezes mais lento. Pior: a agregação com numeric consumiu nove vezes mais memória (1835 MB contra 196 MB), mesmo tendo lido menos páginas de disco (111.632 contra 125.000 buffers).
Em resumo: o tipo que existe para garantir exatidão é o mesmo que mais penaliza quem faz contas em volume. E isso não é bug recente, é consequência de escolhas de 1998, quando o numeric foi desenhado para suportar precisão arbitrária.
Quatro decisões que o Postgres paga até hoje
Lepikhov isola quatro decisões arquiteturais que, tomadas juntas, formam a base do problema:
- Representação independe da declaração: ao contrário de SQL Server, DuckDB, ClickHouse, Arrow e Parquet (que derivam a largura do valor a partir da precisão declarada na coluna), o Postgres trata a largura como propriedade do tipo, não da coluna. Isso obriga
numerica ser de tamanho variável. - A escala vive no valor, não na coluna: por isso
1.5e1.50são armazenados como bytes diferentes mesmo numa colunanumericsem escala fixa. - A escala do resultado é calculada em tempo de execução: o número de dígitos de uma divisão depende dos operandos, não só dos tipos.
- Um limite superior flexível: formalmente há um teto de 131.072 dígitos antes da vírgula e 16.383 depois, mas na prática o espaço é alocado pelo resultado, não pela declaração, tornando erro de overflow quase impossível.
Cada uma faz sentido isolada, geralmente para atender exigências de exatidão típicas de setores regulados. O problema é a soma das contas.
Por dentro do numeric: dígitos em base 10000
Processadores contam em binário, e 0.1 em binário é uma fração periódica infinita, por isso double precision guarda o número representável mais próximo (daí 0.1 + 0.2 resultar em 0.30000000000000004). Dinheiro, juros e regras de arredondamento, porém, são pensados em base decimal, e é para isso que serve um tipo decimal exato.
O numeric do Postgres armazena os dígitos em grupos de dois bytes, cada grupo guardando um valor de 0 a 9999, ou seja, uma notação posicional em base 10000. A posição do primeiro grupo é guardada como weight (peso), uma potência de 10000, evitando que números muito pequenos ou muito grandes acumulem zeros.
A parte pouco intuitiva é o dscale: ele guarda quantos dígitos decimais imprimir, separado dos dígitos em si. Por isso 1.5 e 1.50 têm os mesmos dígitos internos, mas dscale diferente, e comparação/hash precisam ignorar esse campo enquanto a impressão precisa lembrar dele. Numa coluna declarada como numeric(15,2), o cast na escrita fixa dscale = 2 para os dois casos, e aí eles ficam idênticos byte a byte.
Valores pequenos usam um formato empacotado com cabeçalho de 1 byte, mas os operadores internos esperam o cabeçalho padrão de 4 bytes, então quase todo valor é desempacotado antes de qualquer operação. Isso tem um efeito curioso: às vezes numeric ocupa menos espaço que bigint. No exemplo de Lepikhov, pg_column_size retorna 7 bytes para um numeric(15,2) armazenado em tabela, mas 10 bytes para o mesmo valor em um cast avulso (123456.00::numeric).
O contraponto do DuckDB: decimal como inteiro puro
O DuckDB, focado em OLAP, tomou o caminho oposto nos quatro pontos. DECIMAL(15,2) com valor 123456.00 é armazenado como o inteiro 12345600; a vírgula só existe na hora de imprimir. A escala vira propriedade do tipo da coluna, não do valor, e isso permite escolher antecipadamente o menor inteiro que comporta a precisão declarada:
| Precisão (dígitos) | Carrier | Bytes |
|---|---|---|
| 1–4 | INT16 | 2 |
| 5–9 | INT32 | 4 |
| 10–18 | INT64 | 8 |
| 19–38 | INT128 | 16 |
O ganho aparece em números concretos citados por Lepikhov: num benchmark em M4 Pro, single thread, SUM sobre 20 milhões de linhas de DECIMAL(18,2) levou 8,7 ms contra 8,5 ms de um BIGINT equivalente, uma razão de 1,02, ou seja, quase nenhum custo extra por ser decimal. Já DECIMAL(19,2), mesmos dados, saltou para cerca de 880 ms, um salto de 100 vezes só por cruzar a fronteira de 18 para 19 dígitos, quando o carrier muda de INT64 para INT128. O custo não é da lógica decimal, é da largura do valor.
A documentação do MonetDB, de onde essa ideia veio para o DuckDB, resume bem o princípio:
Os tipos decimais são representados como inteiros de tamanho fixo, cujo ponto decimal é produzido na renderização do resultado.
The decimal types are represented as fixed-length integers, whose decimal point is produced during result rendering.Documentação do MonetDB
Dois custos escondidos: passagem por referência e remontagem da tupla
No Postgres, todo valor trafega como Datum, uma palavra de máquina de 8 bytes. Tipos que cabem nela são passados por valor (um bigint vive dentro do próprio registrador do processador); os que não cabem exigem ponteiro e viram pass-by-reference. numeric é sempre pass-by-reference, o que implica palloc (alocação de memória) a cada operação aritmética, mesmo numa simples soma.
Thomas Munro já tinha identificado essa limitação estrutural em 2017, na lista de discussão sobre DECFLOAT:
DECFLOAT(9) [= 32 bits] e DECFLOAT(17) [= 64 bits] poderiam, em teoria, ser passados por valor. Claro que não temos como fazer esses tipos pass-by-value e ainda assim passar DECFLOAT(34) [= 128 bits] por referência! Foi aí que travei da última vez que me interessei pelo assunto, porque esse parece ser o ponto onde ganharíamos bastante performance, e ainda assim os fatores técnicos limitantes parecem muito bem entranhados no Postgres.
DECFLOAT(9) [= 32 bit] and DECFLOAT(17) [= 64 bit] could in theory be passed by value. Of course we don't have a way to make those pass-by-value and yet pass DECFLOAT(34) [= 128 bit] by reference! That is where I got stuck last time I was interested in this subject, because that seems like the place where we would stand to gain a bunch of performance, and yet the limited technical factors seems to be very well baked into Postgres.Thomas Munro, no thread Decimal64 and Decimal128 (2017)
O outro custo é a remontagem da tupla (deform). Colunas de largura fixa têm seus deslocamentos calculados uma vez e guardados no descritor da tabela; colunas de largura variável, como numeric, quebram esse cache a partir da primeira ocorrência, e todo acesso a uma coluna posterior exige reler os cabeçalhos das anteriores, linha após linha.
Esse é justamente o ponto que David Rowley vem atacando no core do Postgres: o commit d28dff3f no PostgreSQL 18 trocou a estrutura de 104 bytes por atributo por uma de 16 bytes, com ganho de até 25% em agregação OLAP; o trabalho seguinte, já no PostgreSQL 19, chegou a 44% em alguns casos. Mas isso reduz o sintoma, não elimina a causa: a largura variável do numeric continua lá.
O que muda no desenho do seu schema
Para quem modela banco de dados no Brasil, a lição prática não é abandonar numeric, é usá-lo onde a exatidão paga a conta. Campos monetários, taxas de juros e qualquer coluna sujeita a regra de arredondamento regulatória continuam exigindo o tipo decimal exato: a diferença de um centavo em milhões de transações é inaceitável, e double precision simplesmente não garante isso.
O ponto de atenção é a agregação em massa sobre essas colunas. Se o relatório gerencial, o dashboard analítico ou a pipeline de BI não depende de exatidão absoluta até a última casa decimal, vale testar double precision no caminho quente e reservar numeric para onde o valor efetivamente é persistido e conciliado. Outra alternativa de projeto, mais trabalhosa mas totalmente dentro do padrão SQL, é guardar valores monetários como bigint representando centavos, evitando tanto a imprecisão binária quanto o custo de tamanho variável, desde que toda a aplicação trate a conversão de forma consistente.
Vale registrar também que tentativas de acelerar o decimal exato dentro do próprio Postgres, como as extensões pgDecimal, pgdecimal2 e fixeddecimal, avançaram na aritmética mas não resolveram o problema de fundo, porque a limitação está na representação de tamanho variável e no pass-by-reference, não só nas contas. Antes de trocar o tipo de uma coluna de produção, o caminho correto continua sendo medir o plano de execução real com EXPLAIN (ANALYZE, BUFFERS) no seu próprio volume de dados: o ganho de 25 a 100 vezes que aparece em microbenchmark só importa se ele se repete na sua carga de trabalho.
Fonte: Planet PostgreSQL
Este artigo foi escrito por Roberto Diniz, colunista de banco de dados. Conteúdo produzido por agente de IA da redação iMasters, sob revisão editorial humana. Saiba como produzimos no expediente.
PostgreSQL: por que max_wal_senders trava replicação e backup mesmo com conexões sobrando
Um post da série All Your GUCs in a Row, do consultor Christophe Pettus, mostra como o parâmetro que controla quantos processos podem ler o WAL tem vida própria desde o PostgreSQL 12, e por que subestimá-lo derruba backup, réplica e replicação lógica ao mesmo tempo.











