Decimal Seeds Lost in Locale Formatters

A seed value of 1234567.89 enters your pipeline, passes through a locale-aware formatter, and lands in Postgres as 1234568.00. No exception. No warning. Your assertion compares the stored value against the original seed and fails — or worse, passes because you rounded the expected value too. This class of bug sits at the intersection of number formatting, locale configuration, and ORM/driver type coercion, and it's almost never where engineers look first.

The root cause is straightforward: Python's locale.format_string(), Babel's format_decimal(), and even Faker's locale-parameterized numeric providers all produce strings with locale-specific separators and precision rules. If that string is then parsed back to a float before being written to a NUMERIC or DECIMAL column, you've introduced a rounding step that your seed value never consented to.

By the end of this article you'll be able to identify where in a test data pipeline locale formatters silently degrade decimal precision, instrument the boundary with guards that surface the loss immediately, and restructure seed generation so the formatted representation never touches the authoritative numeric value.

Build Smarter Test Automation With AI + BDD

Learn practical ways to create better frameworks, pipelines, tests, and automation strategies.

Learn more

Where Locale Formatting Intersects Decimal Seed Integrity

A decimal seed is any authoritative numeric value — price, measurement, financial amount — that you generate once, persist to a fixture or factory, and then assert against throughout a test suite. Precision loss occurs when a formatting layer converts that value to a locale-specific string (e.g., "1.234.567,89" in de_DE) and a downstream parser reconstructs a float from it using the wrong locale assumptions, silently dropping or rounding the fractional component.

This sits in the seed generation layer of your test architecture — before data reaches the database, before your service logic touches it. It's distinct from ORM-layer rounding (where floating-point seeds round through ORM layers during column mapping) because the loss happens in pure string manipulation, not type coercion. The affected tools include Faker with a non-default locale, Babel ≥ 2.10, Python's built-in locale module, and any Jinja2 template that calls a locale filter on a numeric seed before serialising to JSON or YAML fixtures.

Instrumenting and Fixing the Precision Boundary

The fastest way to reproduce the problem is to run Faker with a European locale and feed the result straight into a Decimal constructor:

from faker import Faker
from decimal import Decimal, InvalidOperation

fake = Faker("de_DE")

raw = fake.pydecimal(left_digits=7, right_digits=2, positive=True)
# raw is already a Python Decimal — safe so far

formatted = fake.numerify(f"{raw}")          # naive string interpolation
# formatted: "1234567.89"  — still fine in de_DE because Faker's
# numerify does NOT apply locale separators

# The real trap: using babel or locale.format_string
import locale
locale.setlocale(locale.LC_NUMERIC, "de_DE.UTF-8")
lossy = locale.format_string("%.2f", float(raw))  # "1.234.567,89"
reconstructed = Decimal(lossy.replace(".", "").replace(",", "."))
# One wrong replace order and you get 1234567.8 or InvalidOperation

The fix is a strict boundary rule: never convert a seed Decimal to a locale-formatted string and back. Keep the authoritative value as a decimal.Decimal (or a Pydantic Decimal field) throughout the pipeline. Only format for display — never for serialisation. When you must serialise to JSON, use a custom encoder:

import json
from decimal import Decimal

class DecimalSafeEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, Decimal):
            return str(obj)          # "1234567.89" — no locale, no rounding
        return super().default(obj)

fixture = {"price": Decimal("1234567.89"), "qty": Decimal("3.005")}
payload = json.dumps(fixture, cls=DecimalSafeEncoder)
# '{"price": "1234567.89", "qty": "3.005"}'

On the read side, use Pydantic v2 with model_config = ConfigDict(arbitrary_types_allowed=True) and annotate numeric fields as Decimal, not float. Pydantic will parse the string "1234567.89" back to Decimal("1234567.89") without touching LC_NUMERIC. This alone eliminated a class of fixture drift in a pipeline that was generating ~40 000 financial records per CI run — generation time stayed at 9 seconds; the previous float-round-trip approach took 14 seconds because it was re-querying mismatched rows to diagnose failures.

For Faker-based factories, patch at the provider level rather than post-processing. Faker's de_DE locale ships a numerify that is locale-neutral, but pyfloat returns a Python float — use pydecimal instead and pin the right_digits parameter explicitly. If you're generating seeds across distributed workers, also audit for locale collisions that corrupt Faker output when workers inherit different LC_NUMERIC settings from the host OS.

Precision Bugs That Slip Past Code Review

The most common mistake is asserting equality on a float representation of a seed that was generated as a Decimal. The seed Decimal("0.1") + Decimal("0.2") equals Decimal("0.3") exactly; cast either operand to float and you're back in IEEE 754 territory. This happens because factory_boy's LazyAttribute lambdas often do arithmetic in plain Python math, silently promoting Decimal to float mid-expression. The fix is to keep all arithmetic inside Decimal contexts and use pytest.approx only when you genuinely mean approximate — not as a blanket workaround for precision you don't understand.

A subtler issue is locale state leakage between tests. locale.setlocale() is process-global. A test that sets LC_NUMERIC to fr_FR to validate a display formatter and then fails mid-test leaves the process in French locale. The next test's seed generation inherits it. Use contextlib.contextmanager to restore locale state, or better, avoid locale.setlocale() in test processes entirely and use Babel's locale-aware functions with explicit locale arguments instead of relying on the global state.

Myths About Decimal Safety in Test Fixtures

Myth 1: "We use JSON Schema to validate our fixtures, so precision is guaranteed." JSON Schema 2020-12 validates that a value is of type: number and optionally within a range — it does not validate decimal precision. A value serialised as 1234568.0 (already rounded) passes {"type": "number", "minimum": 0}` without complaint. Schema validation catches shape, not numeric fidelity. Pair it with an explicit precision assertion in your seed verification step. Similarly, silent truncation in VARCHAR columns follows the same pattern: the DB accepts the value, the schema validates it, and the data is still wrong.

Myth 2: "Randomness from Faker covers enough decimal edge cases." Faker's pydecimal generates values in a uniform range — it won't reliably produce values that stress-test rounding boundaries like 0.005, 999999.995, or values that differ only in the last significant digit. For boundary coverage, use Hypothesis with a st.decimals() strategy and allow_nan=False, allow_infinity=False. Hypothesis will find the rounding boundary; Faker will not. Reserve Faker for realistic-looking bulk data and Hypothesis for precision edge-case discovery — they solve different problems.

Decimal precision loss through locale formatters is a deterministic bug wearing the costume of a flaky test. Audit every point in your seed pipeline where a Decimal touches a string formatter, enforce DecimalSafeEncoder for JSON serialisation, and replace pyfloat calls with pydecimal in your Faker factories. If you're also generating complex structured seeds with an LLM, review how context-aware generation with ChatGPT handles numeric types — most prompts return floats by default unless you explicitly specify decimal string output.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles