Removing trailing zeros from decimal columns in Django
Django’s DecimalField always shows all decimal places, even zeros after the decimal place. How do we remove them?
In Django we use DecimalField
to represent a numerical column where precision is important. When declaring a column of this type, the parameters max_digits
and decimal_places
are required, as they are used to build the SQL typesNUMERICAL(12, 2)
or DECIMAL(12, 2)
, for example.
This works great for currency values, because when accessing this column in Django, you will get values like: Decimal('120.00')
or Decimal('67.30')
. But sometimes you need more decimal places, that when displayed in your application, they will look something like: 120.0000
or 67.3000
. This is especially true in your admin site — users would probably expect a round number to be presented as “120”, and decimal places only up until where it is relevant, like “67.3”.
The Python documentation for Decimal shows a nice way to strip a Decimal value to its relevant part:
def remove_exponent(d):
return d.quantize(Decimal(1)) if d == d.to_integral() else d.normalize()
>>> remove_exponent(Decimal('100'))
Decimal('100')
>>> remove_exponent(Decimal('100.0000'))
Decimal('100')
>>> remove_exponent(Decimal('1E+2'))
Decimal('100')
>>> remove_exponent(Decimal('0'))
Decimal('0')
>>> remove_exponent(Decimal('100.1000'))
Decimal('100.1')
>>> remove_exponent(Decimal('0.1540'))
Decimal('0.154')
But how do we make that the default representation of a DecimalField?
Subclasses of the base class django.db.models.fields.Field
may have a method called from_db_value()
which basically prepares a value obtained from the backend to a Python representation. We can make use of this in two ways:
1. Subclass DecimalField
This is the nice solution but it has a drawback: Django will detect a change in all your existing Decimal fields, and will create a migration for them. Also it will not apply to third-party apps, in case they have Decimal fields — and this may or may not be what you want.
from decimal import Decimal
from django.db.models import DecimalField
class StrippedDecimalField(DecimalField):
def from_db_value(self, value, expression, connection):
"""Strips trailing zeros after the decimal point."""
# It could be None or the backend could load something else
if isinstance(value, Decimal):
return remove_exponent(value) # see above
return value
Then, in your models, you would change the declaration of the fields:
class MyModel(models.Model):
# from: rate = models.DecimalField(max_digits=12, decimal_places=4)
rate = StrippedDecimalField(max_digits=12, decimal_places=4)
The class DecimalField
does not implement from_db_value()
so there is no need to call super()
(in fact you can’t do it).
2. Monkey-patch DecimalField
Monkey-patching is seldom the best option, but as the Zen of Python says, “practicality beats purity”. When monkey-patching the class, Django will not make migrations for your existing Decimal fields, and the change will apply to all the apps in your project, even third-party ones.
from decimal import Decimal
from django.db.models import DecimalField
def decimal_from_db_value(self, value, expression, connection):
if isinstance(value, Decimal):
return remove_exponent(value) # see above
return value
DecimalField.from_db_value = decimal_from_db_value
By doing either of these, your should automatically see the changes in the admin, and anywhere else you present the number without formatting.
One small caveat
…Except in Django admin’s change lists and read-only fields. Static values do not change after the fix above because the column values are not direct string representations of the Python objects, but rather controlled by the function display_for_field()
in django.contrib.admin.utils
.
def display_for_field(value, field, empty_value_display):
...
elif isinstance(field, models.DecimalField):
return formats.number_format(value, field.decimal_places)
...
So in order to modify that behavior, we have to make our hands a bit dirtier. We will monkey-patch a global function — but that means we have to know where to patch it. Let’s make a new function.
def monkey_patch_display_for_field():
"""Replaces Django's display_for_field() function to strip zeros
after the decimal separator.
Handling for all other fields is deferred to original function.
"""
from django.utils import formats
from django.db.models import DecimalField
from django.contrib.admin.utils import display_for_field as django_display_for_field
def new_display_for_field(value, field, empty_value_display):
if isinstance(field, DecimalField): # or your new class
return formats.number_format(value, decimal_pos=None)
return django_display_for_field(value, field, empty_value_display)
# Monkey-patch display_for_field() in the places it is imported
# NOTE: this may change in future versions of Django
import django.contrib.admin.helpers
django.contrib.admin.helpers.display_for_field = new_display_for_field
import django.contrib.admin.templatetags.admin_list
django.contrib.admin.templatetags.admin_list.display_for_field = new_display_for_field
Now all you have to do is call the function above during initialization — for example, right after monkey-patching DecimalField. The function simply nullifies the argument decimal_pos
, which controls the number of digits after the decimal separator.
Let’s say you want to keep currency values with 2 digits. You may set your own rules to define when keep that number, for example:
...
if isinstance(field, DecimalField):
decimal_pos = 2 if field.decimal_places == 2 else None
return formats.number_format(value, decimal_pos=decimal_pos)
...
There’s no telling what is currency and what’s not, so you may set your own rules instead of the example above.
As of version 4.1, those are the only places that import and use display_for_field()
, but in the future we may have to patch other places. You cannot patch that function directly in django.contrib.admin.utils, since other modules have already imported them.
Have you ever experienced any of this? Tell us how it went.