
Numeric Precision in Payouts: Why Our Python SDK Uses Decimal, Not Float
If you ever see 0.1 + 0.2 return 0.30000000000000004 and your first instinct is "that is fine, I will round it later," you should not be writing payouts code. We just shipped the Syndicate Links Python SDK and it does not use float anywhere money lives.
The story starts in Postgres. Every monetary column in our database — price, commission_amount, sale_amount, available_balance, total_paid — is a numeric type with explicit precision. Postgres numeric is exact. It does not round. When we serialize a row to JSON to send back over the API, we cast it to a string, not a JS number, because a JS number is an IEEE 754 double and cannot represent 19.99 exactly. Try it in a Node REPL: (0.1 + 0.2).toString() returns '0.30000000000000004'. Now imagine that across a million conversions a month.
This is not a Syndicate Links house style. It is the established fintech-engineering pattern. Voytek Pitula's Fintech Engineering Handbook states the rule directly: never use floating-point for money, store amounts as integer minor-units or arbitrary-precision decimals, and serialize money "either as a string (\"12.34\") or as an integer in its smallest unit" — because "a bare JSON number is an IEEE-754 double in most parsers, so serializing money as a number reintroduces the floating-point problem at the edge, no matter how carefully you represent it internally." That is the exact failure path our SDK is built to avoid.
So the API sends "19.99". Now the SDK has to receive it without losing the precision the database and the wire protocol fought to preserve. The lazy answer is float(value) and a TODO comment. The correct answer is Decimal(value).
Pydantic v2 will not do this for you automatically — it will accept a string into a Decimal field, but its default coercion does not handle the cases where the API returns an int, a float, an empty string, or None for an optional money field. So we wrote one helper and one validator:
from decimal import Decimal, InvalidOperation
def _coerce_decimal(value: Any) -> Optional[Decimal]:
if value is None:
return None
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value)) # str() on the way in — never Decimal(float)
if isinstance(value, str):
if value == "":
return None
try:
return Decimal(value)
except InvalidOperation as exc:
raise ValueError(f"could not parse {value!r} as Decimal") from exc
raise TypeError(f"unsupported type for Decimal coercion: {type(value).__name__}")
The detail that matters is line 8: Decimal(str(value)). If you write Decimal(value) directly when value is a float, Python hands you back the full IEEE 754 garbage:
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> Decimal(str(0.1))
Decimal('0.1')
Routing through str() first lets the float-to-string coercion do the rounding once, predictably, instead of leaking 55 digits of binary noise into a payout amount.
The validator gets attached to every monetary field on every model:
class Event(_SyndicateModel):
sale_amount: Optional[Decimal] = None
commission_amount: Optional[Decimal] = None
# ...
@field_validator("sale_amount", "commission_amount", mode="before")
@classmethod
def _decimalize(cls, v: Any) -> Optional[Decimal]:
return _coerce_decimal(v)
mode="before" matters: it runs the coercer before Pydantic's own type machinery, so the value is already a Decimal by the time Pydantic validates it. No double-conversion, no fallback to float somewhere in the middle.
The fields under this validator across the SDK: Product.price, Product.commission_pct, Program.default_commission_pct, Program.flat_commission, Partnership.custom_commission_pct, Event.sale_amount, Event.commission_amount, RefundEvent.clawed_back, Balance.{available, pending, lifetime, balance}, Payout.amount, PayoutClaim.amount_usd, PayoutStats.{total_paid, pending_amount}, EarningsReportRow.earnings, ReportSummary.{total_revenue, total_commissions}, DashboardSummary.{total_earnings, pending_payouts}. Anything that ever touches money.
You can build an SDK without this and ship it. It will work for the demo. It will work for the first thousand transactions. It will fail in production, silently, in ways your test suite does not catch, on the day a customer reconciles their Stripe payout against your dashboard and the numbers are off by a penny.
pip install syndicate-links