<?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>Deploying Django with Azure DevOps: A Practical CI/CD Pipeline</title>
      <dc:creator>Josh Perspective</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:56:04 +0000</pubDate>
      <link>https://dev.to/joshperspective/deploying-django-with-azure-devops-a-practical-cicd-pipeline-3nb</link>
      <guid>https://dev.to/joshperspective/deploying-django-with-azure-devops-a-practical-cicd-pipeline-3nb</guid>
      <description>&lt;p&gt;Getting a Django app running locally is the easy part. Getting it deployed reliably with tests running automatically, secrets handled safely, migrations applied without downtime, and a rollback path when something goes wrong is where a lot of projects still rely on manual steps and crossed fingers. I've used Azure DevOps to own deployment on a Django platform serving both web and mobile clients, and here's the pipeline structure that's held up well.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the pipeline
&lt;/h2&gt;

&lt;p&gt;A solid CI/CD pipeline for Django breaks into distinct stages, each of which should fail loudly and stop the pipeline rather than letting a broken build limp forward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt; : install dependencies, run linting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test&lt;/strong&gt; : Run the test suite against a real database, not sqlite-in-memory shortcuts that hide production bugs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate&lt;/strong&gt; : apply database migrations safely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; : Push the new build to the target environment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; : A basic health check to confirm the deployment actually worked before calling it done&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what that looks like as an Azure Pipelines YAML file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# azure-pipelines.yml&lt;/span&gt;
&lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;

&lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vmImage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ubuntu-latest"&lt;/span&gt;

&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BuildAndLint&lt;/span&gt;
        &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UsePythonVersion@0&lt;/span&gt;
            &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;versionSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.12"&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;pip install -r requirements.txt&lt;/span&gt;
              &lt;span class="s"&gt;pip install flake8&lt;/span&gt;
              &lt;span class="s"&gt;flake8 .&lt;/span&gt;
            &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Install&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;dependencies&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lint"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
    &lt;span class="na"&gt;dependsOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RunTests&lt;/span&gt;
        &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;python manage.py test&lt;/span&gt;
            &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;test&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;suite"&lt;/span&gt;
            &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(TEST_DATABASE_URL)&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
    &lt;span class="na"&gt;dependsOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;succeeded()&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DeployToAzure&lt;/span&gt;
        &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production"&lt;/span&gt;
        &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;runOnce&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
                    &lt;span class="s"&gt;python manage.py migrate --noinput&lt;/span&gt;
                  &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Apply&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;migrations"&lt;/span&gt;
                  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$(PROD_DATABASE_URL)&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AzureWebApp@1&lt;/span&gt;
                  &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;azureSubscription&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$(AZURE_SERVICE_CONNECTION)"&lt;/span&gt;
                    &lt;span class="na"&gt;appType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webAppLinux"&lt;/span&gt;
                    &lt;span class="na"&gt;appName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$(APP_NAME)"&lt;/span&gt;
                    &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$(Pipeline.Workspace)/**/*.zip"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key structural decision here is &lt;code&gt;dependsOn&lt;/code&gt; and &lt;code&gt;condition: succeeded()&lt;/code&gt;; deployment simply cannot run if tests fail. This sounds obvious, but it's exactly the guardrail that gets skipped under deadline pressure if it's not built into the pipeline itself rather than left as a manual check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets: never in the YAML, never in the repo
&lt;/h2&gt;

&lt;p&gt;Database URLs, API keys for partner integrations, and Django's &lt;code&gt;SECRET_KEY&lt;/code&gt; should never appear directly in your pipeline YAML or in &lt;code&gt;settings.py&lt;/code&gt;. Azure DevOps has &lt;strong&gt;variable groups&lt;/strong&gt; and &lt;strong&gt;Azure Key Vault integration&lt;/strong&gt; specifically for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production-secrets"&lt;/span&gt;  &lt;span class="c1"&gt;# linked to an Azure Key Vault or secure variable group&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reference secrets in scripts as environment variables (as shown in the &lt;code&gt;env:&lt;/code&gt; blocks above), and mark them as "secret" in the variable group UI so they're masked in pipeline logs. If you're integrating with partner APIs, a payment provider, a bank, or anything handling KYC, this isn't optional. A leaked key in a build log is a real incident, not a hypothetical one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations: the part that actually needs the most care
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;migrate&lt;/code&gt; automatically in a deploy pipeline is convenient, but it's also the step most likely to cause real damage if done carelessly. A migration that locks a large table, or one that's not backward-compatible with the currently-running code during a rolling deployment, can cause an outage rather than prevent one.&lt;/p&gt;

&lt;p&gt;A few practices that matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Avoid migrations that both add a NOT NULL column and remove the old one in the same deploy&lt;/strong&gt;; split it into multiple deploys: add the column as nullable first, backfill data, then tighten the constraint in a later release. This avoids a window where old code (still running during a rolling deploy) breaks against the new schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run migrations as a separate step before the new application code goes live&lt;/strong&gt;, not simultaneously; the pipeline above does this by running &lt;code&gt;migrate&lt;/code&gt; before the &lt;code&gt;AzureWebApp&lt;/code&gt; deploy task, so the database is ready before traffic hits the new code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test migrations against a realistic copy of production data volume in staging&lt;/strong&gt;, not just a small test database; a migration that runs instantly on 1,000 rows can lock a table for minutes on a table with millions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Separate environments, separate pipelines (or stages)
&lt;/h2&gt;

&lt;p&gt;For a platform serving real users, a single "push to main, deploy to production" pipeline is risky. A staging environment mirroring production as closely as practical catches problems before they reach real users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DeployStaging&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DeployToStaging&lt;/span&gt;
        &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging"&lt;/span&gt;
        &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;runOnce&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AzureWebApp@1&lt;/span&gt;
                  &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;appName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$(STAGING_APP_NAME)"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DeployProduction&lt;/span&gt;
    &lt;span class="na"&gt;dependsOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DeployStaging&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;succeeded()&lt;/span&gt;
    &lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DeployToProduction&lt;/span&gt;
        &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production"&lt;/span&gt;
        &lt;span class="c1"&gt;# Azure DevOps environments support approval gates here&lt;/span&gt;
        &lt;span class="c1"&gt;# require manual sign-off before production deploy proceeds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Azure DevOps &lt;strong&gt;environments&lt;/strong&gt; support approval gates; you can require a manual approval step before the production stage runs, which is worth having on anything touching real user data or money, even if most of your pipeline is fully automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks and rollback readiness
&lt;/h2&gt;

&lt;p&gt;A deploy that "succeeds" according to the pipeline but leaves the app crash-looping isn't actually a successful deploy. Add a basic verification step after deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;curl -f https://$(APP_NAME).azurewebsites.net/healthz/ || exit 1&lt;/span&gt;
  &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Verify&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;deployment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair this with a simple &lt;code&gt;/healthz/&lt;/code&gt; Django view that checks database connectivity, not just that the process is running:&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.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&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;connections&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;connections&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And know your rollback path before you need it, whether that's Azure App Service's deployment slots (swap back to the previous slot instantly) or simply keeping the previous build artifact ready to redeploy. Deciding this during an incident, under pressure, is much worse than deciding it in advance.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Pipeline stages are ordered so deployment is gated on tests passing, not run in parallel or independently&lt;/li&gt;
&lt;li&gt;Secrets live in Azure Key Vault or secure variable groups, never in YAML or source code&lt;/li&gt;
&lt;li&gt;Migrations run as a distinct step before new code goes live, with backward-compatible migration patterns for zero-downtime deploys&lt;/li&gt;
&lt;li&gt;Staging exists and mirrors production closely enough to catch real issues&lt;/li&gt;
&lt;li&gt;Production deploys have an approval gate for anything touching money or user data&lt;/li&gt;
&lt;li&gt;A post-deploy health check verifies the app is actually working, not just that the deploy command exited successfully&lt;/li&gt;
&lt;li&gt;A rollback path is decided in advance, not improvised during an incident&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic, but each piece tends to get skipped under time pressure until the day a bad migration or a leaked secret makes it very clear why it mattered. Building it into the pipeline once means it's enforced every time, not just when someone remembers to check.&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>azure</category>
      <category>devops</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Josh Perspective</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:58:55 +0000</pubDate>
      <link>https://dev.to/joshperspective/-36a3</link>
      <guid>https://dev.to/joshperspective/-36a3</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/joshperspective/django-61s-fetchpeers-does-it-replace-selectrelated-and-prefetchrelated-14ok" class="crayons-story__hidden-navigation-link"&gt;Django 6.1's FETCH_PEERS: Does It Replace select_related and prefetch_related?&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/joshperspective" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081270%2Fbb4d55a0-db40-4767-9b69-aa993b3b01c8.jpg" alt="joshperspective profile" class="crayons-avatar__image" width="96" height="96"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/joshperspective" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Josh Perspective
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Josh Perspective
                
                
              
              &lt;div id="story-author-preview-content-4536906" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/joshperspective" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4081270%2Fbb4d55a0-db40-4767-9b69-aa993b3b01c8.jpg" class="crayons-avatar__image" alt="" width="96" height="96"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Josh Perspective&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/joshperspective/django-61s-fetchpeers-does-it-replace-selectrelated-and-prefetchrelated-14ok" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 31&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/joshperspective/django-61s-fetchpeers-does-it-replace-selectrelated-and-prefetchrelated-14ok" id="article-link-4536906"&gt;
          Django 6.1's FETCH_PEERS: Does It Replace select_related and prefetch_related?
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/python"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;python&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/django"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;django&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/joshperspective/django-61s-fetchpeers-does-it-replace-selectrelated-and-prefetchrelated-14ok" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/joshperspective/django-61s-fetchpeers-does-it-replace-selectrelated-and-prefetchrelated-14ok#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Django 6.1's FETCH_PEERS: Does It Replace select_related and prefetch_related?</title>
      <dc:creator>Josh Perspective</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:58:31 +0000</pubDate>
      <link>https://dev.to/joshperspective/django-61s-fetchpeers-does-it-replace-selectrelated-and-prefetchrelated-14ok</link>
      <guid>https://dev.to/joshperspective/django-61s-fetchpeers-does-it-replace-selectrelated-and-prefetchrelated-14ok</guid>
      <description>&lt;p&gt;A while back I wrote about hunting down and fixing N+1 queries in Django using &lt;code&gt;select_related&lt;/code&gt; and &lt;code&gt;prefetch_related&lt;/code&gt;. Django 6.1, released this August, adds something that goes after the same problem from a different angle: &lt;strong&gt;fetch modes&lt;/strong&gt;. If you haven't looked at this feature yet, here's what it actually does, and where it fits alongside the tools you're probably already using.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fetch modes are
&lt;/h2&gt;

&lt;p&gt;New in Django 6.1: when your code accesses a model field that wasn't loaded as part of the original query, Django fetches it from the database on demand. This has always been true. What's new is that you can now configure &lt;em&gt;how&lt;/em&gt; that on-demand fetch behaves, using &lt;code&gt;QuerySet.fetch_mode()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are three modes:&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="c1"&gt;# The existing behavior — fetch the missing field for this instance only
&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&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;FETCH_ONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# New — fetch the missing field for every instance from the same queryset, in one extra query
&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&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;FETCH_PEERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# New — raise an exception instead of fetching anything
&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&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;FETCH_RAISE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  FETCH_PEERS: an automatic, on-demand prefetch
&lt;/h2&gt;

&lt;p&gt;This is the headline feature, and it directly targets N+1 queries. Here's the classic problem again:&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="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# fires a separate query per book, without select_related
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;FETCH_PEERS&lt;/code&gt; set on the queryset, the first time any instance in the loop accesses &lt;code&gt;author&lt;/code&gt;, Django fetches that field for &lt;em&gt;every&lt;/em&gt; instance that came from the same queryset, not just the one being accessed, in a single additional query:&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="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&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;FETCH_PEERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1 query for books, 1 query for all authors = 2 total
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100 books goes from 101 queries down to 2, without you needing to know ahead of time which relations the template or serializer will touch. This is the meaningful difference from &lt;code&gt;select_related&lt;/code&gt;/&lt;code&gt;prefetch_related&lt;/code&gt;: those require you to declare, upfront, exactly which relations you intend to access. &lt;code&gt;FETCH_PEERS&lt;/code&gt; reacts to what's actually accessed, at runtime, and fixes the whole batch reactively.&lt;/p&gt;

&lt;p&gt;Fetch modes apply to foreign keys, one-to-one fields, fields deferred via &lt;code&gt;defer()&lt;/code&gt;/&lt;code&gt;only()&lt;/code&gt;, and generic relations, and the mode propagates down through related objects, so setting it once on a queryset applies to the whole tree of relationships it touches, not just the top level.&lt;/p&gt;

&lt;h2&gt;
  
  
  FETCH_RAISE: turning a silent N+1 into a loud failure
&lt;/h2&gt;

&lt;p&gt;The other genuinely useful mode is &lt;code&gt;FETCH_RAISE&lt;/code&gt;, which raises a &lt;code&gt;FieldFetchBlocked&lt;/code&gt; exception instead of fetching anything:&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="n"&gt;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_mode&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;FETCH_RAISE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;book&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises FieldFetchBlocked instead of quietly querying
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a guardrail for performance-critical code paths. The kind of endpoint where an accidental extra query per row is expensive enough that you'd rather the code fail loudly in development or CI than degrade quietly in production. If you've ever shipped an N+1 bug that only showed up once real traffic hit it, this is the tool that catches it before it ships, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  So does this replace select_related and prefetch_related?
&lt;/h2&gt;

&lt;p&gt;Not exactly, they solve overlapping but distinct problems, and I'd still reach for the explicit tools first in most cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;select_related&lt;/code&gt;/&lt;code&gt;prefetch_related&lt;/code&gt;&lt;/strong&gt; are declarative and upfront. You state exactly what you need, Django builds an efficient query (a JOIN for &lt;code&gt;select_related&lt;/code&gt;, a second query for &lt;code&gt;prefetch_related&lt;/code&gt;) for exactly that. This is still the right choice when you know your access patterns ahead of time, which, in a well-understood view or serializer, is most of the time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;FETCH_PEERS&lt;/code&gt;&lt;/strong&gt; is reactive and safer as a fallback than doing nothing. It's genuinely useful in code paths where the exact fields accessed vary; for example, a generic admin view, a flexible reporting endpoint, or third-party/reusable code where you can't predict every relation that'll get touched. It turns what would've been a silent N+1 into a much cheaper 2-query pattern, without requiring you to enumerate every relation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;FETCH_RAISE&lt;/code&gt;&lt;/strong&gt; is a testing and code-review tool as much as a runtime one-set it in tests or performance-sensitive views to make sure nothing is accidentally triggering per-row queries, and catch regressions before they ship.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One thing to check if you're upgrading
&lt;/h2&gt;

&lt;p&gt;If you have code using bare &lt;code&gt;select_related()&lt;/code&gt; with no arguments (which selects all non-nullable related fields), that usage is now deprecated in Django 6.1 in favor of either naming the fields explicitly or switching to &lt;code&gt;FETCH_PEERS&lt;/code&gt;. Worth a quick search through your codebase if you're planning the upgrade. This is an easy one to miss since bare &lt;code&gt;select_related()&lt;/code&gt; still works today, it just prints a deprecation warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical takeaway
&lt;/h2&gt;

&lt;p&gt;If you're on Django 6.1 or planning the upgrade, I wouldn't rip out existing &lt;code&gt;select_related&lt;/code&gt;/&lt;code&gt;prefetch_related&lt;/code&gt; calls, they're still the more efficient, explicit choice where you already know your access patterns. But &lt;code&gt;FETCH_PEERS&lt;/code&gt; is worth reaching for in the gaps: generic or flexible code paths where enumerating every relation upfront isn't practical, and &lt;code&gt;FETCH_RAISE&lt;/code&gt; is worth adding to tests on your highest-traffic endpoints as a tripwire against future N+1 regressions.&lt;/p&gt;

&lt;p&gt;Either way, this is a genuinely useful addition if N+1 queries have ever bitten you in production, it gives you a second line of defense that doesn't rely on remembering to audit every queryset by hand.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>django</category>
      <category>programming</category>
    </item>
    <item>
      <title>The N+1 Query Problem in Django: How to Spot It and Kill It Before It Kills Your API</title>
      <dc:creator>Josh Perspective</dc:creator>
      <pubDate>Thu, 20 Aug 2026 18:34:30 +0000</pubDate>
      <link>https://dev.to/joshperspective/the-n1-query-problem-in-django-how-to-spot-it-and-kill-it-before-it-kills-your-api-49kd</link>
      <guid>https://dev.to/joshperspective/the-n1-query-problem-in-django-how-to-spot-it-and-kill-it-before-it-kills-your-api-49kd</guid>
      <description>&lt;p&gt;There's a specific kind of bug that doesn't look like a bug at all. Your code works. Your tests pass. Everything returns the right data. Then you deploy, real users show up, and suddenly a page that loaded instantly in development takes three seconds in production or worse, times out entirely.&lt;/p&gt;

&lt;p&gt;Nine times out of ten, when this happens on a Django project, the culprit is the N+1 query problem. It's one of the most common performance issues in any ORM-based application, and it's especially easy to introduce without noticing, because the code that causes it often looks perfectly reasonable.&lt;/p&gt;

&lt;p&gt;I ran into this repeatedly while working on a platform with tens of thousands of users generating posts, comments, and messages, the kind of read-heavy, relationship-heavy data model where N+1 issues hide easily and get expensive fast once real traffic shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What N+1 actually means
&lt;/h2&gt;

&lt;p&gt;The name describes exactly what happens: 1 query to fetch a list of objects, then N additional queries, one per object to fetch related data. If you're rendering a list of 100 blog posts and looking up each post's author separately, that's 1 query for the posts and 100 more for the authors. 101 queries to render one page.&lt;/p&gt;

&lt;p&gt;Here's the classic example:&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;# views.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posts.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;posts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- posts.html --&amp;gt;&lt;/span&gt;
{% for post in posts %}
  &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;by {{ post.author.name }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
{% endfor %}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks completely normal. Nothing here is "wrong" in the sense of producing incorrect output. But every time the template accesses &lt;code&gt;post.author&lt;/code&gt;, Django fires a fresh query to fetch that author from the database, because &lt;code&gt;author&lt;/code&gt; is a foreign key that wasn't loaded up front. With 100 posts, that's 100 extra queries invisible in the code, very visible in your database load and response time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spotting it before your users do
&lt;/h2&gt;

&lt;p&gt;The scary part of N+1 is that it's silent in development. With 10 posts and a local SQLite database on your laptop, 11 queries execute in milliseconds and nobody notices. The problem only becomes visible at scale exactly when it's most expensive to discover.&lt;/p&gt;

&lt;p&gt;A few ways to catch it early:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Django Debug Toolbar&lt;/strong&gt; shows you the exact number of queries executed per page, and will explicitly flag duplicate/similar queries, which is usually the fingerprint of an N+1 pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;django-silk&lt;/code&gt; or query logging&lt;/strong&gt; useful in staging environments where Debug Toolbar isn't installed, to log query counts per request and catch regressions before they hit production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A simple habit&lt;/strong&gt;: any time you loop over a queryset and access a related object or reverse relation inside that loop, stop and ask whether that relation is being loaded eagerly. This single habit catches the majority of N+1 bugs before they're even written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it: &lt;code&gt;select_related&lt;/code&gt; for forward relations
&lt;/h2&gt;

&lt;p&gt;For foreign key and one-to-one relationships, use &lt;code&gt;select_related&lt;/code&gt;. It performs a SQL JOIN and pulls the related object into the same query, rather than firing a separate query per row.&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="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;post.author&lt;/code&gt; is already loaded when the template accesses it, no extra query per post, just the one JOIN-based query up front. This works for "forward" relationships, where the model you're querying holds the foreign key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it: &lt;code&gt;prefetch_related&lt;/code&gt; for reverse and many-to-many relations
&lt;/h2&gt;

&lt;p&gt;For reverse foreign keys and many-to-many relationships, &lt;code&gt;select_related&lt;/code&gt;'s JOIN approach doesn't work the same way; instead, use &lt;code&gt;prefetch_related&lt;/code&gt;, which runs a second, separate query for the related objects and joins them in Python rather than in SQL.&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="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;{% for post in posts %}
  &lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;{{ post.comments.count }} comments&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
{% endfor %}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs 2 queries total, regardless of how many posts there are: one for the posts, one for all the related comments, matched up in Python. Compare that to N+1 queries without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining both, and going deeper with &lt;code&gt;Prefetch&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Real-world querysets often need both at once:&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="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comments&lt;/span&gt;&lt;span class="sh"&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;tags&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;For more complex cases — say, you only want to prefetch approved comments, not all of them — use the &lt;code&gt;Prefetch&lt;/code&gt; object to customize the prefetch queryset itself:&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.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Prefetch&lt;/span&gt;

&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Prefetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queryset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approved&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the query count low while still letting you filter, order, or annotate the related data exactly as needed, useful in a comments/messaging-heavy system where you often only want a subset of related rows, not all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch for N+1 hiding in serializers, not just templates
&lt;/h2&gt;

&lt;p&gt;If you're building an API with Django REST Framework, the same problem shows up in serializers, and it's arguably easier to miss because there's no template to visually inspect.&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;PostSerializer&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;author_name&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;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author.name&lt;/span&gt;&lt;span class="sh"&gt;"&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;Post&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;title&lt;/span&gt;&lt;span class="sh"&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;author_name&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;If the view serving this serializer doesn't call &lt;code&gt;select_related("author")&lt;/code&gt; on the queryset, every serialized post triggers a separate query for &lt;code&gt;author.name&lt;/code&gt; the exact same N+1 pattern, just one layer removed from where it's easy to spot. This is especially worth checking in nested serializers, where a list endpoint returning related objects (comments, tags, likes) is a very common place for N+1 to hide in an API-first project.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick way to verify you've actually fixed it
&lt;/h2&gt;

&lt;p&gt;Don't just assume &lt;code&gt;select_related&lt;/code&gt;/&lt;code&gt;prefetch_related&lt;/code&gt; fixed things verify with Django's query logging in a shell:&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;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reset_queries&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.conf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;  &lt;span class="c1"&gt;# only in a dev/test shell, never production
&lt;/span&gt;&lt;span class="nf"&gt;reset_queries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;prefetch_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# should be a small, fixed number, not proportional to len(posts)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the query count scales with the number of posts, something's still triggering N+1 usually a relation you forgot to include in &lt;code&gt;select_related&lt;/code&gt;/&lt;code&gt;prefetch_related&lt;/code&gt;, or a nested relation two levels deep that needs its own prefetch.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Any loop over a queryset that accesses a related object or reverse relation is checked for eager loading&lt;/li&gt;
&lt;li&gt;Forward foreign key / one-to-one access uses &lt;code&gt;select_related&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reverse foreign key / many-to-many access uses &lt;code&gt;prefetch_related&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Complex prefetch filtering uses &lt;code&gt;Prefetch&lt;/code&gt; objects rather than filtering in Python after the fact&lt;/li&gt;
&lt;li&gt;DRF serializers are checked for the same pattern, not just templates especially nested serializers&lt;/li&gt;
&lt;li&gt;Query counts are verified with Debug Toolbar or &lt;code&gt;connection.queries&lt;/code&gt; before shipping, not just assumed fixed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;N+1 queries rarely show up as an obvious bug, they show up as "the app feels slow" or "the database CPU spiked" days or weeks after a feature shipped. Catching the pattern while writing the code, rather than debugging it under production load, is a lot cheaper for the database and for you.&lt;/p&gt;

</description>
      <category>python</category>
      <category>django</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <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>
