<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Josh Perspective</title>
    <description>The latest articles on DEV Community by Josh Perspective (@joshperspective).</description>
    <link>https://dev.to/joshperspective</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081270%2Fbb4d55a0-db40-4767-9b69-aa993b3b01c8.jpg</url>
      <title>DEV Community: Josh Perspective</title>
      <link>https://dev.to/joshperspective</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joshperspective"/>
    <language>en</language>
    <item>
      <title>Handling Money Correctly in Django: A Guide to Decimals, Precision, and the Mistakes That Cost You</title>
      <dc:creator>Josh Perspective</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:41:11 +0000</pubDate>
      <link>https://dev.to/joshperspective/handling-money-correctly-in-django-a-guide-to-decimals-precision-and-the-mistakes-that-cost-you-3pjn</link>
      <guid>https://dev.to/joshperspective/handling-money-correctly-in-django-a-guide-to-decimals-precision-and-the-mistakes-that-cost-you-3pjn</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem: floats are not safe for money
&lt;/h2&gt;

&lt;p&gt;The first mistake almost everyone makes at some point is storing monetary values as floats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
&lt;span class="mf"&gt;0.30000000000000004&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix is to never use &lt;code&gt;FloatField&lt;/code&gt; for currency. Use Django's &lt;code&gt;DecimalField&lt;/code&gt; instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoanRepayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecimalField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_digits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimal_places&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Choosing &lt;code&gt;max_digits&lt;/code&gt; and &lt;code&gt;decimal_places&lt;/code&gt; deliberately
&lt;/h2&gt;

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

&lt;p&gt;For most currency fields, &lt;code&gt;decimal_places=2&lt;/code&gt; 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., &lt;code&gt;decimal_places=4&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoanAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecimalField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_digits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimal_places&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;interest_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecimalField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_digits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimal_places&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. 0.0525 for 5.25%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Always use &lt;code&gt;Decimal&lt;/code&gt; in Python code, never &lt;code&gt;float&lt;/code&gt;
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong — mixes float and Decimal, will raise a TypeError or silently misbehave
&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.05&lt;/span&gt;

&lt;span class="c1"&gt;# Correct
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;
&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.05&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Django will actually raise a &lt;code&gt;TypeError&lt;/code&gt; if you try to multiply a &lt;code&gt;Decimal&lt;/code&gt; by a &lt;code&gt;float&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The safe pattern is to convert incoming values immediately, and always via string, not directly from a float:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="c1"&gt;# If the API returns a string ideal
&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# "1050.75" -&amp;gt; Decimal("1050.75")
&lt;/span&gt;
&lt;span class="c1"&gt;# If the API returns a float, convert via str() first, never Decimal(float) directly
&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# 1050.75 as a float
&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Rounding: be explicit, and be consistent
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Decimal&lt;/code&gt; rounding is "round half to even," which is often not what a finance team expects.&lt;/p&gt;

&lt;p&gt;Be explicit using the &lt;code&gt;quantize&lt;/code&gt; method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROUND_HALF_UP&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;round_currency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;rounding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ROUND_HALF_UP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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."&lt;/p&gt;

&lt;h2&gt;
  
  
  Serialization: Django REST Framework and decimals
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;DecimalField&lt;/code&gt; serializer needs configuring too, or you'll get inconsistent output between environments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rest_framework&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoanRepaymentSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModelSerializer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serializers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DecimalField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_digits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimal_places&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coerce_to_string&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LoanRepayment&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;coerce_to_string=True&lt;/code&gt; (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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database-level considerations
&lt;/h2&gt;

&lt;p&gt;Beyond the Django model layer, it's worth checking that your actual database column type matches your intent. &lt;code&gt;DecimalField&lt;/code&gt; in Django maps to &lt;code&gt;DECIMAL&lt;/code&gt; or &lt;code&gt;NUMERIC&lt;/code&gt; 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 (&lt;code&gt;DECIMAL(12,2)&lt;/code&gt;) as your Django field definition, since a mismatch here can silently truncate values on insert.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist
&lt;/h2&gt;

&lt;p&gt;If you're building or reviewing a financial feature in Django, it's worth running through:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>webdev</category>
      <category>fintech</category>
    </item>
  </channel>
</rss>
