DEV Community

Cover image for Handling Money Correctly in Django: A Guide to Decimals, Precision, and the Mistakes That Cost You
Josh Perspective
Josh Perspective

Posted on

Handling Money Correctly in Django: A Guide to Decimals, Precision, and the Mistakes That Cost You

If you've ever built a feature that touches money, loan repayments, wallet balances, invoice totals, you've probably run into a subtle but expensive class of bugs: numbers that don't quite add up. A balance that's off by a cent. A total that rounds differently depending on which server processed it. These bugs rarely show up in development. They show up in production, in an audit, or in a support ticket from a confused user staring at a number that should be exact but isn't.

I ran into this directly while building the financial features on a platform integrating bank account opening and business loan repayment, real money, real KYC, real consequences for getting it wrong. Here's what I learned about doing it properly in Django.

The core problem: floats are not safe for money

The first mistake almost everyone makes at some point is storing monetary values as floats.

>>> 0.1 + 0.2
0.30000000000000004
Enter fullscreen mode Exit fullscreen mode

This isn't a Python quirk it's how binary floating-point numbers work in every language that uses IEEE 754. The number 0.1 simply can't be represented exactly in binary, the same way 1/3 can't be represented exactly in decimal. For most use cases this rounding error is invisible. For money, where users expect exact arithmetic and every kobo or cent matters, it's unacceptable.

The fix is to never use FloatField for currency. Use Django's DecimalField instead.

from django.db import models

class LoanRepayment(models.Model):
    amount = models.DecimalField(max_digits=12, decimal_places=2)
Enter fullscreen mode Exit fullscreen mode

DecimalField stores values using Python's Decimal type, which represents numbers exactly rather than approximating them in binary. 0.1 + 0.2 as Decimals gives you exactly 0.3, every time.

Choosing max_digits and decimal_places deliberately

It's tempting to guess at these values, but they matter more than they look. max_digits is the total number of digits stored (before and after the decimal point combined), and decimal_places is how many of those are after the point.

For most currency fields, decimal_places=2 is standard most currencies (NGN, USD, GBP) use two decimal places. But if you're dealing with interest calculations, foreign exchange, or any system doing intermediate calculations before rounding to a final amount, consider storing more precision internally (e.g., decimal_places=4 or higher) and only rounding to 2 decimal places at the point of display or final settlement. Rounding too early compounds errors across many transactions, something that matters a lot in a loan repayment system where interest accrues over time.

class LoanAccount(models.Model):
    principal = models.DecimalField(max_digits=14, decimal_places=2)
    interest_rate = models.DecimalField(max_digits=6, decimal_places=4)  # e.g. 0.0525 for 5.25%
Enter fullscreen mode Exit fullscreen mode

Always use Decimal in Python code, never float

This is the mistake that gets people even after they've correctly set up DecimalField in their models. It's easy to accidentally reintroduce floats in application code:

# Wrong — mixes float and Decimal, will raise a TypeError or silently misbehave
amount = loan.principal * 1.05

# Correct
from decimal import Decimal
amount = loan.principal * Decimal("1.05")
Enter fullscreen mode Exit fullscreen mode

Django will actually raise a TypeError if you try to multiply a Decimal by a float directly, which is a helpful guardrail but it's still easy to introduce floats upstream, especially when values come from external APIs (like a partner bank's account-opening or lending API) as JSON, where numbers often arrive as floats or strings.

The safe pattern is to convert incoming values immediately, and always via string, not directly from a float:

from decimal import Decimal

# If the API returns a string ideal
amount = Decimal(response_data["amount"])  # "1050.75" -> Decimal("1050.75")

# If the API returns a float, convert via str() first, never Decimal(float) directly
raw = response_data["amount"]  # 1050.75 as a float
amount = Decimal(str(raw))
Enter fullscreen mode Exit fullscreen mode

Decimal(1050.75) (passing a float directly) will silently inherit the float's imprecision you'll get something like Decimal('1050.7499999999999857891452847979962825775146484375'). Converting through a string avoids that entirely.

Rounding: be explicit, and be consistent

Financial systems often need specific rounding rules round half up, round half to even (banker's rounding), always round down for fees, etc. Python's default Decimal rounding is "round half to even," which is often not what a finance team expects.

Be explicit using the quantize method:

from decimal import Decimal, ROUND_HALF_UP

def round_currency(value: Decimal) -> Decimal:
    return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Enter fullscreen mode Exit fullscreen mode

Pick a rounding strategy deliberately, usually in consultation with whoever owns the compliance or accounting side of the business and apply it consistently everywhere money gets rounded. Inconsistent rounding between, say, the loan calculation service and the repayment display is exactly the kind of bug that surfaces as "why doesn't my balance match what I was charged."

Serialization: Django REST Framework and decimals

If you're exposing these fields through an API (which is likely if you're serving both a web and mobile client from the same backend), DRF's DecimalField serializer needs configuring too, or you'll get inconsistent output between environments.

from rest_framework import serializers

class LoanRepaymentSerializer(serializers.ModelSerializer):
    amount = serializers.DecimalField(max_digits=12, decimal_places=2, coerce_to_string=True)

    class Meta:
        model = LoanRepayment
        fields = ["amount"]
Enter fullscreen mode Exit fullscreen mode

Setting coerce_to_string=True (the DRF default) returns the value as a string in the JSON response rather than a native JSON number. This is deliberate: JSON doesn't have a native decimal type, and many JSON parsers (including JavaScript's) parse numeric literals as floats, silently reintroducing the exact problem you avoided on the backend. Returning a string forces the client to explicitly parse it as a decimal type, which is exactly the friction you want here.

Database-level considerations

Beyond the Django model layer, it's worth checking that your actual database column type matches your intent. DecimalField in Django maps to DECIMAL or NUMERIC in most SQL databases (MySQL, PostgreSQL), which store the value as an exact fixed-point number rather than an approximation, this is what makes the whole approach work. If you're ever writing raw SQL or migrations by hand, keep the same precision and scale (DECIMAL(12,2)) as your Django field definition, since a mismatch here can silently truncate values on insert.

A short checklist

If you're building or reviewing a financial feature in Django, it's worth running through:

  • All monetary fields use DecimalField, never FloatField
  • max_digits/decimal_places are chosen deliberately, with extra precision retained for intermediate calculations if needed
  • All arithmetic in Python code uses Decimal, with explicit conversion via string for any values coming from external APIs
  • Rounding is explicit (quantize with a chosen rounding mode) and applied consistently across the codebase
  • API serializers return decimals as strings, not native JSON numbers
  • Database column types match Django field precision, especially in raw SQL or hand-written migrations

None of this is exotic, it's mostly about being deliberate rather than letting defaults or convenience quietly reintroduce imprecision. But in a system handling real repayments and real account balances, that deliberateness is the difference between a system users trust and one that generates support tickets every time a number doesn't quite add up.

Top comments (0)