Decimal vs Float em Python: o erro de R$ 0,01 que pode custar caro

Decimal vs Float em Python: o erro de R$ 0,01 que pode custar caro

Abra um interpretador Python agora e digite isto:

>>> 0.1 + 0.2
0.30000000000000004

Se você já é um pouco mais experiente, provavelmente nem se assustou — sabe que "é assim mesmo". Mas se você é novato, a primeira reação costuma ser achar que encontrou um bug no Python. Não é. E entender por que isso acontece — e, mais importante, quando isso importa de verdade — é uma daquelas coisas que separam quem só usa números de quem sabe o que está fazendo com eles.

Vamos destrinchar isso com calma.

Por que 0.1 + 0.2 não dá 0.3

O float do Python segue o padrão IEEE 754 de ponto flutuante de precisão dupla — o mesmo usado por praticamente toda linguagem de programação moderna (Java, JavaScript, C, Go). Esse padrão representa números usando binário, e o problema é simples de enunciar: assim como 1/3 não tem representação exata em base decimal (0.333...), a maioria dos números decimais não tem representação exata em base binária.

0.1, para um computador, não é exatamente 0.1. É uma aproximação binária extremamente próxima, mas não idêntica. Quando você soma duas dessas aproximações, os erros de arredondamento se acumulam, e o resultado — 0.30000000000000004 — é a prova disso.

Isso não é um defeito do Python. É uma característica de como números de ponto flutuante funcionam em qualquer linguagem. O problema é que, no dia a dia, a gente esquece disso — até o dia em que um sistema financeiro fecha o caixa com R$ 0,01 de diferença e ninguém entende por quê.

Onde isso vira um problema de verdade

Em boa parte do código que escrevemos, esse erro de precisão é irrelevante — ninguém vai notar a diferença de 0.00000000000000004 num cálculo de média de tempo de resposta de uma API, por exemplo. O problema aparece quando duas condições se juntam:

  1. O valor representa dinheiro (ou qualquer grandeza onde exatidão é contratual — impostos, juros, notas fiscais).
  2. Existem comparações de igualdade ou somas acumuladas ao longo do tempo.

Um exemplo clássico:

total = 0.0
for _ in range(10):
    total += 0.10

print(total)
# 0.9999999999999999

Dez depósitos de dez centavos deveriam somar exatamente um real. Mas o resultado é 0.9999999999999999. Se, em algum lugar do sistema, houver uma checagem do tipo if total == 1.0:, ela vai falhar — silenciosamente, sem lançar nenhum erro, só produzindo o resultado errado.

Pior ainda: imagina isso rodando num sistema que processa milhares de transações por dia. O erro de um centavo, sozinho, é insignificante. Acumulado ao longo de meses, em milhares de operações, pode gerar uma divergência real no fechamento de caixa — o tipo de coisa que só é descoberta na auditoria, quando já é tarde para saber a causa.

A solução: Decimal

O módulo decimal, da biblioteca padrão do Python, existe exatamente para isso. Em vez de representar números em base binária, ele representa em base decimal — do jeito que a gente, humanos, escreve dinheiro no dia a dia.

from decimal import Decimal

total = Decimal("0")
for _ in range(10):
    total += Decimal("0.10")

print(total)
# 1.00

Exatamente 1.00. Sem surpresas, sem erro acumulado.

O detalhe que todo mundo esquece na primeira vez

Repare que criei os valores como Decimal("0.10"), passando uma string, não Decimal(0.10), passando um float. Essa diferença é crucial:

>>> Decimal(0.10)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

>>> Decimal("0.10")
Decimal('0.10')

Se você criar um Decimal a partir de um float, o erro de precisão do float já entrou no valor antes mesmo de o Decimal existir — porque o 0.10 já virou uma aproximação binária no momento em que o Python leu o literal. O Decimal só herda esse erro. A forma correta é sempre construir o Decimal a partir de uma string (ou de um int), nunca de um float.

Essa é, sozinha, a causa mais comum de bugs de "por que meu Decimal não está exato" — o problema não é o Decimal, é o float que passou por trás dele.

Precisão configurável

Outra vantagem do Decimal é que você controla explicitamente a precisão dos cálculos, através do módulo getcontext:

from decimal import Decimal, getcontext

getcontext().prec = 10  # 10 dígitos significativos

resultado = Decimal("1") / Decimal("3")
print(resultado)
# 0.3333333333

Isso é importante em contextos onde a precisão é definida por norma — cálculo de juros compostos, por exemplo, costuma ter regras específicas de quantas casas decimais manter em cada etapa do cálculo, e arredondar cedo demais ou tarde demais pode gerar valores diferentes (e ambos "matematicamente plausíveis", mas só um deles correto para a norma aplicada).

Arredondamento: o outro detalhe traiçoeiro

Arredondar não é tão óbvio quanto parece. O round() nativo do Python usa a estratégia banker's rounding (arredondamento para o par mais próximo), o que surpreende bastante gente:

>>> round(0.5)
0
>>> round(1.5)
2
>>> round(2.5)
2

Repare: 0.5 arredondou para 0, e 2.5 arredondou para 2 — não para 1 e 3, como a regra "decoreba de escola" (sempre arredondar .5 para cima) ensinaria. Essa estratégia existe para evitar viés sistemático em somas grandes, mas em sistemas financeiros brasileiros geralmente a regra esperada é outra (arredondamento comercial, sempre para cima a partir de .5).

O Decimal te dá controle explícito sobre isso, através do método quantize:

from decimal import Decimal, ROUND_HALF_UP

valor = Decimal("2.505")
arredondado = valor.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(arredondado)
# 2.51

Aqui você diz exatamente quantas casas decimais quer (Decimal("0.01") = duas casas) e qual estratégia de arredondamento usar (ROUND_HALF_UP é o arredondamento "de escola", sempre para cima a partir do .5). Isso é o tipo de controle que um sistema financeiro sério precisa ter — e que o float simplesmente não oferece.

E o Fraction? Vale a pena mencionar

O Python também tem fractions.Fraction, que representa números como frações exatas (numerador/denominador), sem nenhum erro de arredondamento:

from fractions import Fraction

Fraction(1, 3) + Fraction(1, 3)
# Fraction(2, 3)

É útil em contextos matemáticos ou científicos onde você precisa de exatidão racional pura — mas não é a ferramenta certa para dinheiro. Dinheiro tem casas decimais fixas por convenção (centavos), e é isso que o Decimal modela bem. Fraction resolve um problema diferente.

Quando float continua sendo a escolha certa

Depois de tudo isso, é tentador pensar "então nunca mais vou usar float". Isso seria um exagero na direção oposta. float é mais rápido, ocupa menos memória e é perfeitamente adequado — na verdade, preferível — em cenários como:

  • Cálculos científicos e de engenharia, onde pequenas imprecisões são esperadas e o volume de operações é alto.
  • Machine learning e processamento numérico (numpy, pandas trabalham nativamente com float por performance).
  • Coordenadas geográficas, sensores, médias estatísticas — onde a precisão absoluta na décima casa decimal não tem significado prático.
  • Qualquer cálculo onde o resultado será exibido de forma aproximada de qualquer forma (ex: "87% de progresso").

A régua é simples: se o número representa dinheiro, ou se a exatidão é uma exigência contratual/legal, use Decimal. Para o resto, float é mais leve e continua sendo a escolha padrão.

Boas práticas na hora de usar Decimal

  • Sempre construa Decimal a partir de string, nunca de floatDecimal("10.50"), nunca Decimal(10.50).
  • Em modelos Pydantic, use Decimal diretamente no type hint — o Pydantic já sabe validar e converter corretamente:
from decimal import Decimal
from pydantic import BaseModel

class Pedido(BaseModel):
    valor_total: Decimal
  • Se o dado vier de um formulário, JSON ou API externa, ele provavelmente vai chegar como string ou float — valide e converta explicitamente na borda do sistema, não confie que "sempre vai vir certo".
  • No banco de dados, use tipos equivalentes — NUMERIC ou DECIMAL no PostgreSQL, por exemplo — em vez de FLOAT ou DOUBLE PRECISION, para que a precisão exata seja preservada também na persistência.
  • Evite converter Decimal para float no meio do caminho só para "facilitar uma conta" — isso reintroduz exatamente o erro que você estava evitando. Se precisar misturar com bibliotecas que só aceitam float (como algumas de plotagem gráfica), converta apenas no fim, para exibição.
  • Documente a decisão no código. Um comentário simples tipo # Decimal: valores monetários exigem precisão exata (ver docs internas) economiza a dúvida do próximo dev que ler o código e pensar "por que não é só float aqui?".

Conclusão

O erro de 0.1 + 0.2 é engraçado no REPL, mas é sério em produção. Não porque o float esteja quebrado — ele está fazendo exatamente o que o padrão IEEE 754 define —, mas porque ele foi projetado para aproximação eficiente, não para exatidão decimal garantida. Dinheiro exige a segunda coisa.

A regra prática que vale levar deste artigo: toda vez que você escrever um modelo, uma função ou uma query que lida com valores monetários, pare um segundo e pergunte "isso é Decimal ou é float?". Essa pergunta, feita cedo, custa dois segundos. Feita tarde — depois que o sistema já está em produção lidando com dinheiro de verdade — pode custar bem mais.