Float vs Decimal. Fight! - Noloquideus/fastapi-template GitHub Wiki

Float vs Decimal: Understanding Precision in Python

A comprehensive guide to floating-point arithmetic, decimal precision, and when to use each in your FastAPI applications.

Table of Contents

The Floating-Point Mystery

Have you ever been surprised that 0.1 + 0.2 != 0.3 in Python? Why does float calculate with errors, and why is everyone okay with it?

>>> 0.1 + 0.2
0.30000000000000004

>>> 0.1 + 0.2 == 0.3
False

This isn't a Python bug—it's a fundamental aspect of how computers represent floating-point numbers.

Understanding IEEE 754

Binary Representation of 0.1

The number 0.1 in binary floating-point (IEEE 754 double precision) looks like:

0 01111111011 1100110011001100110011001100110011001100110011001101

Breaking this down:

  • Sign bit (1 bit): 0 = positive, 1 = negative
  • Exponent (11 bits): 01111111011 = 1019₁₀ - 1023 (bias) = -4
  • Mantissa (52 bits): 1100110011... ≈ 0.1 (with precision loss)

The Full Picture

import decimal

# What 0.1 actually looks like in binary float
>>> decimal.Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

# What 0.2 actually looks like
>>> decimal.Decimal(0.2) 
Decimal('0.200000000000000011102230246251565404236316680908203125')

# The sum
>>> decimal.Decimal(0.1 + 0.2)
Decimal('0.3000000000000000444089209850062616169452667236328125')

# What 0.3 actually is
>>> decimal.Decimal(0.3)
Decimal('0.299999999999999988897769753748434595763683319091796875')

The numbers aren't equal because their difference exceeds the precision limit of float.

Machine Epsilon

Python provides sys.float_info.epsilon to show the minimum difference detectable between 1.0 and the next representable float:

import sys

>>> sys.float_info.epsilon
2.220446049250313e-16

>>> 1.0 + sys.float_info.epsilon > 1.0
True

>>> 1.0 + sys.float_info.epsilon / 2 == 1.0
True  # Too small to be represented

Why Float Has Precision Issues

1. Binary vs Decimal Representation

Computers use binary (base-2), but humans think in decimal (base-10). Some decimal fractions cannot be exactly represented in binary, just like 1/3 cannot be exactly represented in decimal (0.333...).

# These fractions have exact binary representations
>>> 0.5    # 1/2
0.5
>>> 0.25   # 1/4  
0.25
>>> 0.125  # 1/8
0.125

# These don't
>>> 0.1    # 1/10
0.1
>>> 0.3    # 3/10
0.3

2. Limited Precision

IEEE 754 double precision provides about 15-17 decimal digits of precision. Beyond that, rounding errors accumulate.

>>> 0.1 + 2.220446049250313e-18 == 0.1
True  # Addition too small to affect the result

>>> 1.0000000000000002 == 1.0
False  # Just barely different enough to be detected

3. Cumulative Errors

Small errors can accumulate in complex calculations:

>>> sum(0.1 for _ in range(10))
0.9999999999999999

>>> sum(0.1 for _ in range(10)) == 1.0
False

Decimal: The Precise Alternative

Basic Usage

from decimal import Decimal, getcontext

# Creating Decimals
>>> Decimal('0.1')
Decimal('0.1')

>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')

>>> Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
True

Configurable Precision

from decimal import getcontext

# Default precision (usually 28)
>>> getcontext().prec
28

# Set custom precision
>>> getcontext().prec = 6
>>> Decimal(1) / Decimal(7)
Decimal('0.142857')

>>> getcontext().prec = 50
>>> Decimal(1) / Decimal(7)
Decimal('0.14285714285714285714285714285714285714285714285714')

Exact Arithmetic

# Exact decimal arithmetic
>>> sum(Decimal('0.1') for _ in range(10))
Decimal('1.0')

>>> sum(Decimal('0.1') for _ in range(10)) == Decimal('1')
True

Practical Comparisons

Performance

import timeit
from decimal import Decimal

# Float operations (fast)
>>> timeit.timeit('0.1 + 0.2', number=1000000)
0.03234567  # ~0.03 seconds

# Decimal operations (slower)
>>> timeit.timeit('Decimal("0.1") + Decimal("0.2")', 
...              setup='from decimal import Decimal', number=1000000)
0.421234567  # ~0.42 seconds

Memory Usage

import sys

>>> sys.getsizeof(3.14159)
24  # bytes

>>> sys.getsizeof(Decimal('3.14159'))
104  # bytes

Precision Comparison

import math

# Float precision limits
>>> 1.0000000000000001 == 1.0
True  # Not precise enough

>>> 1.00000000000000001 == 1.0  
True  # Still not precise enough

# Decimal precision
>>> Decimal('1.0000000000000001') == Decimal('1.0')
False  # Can distinguish

>>> Decimal('1.00000000000000000000000000001') == Decimal('1.0')
False  # Even finer precision

When to Use What

Use Float When:

  1. Performance is critical
  2. Working with large datasets
  3. Scientific computing (approximations are acceptable)
  4. Graphics and games (small errors don't matter)
  5. Hardware acceleration is needed
# Scientific computing example
import numpy as np

# Fast vector operations
data = np.array([1.1, 2.2, 3.3, 4.4], dtype=float)
result = data * 2.5  # Hardware accelerated

Use Decimal When:

  1. Financial calculations (money must be exact)
  2. Accounting systems
  3. Legal/regulatory requirements for precision
  4. User-facing calculations where precision matters
  5. APIs dealing with currency
# Financial calculation example
from decimal import Decimal

price = Decimal('19.99')
tax_rate = Decimal('0.08')
tax = price * tax_rate
total = price + tax

print(f"Price: ${price}")
print(f"Tax: ${tax}")  
print(f"Total: ${total}")  # Exact result

FastAPI Implementation

Model Definitions

from decimal import Decimal
from pydantic import BaseModel, Field
from typing import Optional

class Product(BaseModel):
    name: str
    # Use Decimal for money
    price: Decimal = Field(..., decimal_places=2, ge=0)
    # Use float for measurements that don't need exact precision
    weight_kg: float = Field(..., ge=0)
    
class OrderItem(BaseModel):
    product_id: int
    quantity: int = Field(..., ge=1)
    unit_price: Decimal = Field(..., decimal_places=2, ge=0)
    
    @property
    def total_price(self) -> Decimal:
        # Exact calculation for money
        return self.unit_price * Decimal(self.quantity)

class Order(BaseModel):
    items: list[OrderItem]
    tax_rate: Decimal = Field(default=Decimal('0.0'), ge=0, le=1)
    
    @property
    def subtotal(self) -> Decimal:
        return sum(item.total_price for item in self.items)
    
    @property 
    def tax_amount(self) -> Decimal:
        return self.subtotal * self.tax_rate
    
    @property
    def total(self) -> Decimal:
        return self.subtotal + self.tax_amount

API Endpoints

from fastapi import FastAPI, HTTPException
from decimal import Decimal, ROUND_HALF_UP

app = FastAPI()

@app.post("/orders/calculate")
async def calculate_order(order: Order) -> dict:
    """Calculate order totals with exact decimal precision."""
    
    # Round to 2 decimal places for currency
    subtotal = order.subtotal.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
    tax = order.tax_amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
    total = order.total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
    
    return {
        "subtotal": str(subtotal),  # Convert to string for JSON
        "tax": str(tax),
        "total": str(total),
        "items_count": len(order.items)
    }

@app.get("/products/{product_id}/price-tiers")
async def get_price_tiers(product_id: int, base_price: Decimal) -> dict:
    """Calculate price tiers with exact precision."""
    
    tiers = {}
    discounts = [Decimal('0.0'), Decimal('0.05'), Decimal('0.10'), Decimal('0.15')]
    
    for i, discount in enumerate(discounts):
        tier_name = f"tier_{i + 1}"
        discounted_price = base_price * (Decimal('1.0') - discount)
        tiers[tier_name] = {
            "discount_percent": str(discount * 100),
            "price": str(discounted_price.quantize(Decimal('0.01')))
        }
    
    return tiers

Configuration

# src/infrastructure/decimal_config.py
from decimal import getcontext, ROUND_HALF_UP

def configure_decimal_precision():
    """Configure decimal precision for the application."""
    # Set precision for financial calculations
    getcontext().prec = 28  # Default is usually fine
    getcontext().rounding = ROUND_HALF_UP  # Standard rounding
    
# Call this in your FastAPI startup
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Configure decimal on startup
    configure_decimal_precision()
    yield

app = FastAPI(lifespan=lifespan)

Database Integration

# SQLAlchemy model with Decimal
from sqlalchemy import Column, Integer, String, DECIMAL
from sqlalchemy.types import TypeDecorator, String as SQLString
from decimal import Decimal

class DecimalType(TypeDecorator):
    """Custom SQLAlchemy type for Decimal with proper precision."""
    impl = SQLString
    
    def process_bind_param(self, value, dialect):
        if value is not None:
            return str(value)
        return value
    
    def process_result_value(self, value, dialect):
        if value is not None:
            return Decimal(value)
        return value

class Product(Base):
    __tablename__ = "products"
    
    id = Column(Integer, primary_key=True)
    name = Column(String(255), nullable=False)
    # Store as DECIMAL for exact precision
    price = Column(DECIMAL(10, 2), nullable=False)  # 10 digits, 2 decimal places
    # Or use custom type for automatic Decimal conversion
    cost = Column(DecimalType(), nullable=False)

Best Practices

1. Input Validation

from decimal import Decimal, InvalidOperation
from pydantic import validator

class PriceModel(BaseModel):
    amount: Decimal
    
    @validator('amount')
    def validate_price(cls, v):
        if v < 0:
            raise ValueError('Price cannot be negative')
        
        # Ensure max 2 decimal places for currency
        if v.as_tuple().exponent < -2:
            raise ValueError('Price cannot have more than 2 decimal places')
        
        return v

2. Consistent Rounding

from decimal import Decimal, ROUND_HALF_UP

def round_currency(amount: Decimal) -> Decimal:
    """Standard currency rounding to 2 decimal places."""
    return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

def round_percentage(rate: Decimal) -> Decimal:
    """Round percentage to 4 decimal places."""
    return rate.quantize(Decimal('0.0001'), rounding=ROUND_HALF_UP)

# Usage
price = Decimal('19.996')
rounded_price = round_currency(price)  # Decimal('20.00')

3. JSON Serialization

import json
from decimal import Decimal

class DecimalEncoder(json.JSONEncoder):
    """JSON encoder that handles Decimal objects."""
    
    def default(self, obj):
        if isinstance(obj, Decimal):
            return str(obj)  # Convert to string to preserve precision
        return super().default(obj)

# Usage in FastAPI
from fastapi.encoders import jsonable_encoder

@app.get("/price")
async def get_price():
    price = Decimal('19.99')
    
    # Pydantic automatically handles Decimal -> string conversion
    return {"price": price}

4. Testing with Decimals

import pytest
from decimal import Decimal

class TestPriceCalculations:
    
    def test_exact_arithmetic(self):
        """Test that decimal arithmetic is exact."""
        price = Decimal('10.00')
        tax_rate = Decimal('0.08')
        expected_tax = Decimal('0.80')
        
        calculated_tax = price * tax_rate
        assert calculated_tax == expected_tax
    
    def test_rounding_consistency(self):
        """Test that rounding is consistent."""
        values = [
            Decimal('1.995'),  # Should round to 2.00
            Decimal('1.994'),  # Should round to 1.99
        ]
        
        expected = [Decimal('2.00'), Decimal('1.99')]
        
        for value, expected_result in zip(values, expected):
            rounded = round_currency(value)
            assert rounded == expected_result
    
    @pytest.mark.parametrize("input_val,expected", [
        ("10.00", Decimal("10.00")),
        ("10.99", Decimal("10.99")),
        ("0.01", Decimal("0.01")),
    ])
    def test_decimal_conversion(self, input_val, expected):
        """Test string to Decimal conversion."""
        result = Decimal(input_val)
        assert result == expected

Performance Considerations

When Performance Matters

import timeit
from decimal import Decimal

def benchmark_operations():
    """Compare float vs Decimal performance."""
    
    # Float operations
    float_time = timeit.timeit(
        'sum(0.1 * i for i in range(1000))',
        number=1000
    )
    
    # Decimal operations  
    decimal_time = timeit.timeit(
        'sum(Decimal("0.1") * i for i in range(1000))',
        setup='from decimal import Decimal',
        number=1000
    )
    
    print(f"Float time: {float_time:.4f}s")
    print(f"Decimal time: {decimal_time:.4f}s") 
    print(f"Decimal is {decimal_time/float_time:.1f}x slower")

# Example output:
# Float time: 0.0123s
# Decimal time: 0.1847s  
# Decimal is 15.0x slower

Optimization Strategies

from decimal import Decimal, localcontext

# 1. Use local context for temporary precision changes
def fast_decimal_calculation():
    with localcontext() as ctx:
        ctx.prec = 10  # Lower precision for faster calculation
        result = Decimal('1') / Decimal('7')
        return result

# 2. Batch operations when possible
def calculate_bulk_discounts(prices: list[Decimal], discount: Decimal) -> list[Decimal]:
    """Apply discount to multiple prices efficiently."""
    multiplier = Decimal('1') - discount  # Calculate once
    return [price * multiplier for price in prices]

# 3. Use appropriate data types for different parts
class OptimizedOrder:
    def __init__(self):
        self.items = []  # Store as tuples for speed
        self._total_cache = None  # Cache expensive calculations
    
    def add_item(self, price: Decimal, quantity: int):
        self.items.append((price, quantity))
        self._total_cache = None  # Invalidate cache
    
    @property  
    def total(self) -> Decimal:
        if self._total_cache is None:
            self._total_cache = sum(
                price * Decimal(qty) for price, qty in self.items
            )
        return self._total_cache

Limitations and Edge Cases

Decimal Limitations

from decimal import Decimal
import math

# 1. Irrational numbers still can't be exactly represented
>>> Decimal(str(math.pi))
Decimal('3.141592653589793')  # Limited by string representation

# 2. Some operations may lose precision
>>> Decimal('1') / Decimal('3')
Decimal('0.3333333333333333333333333333')  # Limited by context precision

# 3. Memory usage grows with precision
>>> import sys
>>> sys.getsizeof(Decimal('1.23'))
104
>>> sys.getsizeof(Decimal('1.234567890123456789012345678901234567890'))
136  # More memory for higher precision

Float Advantages

# 1. Hardware acceleration
import numpy as np

large_array = np.random.random(1000000).astype(float)
# NumPy operations are heavily optimized for float

# 2. Built-in math functions
import math

value = 2.5
result = math.sqrt(value)  # Fast, hardware-accelerated

# With Decimal, you need to be more careful
from decimal import Decimal
decimal_value = Decimal('2.5')
# decimal_result = math.sqrt(decimal_value)  # This would lose precision!
decimal_result = decimal_value.sqrt()  # Use Decimal's methods

Conclusion

The choice between float and Decimal depends on your specific requirements:

Use Float for:

  • Performance-critical applications
  • Scientific computing where approximations are acceptable
  • Large-scale data processing
  • Graphics and games

Use Decimal for:

  • Financial applications where precision is legally required
  • Accounting systems
  • E-commerce platforms handling money
  • APIs where users expect exact calculations

Key Takeaways:

  1. Understand the trade-offs: Float is fast but imprecise; Decimal is precise but slower
  2. Be consistent: Don't mix float and Decimal in the same calculation context
  3. Validate inputs: Ensure proper decimal places for currency
  4. Test thoroughly: Include edge cases and precision requirements in tests
  5. Document your choice: Make it clear why you chose float vs Decimal

The "imprecision" of float isn't a bug—it's a design choice that enables incredibly fast calculations for most real-world applications. For the cases where exact precision matters, Decimal provides the solution, with the understanding that it comes at a performance cost.


See Also: