Coercing and Validating Decimal Quantities with Pydantic Permalink to this section
↑ Part of Schema Validation Using Pydantic.
A quantity or unit price that enters your model as a Python float carries binary rounding error the instant it is parsed, long before any business rule runs against it. Decimal("1234.50") != Decimal(str(1234.50 * 3)) is not a hypothetical — it is what happens every time a receiving quantity is multiplied by a unit cost using float arithmetic and then compared against an ERP total computed in fixed-point. This page addresses one precise boundary decision: how do you force every financial quantity and price field through Pydantic v2 as Decimal, reject float input outright instead of silently coercing it, normalize the comma-formatted and over-precise strings real feeds send, and route anything that fails to a quarantine record with a reason code your downstream systems can act on? It is the numeric-precision companion to the contract design in the parent Schema Validation Using Pydantic reference and to the field-level coercion patterns in Validating Supplier Data Payloads with Pydantic Models.
Operational Trigger Signals Permalink to this section
Add an explicit Decimal-only coercion layer for quantity and price fields — instead of relying on Pydantic’s default lax numeric coercion — when your reconciliation telemetry shows any of these measurable conditions:
- Phantom quantity or price variances appear without a source discrepancy. A
quantity_receivedof150.1and an ERP total of150.10000000000002fail an equality check even though no physical unit is in dispute — the variance exists only in the arithmetic, not the shipment. - Rounding drift accumulates across batch aggregation. Summing thousands of
floatline totals produces a batch total that diverges from the sum of the same values computed in fixed-point by a cent or more, and the drift grows with batch size rather than staying constant. - Fields silently accept more precision than the ledger can post. A
unit_priceof14.123456789passes validation as a barefloator unconstrainedDecimal, then fails at ERP import because the target column isNUMERIC(14,6)and truncates or rejects the row. - Thousands-separated or locale-formatted strings raise a parse error instead of being normalized. Vendor exports send
"1,234.50"or"1 234,50"; a naiveDecimal(value)call raisesInvalidOperationon the comma, and the record is rejected for a formatting difference a human would resolve in one glance. - Scientific notation or negative quantities reach fields that must never carry them. A malformed export emits
"1.5E+3"for a quantity field, and without an explicit precision and sign constraint it coerces successfully into a value three orders of magnitude off.
When any of these signals persist across consecutive ingestion runs, the fix is not a looser equality check downstream — matching and reconciliation already assume decimal-safe inputs, which is why Setting Quantity and Price Tolerance Windows defines tolerance in fixed-point terms. The fix belongs at the schema boundary, before a float-tainted value can enter any downstream calculation.
Step-by-Step Implementation Permalink to this section
Build the coercion as a field_validator with mode="before" so it runs ahead of Pydantic’s own type coercion, combined with Field constraints that enforce precision after the value is typed. The procedure:
- Reject
floatinput immediately — do not attempt to recover it viaDecimal(str(value)). Afloathas already lost precision by the time your validator sees it; onlystr,int, andDecimalinputs are trustworthy. - Normalize string input — strip whitespace and thousands separators, and reject anything that still fails
Decimal()parsing with a structured error rather than an unhandled exception. - Quantize to the field’s declared precision using an explicit rounding mode, never the ambient default.
- Enforce
max_digitsanddecimal_placesasFieldconstraints so a value that is technically a validDecimalbut exceeds the ledger’s column precision fails validation instead of silently truncating on write. - Catch
ValidationError, extract per-field reason codes, and route the record to quarantine rather than raising past the ingestion boundary.
import logging
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Annotated, Optional
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
logger = logging.getLogger("ingestion.decimal_quantities")
# Precision matches the ERP's NUMERIC(12,4) receiving column.
QuantityDecimal = Annotated[
Decimal,
Field(ge=0, max_digits=12, decimal_places=4, description="Receiving quantity, 4dp"),
]
# Precision matches NUMERIC(14,6) to absorb sub-cent FX-converted unit costs.
UnitPriceDecimal = Annotated[
Decimal,
Field(gt=0, max_digits=14, decimal_places=6, description="Unit price, base currency, 6dp"),
]
_QUANTUM_BY_FIELD = {"quantity_received": Decimal("1.0000"), "unit_price": Decimal("1.000000")}
class ReceivingLine(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
line_number: int = Field(ge=1)
sku: str = Field(pattern=r"^[A-Z0-9\-]{4,20}$")
quantity_received: QuantityDecimal
unit_price: UnitPriceDecimal
supplier_currency: Optional[str] = Field(default=None, pattern=r"^[A-Z]{3}$")
@field_validator("quantity_received", "unit_price", mode="before")
@classmethod
def coerce_decimal(cls, value: object, info) -> Decimal:
"""Force Decimal at the boundary: reject float, normalize strings, quantize."""
field = info.field_name
if isinstance(value, float):
# A float has already lost precision before this validator runs;
# str(value) would launder the error instead of surfacing it.
logger.error("float_rejected field=%s raw=%r", field, value)
raise ValueError(f"{field} must be str/int/Decimal, not float (got {value!r})")
if isinstance(value, str):
cleaned = value.strip().replace(",", "").replace(" ", "")
try:
value = Decimal(cleaned)
except InvalidOperation as exc:
logger.error("decimal_parse_failed field=%s raw=%r", field, value)
raise ValueError(f"{field} is not a parseable decimal string: {value!r}") from exc
elif isinstance(value, int):
value = Decimal(value)
if not isinstance(value, Decimal):
logger.error("decimal_coercion_failed field=%s type=%s", field, type(value).__name__)
raise ValueError(f"{field} must coerce to Decimal, got {type(value).__name__}")
if value.is_nan() or value.is_infinite():
raise ValueError(f"{field} must be a finite decimal value")
quantum = _QUANTUM_BY_FIELD.get(field, Decimal("1.0000"))
return value.quantize(quantum, rounding=ROUND_HALF_UP)
def validate_receiving_line(raw: dict) -> tuple[Optional[ReceivingLine], Optional[dict]]:
"""Validate one receiving line; return (model, None) on success or (None, quarantine_record)."""
try:
return ReceivingLine.model_validate(raw), None
except ValidationError as exc:
errors = exc.errors()
logger.warning(
"quarantine line=%s error_count=%d fields=%s",
raw.get("line_number"), len(errors), [e["loc"] for e in errors],
)
quarantine = {
"raw_payload": raw,
"failure_reasons": [
{"field": ".".join(str(p) for p in e["loc"]), "type": e["type"], "msg": e["msg"]}
for e in errors
],
}
return None, quarantine
The order of operations inside coerce_decimal is what makes the field trustworthy: the float check runs before any string handling can accidentally re-admit a float that was passed as f"{value}" upstream, and quantization always runs last so every value that reaches the Field constraint check has already been rounded to the field’s declared precision — a value can fail decimal_places only because it genuinely exceeds the ledger’s column width, not because of an unrounded remainder.
Quantization is the step that most implementations get subtly wrong by relying on the Decimal context’s default rounding rather than declaring one explicitly. Given a target precision of decimal places, the quantize step computes:
with the rounding rule pinned to ROUND_HALF_UP (or ROUND_HALF_EVEN if your finance team mandates banker’s rounding for tax calculations) rather than left to whatever the ambient decimal.Context happens to be configured with in that process. A max_digits constraint additionally requires that the total digit count — integer part plus fractional digits — never exceed the column width the value will eventually be written to, which is precisely the check that catches a value that quantizes cleanly but still would not fit NUMERIC(12,4).
Configuration Reference Permalink to this section
Field-level precision should mirror the destination column exactly, not a convenient round number. Set max_digits from the target table’s NUMERIC(p, s) definition and decimal_places from s.
| Field | Pydantic type | max_digits |
decimal_places |
Extra constraint | Notes |
|---|---|---|---|---|---|
quantity_received |
Decimal |
12 | 4 | ge=0 |
Fractional units (kg, L, m) need 4dp; eaches can use 0dp. |
unit_price |
Decimal |
14 | 6 | gt=0 |
Sub-cent precision absorbs FX-converted unit costs. |
line_total |
Decimal |
16 | 6 | ge=0 |
Computed field; validate as output, never accept from the feed. |
exchange_rate |
Decimal |
12 | 8 | gt=0 |
Matches the precision used across the multi-currency reconciliation frameworks. |
tolerance_pct |
Decimal |
5 | 4 | ge=0, le=1 |
A ratio, not a currency amount; still must reject float input. |
| rounding mode | decimal.ROUND_* |
— | — | ROUND_HALF_UP default |
Pin per field; never inherit the process-wide Decimal context. |
model_config.strict |
bool |
— | — | True |
Blocks Pydantic’s own implicit str/int coercion outside the validator. |
model_config.extra |
str |
— | — | "forbid" |
An undocumented numeric field is a contract change, not noise to drop. |
Debugging & Recovery Permalink to this section
Precision bugs are silent by construction — a float-tainted quantity validates successfully and only surfaces three stages downstream as an unexplained variance. Recovery has to start from the quarantine record, not from the reconciliation exception:
- Trace phantom variances to their coercion path first. Before treating a small quantity or price mismatch as a business exception, check whether either side of the comparison ever passed through
float. A variance that is a repeating binary fraction (0.1000000000000000055511151231257827021181583404541015625truncated to look like0.1) is a coercion bug, not a shipment discrepancy, and belongs back at this validator, not in the exception queue documented for Setting Quantity and Price Tolerance Windows. - Replay quarantined records after a schema fix. Because
validate_receiving_linenever mutates the original payload, a correctedmax_digitsor an added currency alias can be deployed and every quarantined record reprocessed idempotently without re-fetching from the source system. - Distinguish
InvalidOperationfromValidationError.Decimal()parsing raisesInvalidOperationfor genuinely unparseable strings; Pydantic’sValidationErrorcarries the full field path and error type. CatchingInvalidOperationinside the validator and re-raising asValueErroris what lets it surface through the structuredValidationError.errors()list instead of crashing the batch. - Watch for rounding-mode drift across services. If the ingestion validator quantizes with
ROUND_HALF_UPbut a downstream aggregation service uses theDecimalmodule’s defaultROUND_HALF_EVEN, the two totals will diverge by a cent on every batch with an odd number of.5boundary values — pin and document the rounding mode once, at the schema boundary, and treat any other service that re-rounds the same field as a bug. - Alert on a rising
PRECISION_EXCEEDEDshare, distinct fromFLOAT_REJECTED. A rising float-rejection rate usually means a new integration emits typed JSON numbers instead of strings and needs an explicitstr()cast upstream; a rising precision-exceeded rate usually means a supplier changed their unit-of-measure granularity and the field’sdecimal_placesgenuinely needs revisiting.
FAQ Permalink to this section
Why does Pydantic’s default lax coercion corrupt financial quantities if float is a valid Python number? Permalink to this section
Because Pydantic’s default (non-strict) numeric coercion will accept a float for a Decimal field by calling Decimal(value) directly on the float object, which reproduces the float’s exact binary representation — not the decimal value a human typed. Decimal(1234.50) is Decimal('1234.5000000000000454747350886464118957519531250'), not Decimal('1234.50'). The mode="before" validator in this pattern intercepts the value before Pydantic’s own coercion runs and rejects the float outright, forcing the caller to supply a string or an already-correct Decimal instead.
Should decimal_places match the currency’s minor unit or the source system’s precision? Permalink to this section
Match the destination ledger’s column precision, not the currency’s textbook minor unit. Most currencies post at 2 decimal places, but unit prices computed after FX conversion routinely need 6 to avoid rounding a sub-cent difference to zero across thousands of lines, and physical quantities in kilograms or liters often need 3–4dp that has nothing to do with currency at all. Setting decimal_places from the NUMERIC(p, s) definition of the table the value will eventually be written to is the only choice that prevents a value from validating successfully and then failing on write.
What rounding mode should the quantize step use for financial quantities? Permalink to this section
ROUND_HALF_UP is the safest default because it matches how most ERP and accounting systems round for display and posting, and it is unambiguous to audit. ROUND_HALF_EVEN (banker’s rounding) is sometimes required for tax calculations specifically because it eliminates a slight upward bias across large populations of .5 values — use it only where a specific business rule mandates it, and never let two services in the same pipeline quantize the same field with different modes.
Related Permalink to this section
- Schema Validation Using Pydantic — the parent contract-design reference this precision policy implements
- Validating Supplier Data Payloads with Pydantic Models — the full payload-level validation procedure this page’s field constraints plug into
- Setting Quantity and Price Tolerance Windows — the downstream tolerance check that assumes decimal-safe inputs
- Multi-Currency Reconciliation Frameworks — precision requirements for exchange-rate and converted-price fields
- ↑ Parent: Schema Validation Using Pydantic