Zero Down Time DB migrations with Django

Avoiding Downtime in Django Migrations with django-migration-linter

Deploying Django apps with zero downtime is essential in modern production systems, especially when rolling out updates on Kubernetes or deploying worker-based systems incrementally. However, even a simple migration, like adding a new field, can silently disrupt your system if not handled carefully.

In this post, we’ll explore how django-migration-linter helps detect backward-incompatible migrations before they reach production—and how to effectively integrate it into your Django project and CI pipeline.


🚨 Migrations Are More Dangerous Than They Look

Django makes database migrations easy—but that simplicity can be deceptive. Let’s say you add a new field like this:

# models.py
class Order(models.Model):
    status = models.CharField(max_length=50, default="draft")

You run makemigrations, commit, and deploy. Everything seems fine, right?

Wrong.

Django's default= only applies at the Python level, not in the database itself. If your application uses old code that tries to write to this model without populating the new field, and there's no database-level default (db_default), your code can crash with a NOT NULL constraint failed.

This becomes especially dangerous during rolling deployments where both the old code and new schema run simultaneously.

Scenario:

  • Migration is applied, a new column is created without a DB default
  • A user tries to create a new row via the backend (still running old code)
  • The backend doesn’t know about the new field and doesn’t send it
  • The DB rejects the insertion → crash

⚠️ Common Pitfalls in Zero-Downtime Migrations

Here are a few common operations that seem innocent but can cause serious trouble during deployments:

  • ❌ Adding a NOT NULL field without a db_default
  • ❌ Renaming a field or table (breaks old code)
  • ❌ Dropping a field still used by old workers
  • ❌ Changing field types or constraints directly

To see a full list of potentially incompatible operations, check out this fantastic incompatibility reference.


🔧 What is django-migration-linter?

django-migration-linter is a Django app that scans your migrations and flags operations that are not backward compatible. It acts as a safety net to ensure that every migration you write won’t break live systems when old and new code run side-by-side.

✅ What it catches:

  • Dangerous field additions
  • Dropped fields or indexes
  • Renames and type changes
  • Constraint modifications

It’s especially useful in CI pipelines and rolling deployments.


📦 Installation

Install django-migration-linter as part of your project dependencies (not just in CI):

pip install django-migration-linter

Then, add it to your INSTALLED_APPS in settings.py:

INSTALLED_APPS += ["django_migration_linter"]

Optional Configuration

You can further customize the linter in settings.py:

MIGRATION_LINTER_OPTIONS = {
    # Only check migrations added since this commit (optional for legacy projects)
    "git_commit_id": "SOMECOMMITHASH",
    "project_root_path": BASE_DIR,
}

🧪 Linting Migrations in Your Project

Run the migration linter using Django's management command:

python manage.py lintmigrations

This will lint all migrations unless you configure it to only check those added since a specific Git commit (useful in legacy codebases).


🚀 Why This Matters in Kubernetes and Rolling Deployments

Let’s visualize a common deployment pattern:

sequenceDiagram
    participant Dev as Developer
    participant CI as CI/CD
    participant K8s as Kubernetes
    participant DB as Database

    Dev->>CI: Push code with migration
    CI->>DB: Apply migration
    CI->>K8s: Start rolling update
    K8s->>App: Some pods use old code
    K8s->>App: Some pods use new code
    App->>DB: Old code reads/writes old schema
    App->>DB: New code reads/writes new schema
    Note over DB: Must support both code versions

Of course, this is a simplified view (not involving GitOps), but our main goal is to demonstrate the possible issues.

Also note the time gap between the migration being applied and the start of the deployment. Even without rolling updates, your system may run old code against the new schema for some time. This is why every schema change must be backward compatibledjango-migration-linter helps enforce that rule.


⚙️ CI Integration: GitHub Actions and GitLab CI

You can block unsafe migrations in your CI by running the linter after installing your Django app.

✅ GitHub Actions

name: Check Django Migrations

on: [pull_request]

jobs:
  lint-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: 3.13
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
      - name: Run Migration Linter
        run: |
          python manage.py lintmigrations --no-cache

✅ GitLab CI

lint_migrations:
  image: python:3.13
  stage: lint
  script:
    - pip install -r requirements.txt
    - python manage.py lintmigrations --no-cache
  interruptible: true

This ensures that only new migrations are linted on pull requests or merge requests. You can also configure your pipeline to skip the job if no migration files were added or updated.


📚 Resources


🔚 Conclusion

Schema changes are deceptively simple in Django, but their impact can be critical in a live environment. django-migration-linter helps you enforce safety by detecting migration operations that aren’t backward compatible—before you deploy.

✅ Add it to your project
✅ Run it locally
✅ Enforce it in CI

Your users won’t even notice you migrated.


🧩 Have you used django-migration-linter in production? Share your thoughts or lessons learned in the comments!

1
Subscribe to my newsletter

Read articles from Mounir Messelmeni directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mounir Messelmeni
Mounir Messelmeni