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 adb_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 compatible—django-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
- 🔗 GitHub Repo: 3YOURMIND/django-migration-linter
- 📖 Incompatibility List: See all unsafe patterns
🔚 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!
Subscribe to my newsletter
Read articles from Mounir Messelmeni directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
