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
- Understanding IEEE 754
- Why Float Has Precision Issues
- Decimal: The Precise Alternative
- Practical Comparisons
- When to Use What
- FastAPI Implementation
- Best Practices
- Performance Considerations
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:
- Performance is critical
- Working with large datasets
- Scientific computing (approximations are acceptable)
- Graphics and games (small errors don't matter)
- 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:
- Financial calculations (money must be exact)
- Accounting systems
- Legal/regulatory requirements for precision
- User-facing calculations where precision matters
- 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:
- Understand the trade-offs: Float is fast but imprecise; Decimal is precise but slower
- Be consistent: Don't mix float and Decimal in the same calculation context
- Validate inputs: Ensure proper decimal places for currency
- Test thoroughly: Include edge cases and precision requirements in tests
- 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:
- API Development - Building robust API endpoints
- Validation - Input validation strategies
- Testing Guide - Testing numeric calculations
- Database Operations - Storing numeric data properly