Decimal vs float para dinero y precisión numérica

Cuando Python calcula 0.1 + 0.2, el resultado es 0.30000000000000004. No es un bug del lenguaje ni una rareza: es la consecuencia directa de cómo IEEE 754 representa números en punto flotante binario. El problema es que 0.1 no tiene representación exacta en base 2, igual que 1/3 no tiene representación exacta en base 10. Lo que almacenas en memoria es la fracción binaria más cercana posible, y al operar con ella, el error de representación se propaga.

En la mayoría de los contextos esto es inocuo. Pero en código financiero, ese 0.00000000000000004 sobrante no es ruido: puede ser la diferencia entre aprobar o rechazar una transacción, entre cuadrar o no una hoja contable, o entre calcular un impuesto correcto y uno que no pasa una auditoría.

El módulo decimal de la biblioteca estándar resuelve esto implementando aritmética en punto flotante decimal. En lugar de trabajar en base 2, trabaja en base 10, la misma base que usamos los humanos. Decimal('0.1') almacena exactamente ese valor. La clave es entender que Decimal no da “precisión infinita” —sigue siendo punto flotante, con un número configurable de dígitos significativos— sino que elimina el error de representación que surge de convertir entre bases.

El contexto aritmético (decimal.getcontext()) controla la precisión máxima (por defecto 28 dígitos significativos) y el modo de redondeo. Puedes modificarlo globalmente o, mejor aún, usar decimal.localcontext() para aislar cambios a un bloque específico sin afectar al resto del programa.

¿Cuándo usar Decimal? Cuando el redondeo tiene consecuencias: precios, impuestos, intereses, comisiones, cualquier cálculo que luego aparece en un documento legal o una conciliación bancaria. Cuando el error no importa —coordenadas GPS, simulaciones físicas, machine learning— float es la elección correcta: es 10–100× más rápido y consume menos memoria.

Lo que se rompe si ignoras esto: los errores de representación se acumulan. Mil transacciones de 0.1 no suman exactamente 100.0 con float. En un sistema de pagos esto se traduce en discrepancias reales que son difíciles de depurar porque cada operación individual parece razonable.

import decimal
from decimal import Decimal, ROUND_HALF_UP

# ── Configuración del contexto ──────────────────────────────────────────
# 28 dígitos es el default; lo hacemos explícito por claridad en proyectos
# financieros donde el equipo necesita saber que se tomó esta decisión.
decimal.getcontext().prec = 28
decimal.getcontext().rounding = ROUND_HALF_UP  # comportamiento bancario estándar


# ── Modelo de datos ─────────────────────────────────────────────────────
class LineItem:
    def __init__(self, description: str, unit_price: str, quantity: int):
        # Siempre construir Decimal desde string, nunca desde float.
        # Decimal(0.1) hereda el error de representación del float 0.1.
        self.description = description
        self.unit_price = Decimal(unit_price)
        self.quantity = Decimal(quantity)

    def subtotal(self) -> Decimal:
        return self.unit_price * self.quantity


def apply_discount(amount: Decimal, percent: str) -> Decimal:
    rate = Decimal(percent) / Decimal("100")
    return amount * (Decimal("1") - rate)


def apply_tax(amount: Decimal, tax_rate: str) -> Decimal:
    rate = Decimal(tax_rate) / Decimal("100")
    return amount * (Decimal("1") + rate)


def quantize_money(amount: Decimal) -> Decimal:
    # Redondeo final a dos decimales, solo al presentar o persistir el valor.
    # Redondear antes de este punto acumula errores de redondeo intermedios.
    return amount.quantize(Decimal("0.01"))


# ── Cálculo de una factura ──────────────────────────────────────────────
items = [
    LineItem("Widget A",  "9.99",  3),
    LineItem("Widget B",  "14.50", 1),
    LineItem("Widget C",  "0.99",  7),
]

subtotal = sum((item.subtotal() for item in items), Decimal("0"))
after_discount = apply_discount(subtotal, "10")   # 10% de descuento
total_with_tax = apply_tax(after_discount, "21")  # IVA 21%

print(f"Subtotal:          {quantize_money(subtotal)}")
print(f"Después descuento: {quantize_money(after_discount)}")
print(f"Total con IVA:     {quantize_money(total_with_tax)}")

# ── Demostración del problema original ─────────────────────────────────
print("\n--- float vs Decimal ---")
print(f"float:   {0.1 + 0.2}")                                     # 0.30000000000000004
print(f"Decimal: {Decimal('0.1') + Decimal('0.2')}")               # 0.3
print(f"¿Iguales con Decimal?: {Decimal('0.1') + Decimal('0.2') == Decimal('0.3')}")  # True

# ── Alternativa: enteros de centavos ───────────────────────────────────
# Para sistemas donde la velocidad es crítica y la unidad mínima es fija.
price_cents = 999        # $9.99 como enteros
quantity    = 3
total_cents = price_cents * quantity   # 2997 centavos, aritmética entera exacta
print(f"\nEnteros de centavos: {total_cents} ¢ = ${total_cents / 100:.2f}")

Lo que realmente está pasando aquí

La línea Decimal(unit_price) recibe un str, no un float. Esta distinción es fundamental: cuando escribes Decimal(9.99), Python primero crea el float 9.99, que ya es 9.98999999999999999112..., y luego Decimal convierte esa imprecisión fielmente a decimal. El string "9.99" en cambio se interpreta directamente como el número decimal 9.99, sin pasar por representación binaria.

quantize_money aplica el redondeo solo al final, antes de mostrar o guardar el valor. Redondear en cada paso intermedio introduce error de redondeo acumulado, que es un problema diferente al de representación pero igual de real. La regla práctica: trabaja con toda la precisión disponible durante el cálculo, redondea una sola vez al persistir o mostrar.

decimal.localcontext() (no usado aquí por brevedad, pero relevante en producción) permite cambiar precisión o modo de redondeo para un cálculo específico sin contaminar el contexto global. Si tienes un módulo que necesita 50 dígitos para un cálculo de interés compuesto a largo plazo, aislarlo con localcontext es la práctica correcta.

La alternativa de enteros de centavos merece atención. Si tu dominio tiene una unidad mínima fija —centavos de euro, satoshis de bitcoin— representar el dinero como enteros elimina el problema de punto flotante completamente: los enteros en Python son exactos. El precio es que formatear para presentación requiere división, y que si alguna vez necesitas fracciones de centavo (intereses diarios, por ejemplo), el modelo se complica.

Errores que debes conocer

Error: construir Decimal desde un float literal en lugar de desde un string.

# ❌ Wrong
price = Decimal(0.1)   # Hereda el error: Decimal('0.1000000000000000055511151231257827021181583404541015625')

# ✅ Right
price = Decimal("0.1")  # Exactamente 0.1

El constructor de Decimal es honesto: si le das un float impreciso, te devuelve su representación exacta, imprecisiones incluidas. Siempre usa strings o enteros como fuente.


Error: comparar Decimal con float directamente en condiciones.

# ❌ Wrong
total = Decimal("10.00")
if total == 10.0:      # Python convierte el float; el resultado puede ser inesperado
    process_payment()

# ✅ Right
if total == Decimal("10.00"):
    process_payment()

Python intenta la comparación convirtiendo el float a Decimal, pero el float 10.0 tiene representación exacta en este caso concreto. El problema real aparece con valores como 10.1: mezclar tipos en comparaciones de igualdad en código financiero es una fuente de bugs sutiles que no explotan en tests básicos.


Error: usar round() incorporado de Python para redondear Decimal a moneda.

# ❌ Wrong
total = round(Decimal("2.455"), 2)  # Puede dar 2.45 por banker's rounding (ROUND_HALF_EVEN)

# ✅ Right
total = Decimal("2.455").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)  # 2.46

round() en Python usa banker’s rounding (redondeo al par más cercano), que minimiza el sesgo estadístico pero raramente coincide con las reglas contables o fiscales de tu país. .quantize() con el modo de redondeo explícito te da control total y hace la intención visible en el código.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio