Skip to content

fix: DecimalField no longer strips the scale it just quantized to - #2271

Open
dylanpulver wants to merge 1 commit into
tortoise:developfrom
dylanpulver:fix-decimal-scale
Open

fix: DecimalField no longer strips the scale it just quantized to#2271
dylanpulver wants to merge 1 commit into
tortoise:developfrom
dylanpulver:fix-decimal-scale

Conversation

@dylanpulver

@dylanpulver dylanpulver commented Sep 2, 2026

Copy link
Copy Markdown

DecimalField.to_python_value does Decimal(value).quantize(self.quant).normalize(). quantize
sets the scale to decimal_places; normalize on the same expression strips it again. So a
DecimalField(max_digits=12, decimal_places=2) holding 100.00 becomes Decimal('1E+2'):

to_python_value('100.00')   -> Decimal('1E+2')      str -> 1E+2      f'${...}' -> $1E+2
to_python_value('10')       -> Decimal('1E+1')      str -> 1E+1
to_python_value('1000000')  -> Decimal('1E+6')      str -> 1E+6
to_python_value('12.34')    -> Decimal('12.34')

Model.__init__ runs to_python_value on user input too, so it is written that way as well — the
SQLite column literally holds '1E+2' — and pydantic_model_creator then emits
{"id":1,"price":"1E+2"}.

I believe this is the cause of #1549 (open since 2024-01-23, reporter's example: created as
200.00, got 2E+2). The workaround on that thread attributes it to "how the decimal field is
saved in the database"; it reproduces with no database at all, from to_python_value alone — the
DB row is 1E+2 because tortoise wrote it that way.

Why the existing tests can't see it: tests/fields/test_decimal.py
asserts through obj.decimal == Decimal("…"), and Decimal.__eq__ compares numerically —
Decimal('1E+2') == Decimal('100.00') is True. The whole file passes with the defect live and
with it fixed. test_aggregate_sum_with_f_expression expects Decimal("4E+1") for forty, which is
the shape of a value transcribed from a run rather than written by hand. The new tests assert on
str() and on as_tuple().exponent instead.

Mutants. Reverting tortoise/fields/data.py against origin/develop and keeping the new tests
fails both of them. Dropping the quantize as well (the "stop transforming it" reading) fails 14
tests in test_decimal.py and 18 suite-wide — the quantize is load-bearing, only the normalize is
the defect.

Run: Python 3.14.7, sqlite 3.53.4, pytest1912 passed, 148 skipped, 2 xfailed, 2 failed.
The two failures are test_relations.py::test_recursive and test_version.py::test_version, both
of which fail identically on unmodified develop here (the package is not pip-installed, so
importlib.metadata has no version). ruff format --check and ruff check clean on the changed
files with ruff 0.15.4, the version in uv.lock. I did not run this against MySQL, Postgres,
MSSQL or Oracle — no servers here — so only the SQLite path is verified end to end; to_python_value
itself is backend-independent.

One self-correction worth stating: my first version of the new test expected 12.34512.35.
quantize uses the context rounding, ROUND_HALF_EVEN, so it is 12.34. My expectation was wrong,
not the code; the test now uses 12.3456 so it does not depend on the rounding mode.

AI assistance: found and drafted with Claude Code (Claude Opus 5, claude-opus-5), from a sweep for
field round trips that do not close, not from production use.

to_python_value() called .quantize(self.quant).normalize(): quantize sets the
scale to decimal_places and normalize immediately removes it again. A
DecimalField(max_digits=12, decimal_places=2) holding 100.00 came back as
Decimal('1E+2'), which is what str(), f-strings, json.dumps() and
pydantic_model_creator all render. Model.__init__ runs to_python_value on
user input too, so the value is written that way as well.

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant