Decimal Module — Deep Dive
The Specification Behind Decimal
Python’s decimal module implements IBM’s General Decimal Arithmetic specification (authored by Mike Cowlishaw), which itself became IEEE 854. This isn’t a Python invention — it’s a formal standard used in financial systems worldwide, from COBOL mainframes to Java’s BigDecimal.
A Decimal value is represented as a triple: (sign, coefficient, exponent):
from decimal import Decimal
d = Decimal("123.45")
print(d.as_tuple()) # DecimalTuple(sign=0, digits=(1, 2, 3, 4, 5), exponent=-2)
The value equals (-1)^sign × coefficient × 10^exponent. This representation avoids binary fraction issues entirely — 0.1 is stored as (0, (1,), -1), exactly one-tenth.
C Implementation: _decimal and libmpdec
Since Python 3.3, the decimal module uses Stefan Krah’s _decimal C extension, backed by the libmpdec library. This replaced the pure-Python implementation from Python 2, delivering a ~100x speedup:
import timeit
from decimal import Decimal
# Decimal arithmetic is fast in Python 3.3+
timeit.timeit('a + b', globals={'a': Decimal("1.23"), 'b': Decimal("4.56")}, number=1_000_000)
# ~0.15s on modern hardware
# vs float (still faster)
timeit.timeit('a + b', globals={'a': 1.23, 'b': 4.56}, number=1_000_000)
# ~0.03s
Decimal is roughly 3-5x slower than float for basic arithmetic. For I/O-bound financial applications, this is negligible. For compute-heavy numerical work, it’s a deal-breaker.
Thread-Local Contexts
Each thread gets its own decimal context. This is critical for web servers where different requests might need different precision:
import threading
from decimal import getcontext, localcontext, Decimal, ROUND_HALF_UP
def process_payment():
# Thread-safe: modifies only this thread's context
with localcontext() as ctx:
ctx.prec = 10
ctx.rounding = ROUND_HALF_UP
amount = Decimal("99.995")
return amount.quantize(Decimal("0.01"))
localcontext() is a context manager that temporarily modifies the context and restores it on exit. Always prefer it over getcontext() in production — direct context modification is not exception-safe.
Context Attributes
from decimal import Context, ROUND_HALF_EVEN
ctx = Context(
prec=28, # Significant digits
rounding=ROUND_HALF_EVEN, # Default rounding mode
Emin=-999999, # Minimum exponent
Emax=999999, # Maximum exponent
capitals=1, # E vs e in string output
clamp=0, # Clamp exponents to range
flags=[], # Accumulated condition flags
traps=[], # Conditions that raise exceptions
)
Special Values: NaN, Infinity, and Signed Zero
Decimal supports IEEE special values:
from decimal import Decimal, InvalidOperation
pos_inf = Decimal("Infinity")
neg_inf = Decimal("-Infinity")
nan = Decimal("NaN")
snan = Decimal("sNaN") # Signaling NaN — raises on any operation
print(Decimal("1") / Decimal("0")) # Raises DivisionByZero (if trapped)
print(Decimal("0") == Decimal("-0")) # True (but they're distinguishable)
print(Decimal("-0").is_signed()) # True
print(Decimal("0").is_signed()) # False
Signaling NaN (sNaN) is useful for marking uninitialized values — any arithmetic with it raises InvalidOperation.
Production Pattern: Money Type
Never use raw Decimal for money. Wrap it:
from decimal import Decimal, ROUND_HALF_EVEN, localcontext
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self):
if not isinstance(self.amount, Decimal):
object.__setattr__(self, 'amount',
Decimal(str(self.amount)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_EVEN
))
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
return Money(self.amount + other.amount, self.currency)
def __mul__(self, factor):
if isinstance(factor, (int, Decimal)):
return Money(self.amount * Decimal(str(factor)), self.currency)
return NotImplemented
def allocate(self, ratios: list[int]) -> list["Money"]:
"""Split money by ratios without losing pennies."""
total = sum(ratios)
results = []
remainder = self.amount
for i, ratio in enumerate(ratios):
if i == len(ratios) - 1:
results.append(Money(remainder, self.currency))
else:
share = (self.amount * ratio / total).quantize(
Decimal("0.01"), rounding=ROUND_HALF_EVEN
)
results.append(Money(share, self.currency))
remainder -= share
return results
The allocate method solves the “split $10 three ways” problem — 3.33 + 3.33 + 3.33 = 9.99, losing a penny. The last share gets the remainder.
Traps and Signals in Practice
For financial code, enable traps to catch errors early:
from decimal import getcontext, Inexact, Overflow, DivisionByZero
ctx = getcontext()
ctx.traps[Inexact] = True
ctx.traps[Overflow] = True
ctx.traps[DivisionByZero] = True
# Now any rounding raises Inexact
try:
result = Decimal("1") / Decimal("3") # Raises Inexact
except Inexact:
print("Result required rounding — review precision settings")
In production, you typically don’t trap Inexact globally (too noisy), but enable it in specific code paths where unexpected rounding indicates a bug.
Performance Optimization
Avoid string conversion in hot paths
# Slow: converts to string on every call
def calculate_tax_slow(price_str, rate_str):
return Decimal(price_str) * Decimal(rate_str)
# Fast: pre-convert, reuse Decimal objects
TAX_RATE = Decimal("0.0825")
def calculate_tax_fast(price: Decimal) -> Decimal:
return price * TAX_RATE
Batch operations with reduced precision
with localcontext() as ctx:
ctx.prec = 10 # Lower precision for intermediate calculations
subtotal = sum(Decimal(str(p)) for p in prices)
# Full precision for final result
total = subtotal.quantize(Decimal("0.01"))
Use integer arithmetic when possible
For systems processing millions of transactions, store amounts as integer cents and convert to Decimal only for display:
class IntMoney:
"""Store as integer cents for maximum performance."""
__slots__ = ('_cents', 'currency')
def __init__(self, cents: int, currency: str = "USD"):
self._cents = cents
self.currency = currency
@classmethod
def from_decimal(cls, amount: Decimal, currency: str = "USD"):
return cls(int(amount * 100), currency)
@property
def decimal(self) -> Decimal:
return Decimal(self._cents) / Decimal(100)
def __add__(self, other):
return IntMoney(self._cents + other._cents, self.currency)
Integer arithmetic is ~10x faster than Decimal and guarantees exactness for fixed-point amounts.
Comparison Gotchas
# Decimal and float comparison works but is surprising
Decimal("0.1") == 0.1 # False! float 0.1 != Decimal 0.1
# Decimal and int comparison works correctly
Decimal("1") == 1 # True
# Hash compatibility
hash(Decimal("1")) == hash(1) == hash(1.0) # True
# So they work as dict keys interchangeably for integer values
Never mix Decimal and float in comparisons or arithmetic. Decimal + float raises TypeError (by design, since Python 3).
Database Integration
Most databases store decimal values as NUMERIC or DECIMAL types. Python database drivers typically return these as Decimal objects:
# SQLAlchemy
from sqlalchemy import Column, Numeric
class Product(Base):
price = Column(Numeric(10, 2)) # 10 digits, 2 decimal places
# Returns Decimal objects automatically
# psycopg2 returns Decimal for NUMERIC columns by default
Ensure your ORM isn’t silently converting to float. Check with type(row.price) — it should be decimal.Decimal.
JSON Serialization
json.dumps doesn’t handle Decimal:
import json
from decimal import Decimal
# Fails: TypeError
json.dumps({"price": Decimal("19.99")})
# Fix 1: Custom encoder
class DecimalEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Decimal):
return str(obj)
return super().default(obj)
json.dumps({"price": Decimal("19.99")}, cls=DecimalEncoder)
# '{"price": "19.99"}'
# Fix 2: Convert to float (loses precision — usually OK for display)
json.dumps({"price": float(Decimal("19.99"))})
For APIs, sending Decimal as a string is safer — the client can parse it with their own precision rules.
One thing to remember: Python’s decimal module implements the General Decimal Arithmetic standard via libmpdec, providing exact base-10 arithmetic with configurable precision, thread-local contexts, and eight rounding modes. For production financial code: wrap Decimal in a Money type, use localcontext() for thread safety, prefer integer cents for high-throughput paths, and never create Decimal from float literals.
See Also
- Python Atexit How Python's atexit module lets your program clean up after itself right before it shuts down.
- Python Bisect Sorted Lists How Python's bisect module finds things in sorted lists the way you'd find a word in a dictionary — by jumping to the middle.
- Python Contextlib How Python's contextlib module makes the 'with' statement work for anything, not just files.
- Python Copy Module Why copying data in Python isn't as simple as it sounds, and how the copy module prevents sneaky bugs.
- Python Dataclass Field Metadata How Python dataclass fields can carry hidden notes — like sticky notes on a filing cabinet that tools read automatically.