A PostgreSQL migration safety linter for Django
Project description
zero-downtime-migrations (zdm)
A PostgreSQL migration safety linter for Django.
Why
Deploying database migrations without downtime requires careful attention to how PostgreSQL acquires locks. Operations like adding an index, altering a column to NOT NULL, or adding a foreign key can lock tables for extended periods on large datasets, blocking reads and writes and causing outages. zdm statically analyzes Django migration files to catch these unsafe patterns before they reach production, helping teams ship schema changes safely during normal deployments.
What
A standalone Rust CLI tool that statically analyzes Django migration files to catch unsafe patterns that cause table locks, outages, and data loss on large PostgreSQL databases. Distributed like ruff/uv — a single fast binary, installable via pip, uvx, or standalone download.
Supports Django 3.2+ — zdm parses migration files directly without importing Django, so it works with any Django version and doesn't require Django to be installed.
Installation
Breaking change: the
zero-downtime-migrationscommand alias has been removed. Usezdm. (alias zero-downtime-migrations=zdmin your shell is a one-line workaround if you depended on the old name.)
# Install via pip (PyPI package is django-zdm; binary is `zdm`)
pip install django-zdm
# Or use uvx to run without installing
uvx --from django-zdm zdm .
# Or install with pipx
pipx install django-zdm
Usage
# Lint a single migration
zdm app/migrations/0042_add_index.py
# Lint all migrations in a directory
zdm app/migrations/
# Lint all migrations in the project
zdm .
# Diff mode: lint changed migrations in a PR
zdm --diff origin/main
# Staged diff mode: lint changes being committed by pre-commit
zdm --diff-staged origin/main
# Output formats
zdm --output-format json .
zdm --output-format compact .
# Select/ignore specific rules
zdm --select R001,R003 .
zdm --ignore R008 .
# Show explanation for a rule
zdm rule R001
# List every rule the binary recognises
zdm --list-rules
# Treat warnings as errors
zdm --warnings-as-errors .
--diff compares the merge base to HEAD and reads file contents from the
HEAD tree, giving deterministic PR/CI results even when the worktree is
dirty. --diff-staged compares the same merge base to the index and reads the
staged blobs.
Exit Codes
0— no issues found1— lint violations found (errors). Warnings alone do NOT cause exit code 1 unless--warnings-as-errorsis set.2— tool error (bad arguments, config parse failure, invalid file path)
JSON Output Schema
zdm --output-format json writes a single JSON object to stdout:
{
"diagnostics": [
{
"rule_id": "R001",
"rule_name": "non-concurrent-add-index",
"severity": "error",
"message": "Use AddIndexConcurrently instead of AddIndex …",
"path": "app/migrations/0001_bad.py",
"line": 8,
"column": 9,
"help": "Replace migrations.AddIndex with …"
}
],
"summary": { "total": 1, "errors": 1, "warnings": 0 }
}
severity is "error" or "warning". help is null when the
rule has no help text. The schema is pinned by the integration
test suite — every field above is guaranteed on every diagnostic.
Rules
| Rule | Name | Severity | Description |
|---|---|---|---|
| R001 | non-concurrent-add-index | Error | Use AddIndexConcurrently instead of AddIndex |
| R002 | unique-constraint-without-index | Error | Unique constraints should have a concurrent index |
| R003 | runsql-create-index | Error | Use AddIndexConcurrently instead of raw SQL CREATE INDEX |
| R004 | missing-atomic-false | Error | Non-atomic migrations require atomic = False |
| R005 | remove-field-without-separate | Error | Use SeparateDatabaseAndState to remove fields safely |
| R006 | add-field-foreign-key | Error | Adding FK creates index and validates constraint (merged R007) |
| R008 | disallowed-file-changes | Error | Don't change app code alongside migrations |
| R009 | separate-db-state-same-pr | Error | Don't deploy both steps of SeparateDatabaseAndState together |
| R010 | add-field-not-null | Error | Adding NOT NULL field without default rewrites table |
| R011 | rename-field | Error | Renaming fields can break running code |
| R012 | irreversible-run-python | Warning | RunPython should have a reverse function |
| R013 | irreversible-run-sql | Warning | RunSQL should have a reverse SQL |
| R014 | model-imports | Error | Don't import models in RunPython |
| R015 | alter-field-not-null | Warning | AlterField whose result is NOT NULL may scan every row |
| R016 | non-concurrent-remove-index | Error | Use RemoveIndexConcurrently instead of RemoveIndex |
| R017 | non-concurrent-add-constraint | Error | CHECK constraint validates all rows; EXCLUDE constraint builds an index non-concurrently |
CreateModel Exemption
Several rules (R001, R002, R006, R010, R016, R017) automatically exempt operations that target models created in the same migration. This is because operations on newly created (empty) tables don't cause the locking issues these rules detect. The exemption is order-aware—a CreateModel that runs after the flagged op cannot retroactively exempt it—and follows RenameModel when the fresh table is renamed before a later operation.
Note: R007 (
fk-without-concurrent-index) was merged into R006 and retired. R006 now takes the conservative stance that a prebuilt concurrent index does not make a one-stepAddField(ForeignKey)safe on an existing table. Split the rollout instead of relying on an index exemption.
For example, this migration will NOT trigger R001:
class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name='Order',
fields=[('id', models.AutoField(primary_key=True))],
),
migrations.AddIndex( # Exempt: 'order' was just created above
model_name='order',
index=models.Index(fields=['created_at'], name='order_idx'),
),
]
R015 Limitation
R015 (alter-field-not-null) cannot tell, from a single AlterField operation, whether the column was previously nullable. It flags any AlterField whose resulting field is NOT NULL, which catches a genuine nullable→NOT NULL transition (the dangerous case) alongside benign re-stipulations of an already-NOT-NULL column. Because static analysis has no schema history, the rule emits Warning rather than Error — surfaced for review without breaking CI. Add # zdm: ignore R015 on operations you have verified are safe.
Inline Suppression
You can silence specific rules on a per-operation basis with a comment:
operations = [
# zdm: ignore R001
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['created_at'], name='order_idx'),
),
migrations.AlterField( # zdm: ignore R015, R010
model_name='product',
name='sku',
field=models.CharField(max_length=50),
),
]
The comment can sit on the line just above the operation or on the same line as any line in the operation's range. Multiple rule IDs may be listed, separated by commas.
Configuration
Configure via pyproject.toml or zero-downtime-migrations.toml:
[tool.zdm]
select = ["R001", "R002"]
ignore = ["R008"]
warnings-as-errors = false
allowed-file-patterns = ["*.txt", "*.md", "models.py"]
exclude = ["**/test_migrations/**"]
Configuration Precedence
Settings are applied in this order (highest to lowest priority):
- CLI flags (
--select,--ignore,--warnings-as-errors) zero-downtime-migrations.tomlfound in the current directory or a trusted repo ancestorpyproject.toml[tool.zdm]section in the same directory- Default values
The config search starts in the current working directory. On Unix, if zdm is running inside a trusted git repository, it walks upward within that repository and stops at the first directory that contains zero-downtime-migrations.toml or a pyproject.toml with [tool.zdm]. The nearest .git is always the boundary; an untrusted boundary never falls through to an outer repository. A pyproject for another tool is ignored, so running zdm from repo/apps/myapp/migrations/ still picks up repo/zero-downtime-migrations.toml. Without a trusted .git ancestor—and on Windows, where zdm cannot yet validate repository ACL ownership—only the current directory is checked. Config inputs must be regular UTF-8 files no larger than 1 MiB.
CLI flags always override config file settings. If both zero-downtime-migrations.toml and pyproject.toml exist in the same directory, the standalone file takes precedence; multi-level merging is not performed.
Pre-commit Integration
Install zdm in the environment where pre-commit runs, then call that installed
binary from your .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: zdm
name: zdm
entry: zdm
language: system
types: [python]
files: .*/migrations/.*\.py$
exclude: __init__\.py$
Or use diff mode to only check changed migrations:
repos:
- repo: local
hooks:
- id: zdm-diff
name: zdm diff
entry: zdm --diff-staged origin/main
language: system
pass_filenames: false
always_run: true
The zdm-diff hook uses --diff-staged so it checks the staged index that
pre-commit is validating, rather than the previous HEAD commit.
The repository also publishes source-based pre-commit hooks for users who prefer
repo: https://github.com/Photoroom/zero-downtime-migrations with
rev: <latest release tag>. Those hooks install the package from source, so
Rust must be available in the pre-commit environment.
GitHub Actions
- uses: actions/checkout@v4
with:
# --diff needs the base ref and enough history to compute a merge base.
fetch-depth: 0
- name: Install zdm
run: pip install django-zdm
- name: Lint migrations
run: zdm --diff origin/main
Rust library API
The Rust crate exposes a small programmatic API, but it remains experimental
while the project is in the 0.x series. Prefer Migration::from_path or
Migration::from_source, Config, and the built-in rule registries. Low-level
parser, extractor, diagnostic-construction, discovery, and git helpers may
change between minor 0.x releases.
Comparison with Other Tools
| zdm | django-migration-linter | Django's makemigrations --check |
|
|---|---|---|---|
| Requires Django installed | No | Yes | Yes |
| Requires project setup | No | Yes (settings.py) | Yes (full environment) |
| Checks for missing migrations | No | No | Yes |
| Checks for unsafe operations | Yes (16 active rules; zdm --list-rules) |
Yes (~8 rules) | No |
Configurable via pyproject.toml |
Yes (walks up within trusted repos) | Yes | N/A |
| Can run without database | Yes | Yes | No |
| Language | Rust | Python | Python |
When to use what:
- Use
makemigrations --checkto ensure all model changes have migrations - Use zdm or django-migration-linter to catch unsafe migration patterns
- zdm is useful when you want to run checks in CI without setting up Django, or when you need the additional rules (NOT NULL alterations, RenameField, irreversible migrations, RemoveIndex)
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_zdm-0.4.0.tar.gz.
File metadata
- Download URL: django_zdm-0.4.0.tar.gz
- Upload date:
- Size: 111.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1bf6a3632e36929a4384ac03af2b61605c6ce9d1a1416e1d3351aa1d35209a48
|
|
| MD5 |
6a6f9da50dca983e57b0f99001d0b277
|
|
| BLAKE2b-256 |
bb25913e038d064d7d3c0427dd7a9515a6105f3015ea5b6601ad0bc85371ac81
|
Provenance
The following attestation bundles were made for django_zdm-0.4.0.tar.gz:
Publisher:
release.yml on Photoroom/zero-downtime-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_zdm-0.4.0.tar.gz -
Subject digest:
1bf6a3632e36929a4384ac03af2b61605c6ce9d1a1416e1d3351aa1d35209a48 - Sigstore transparency entry: 2138283845
- Sigstore integration time:
-
Permalink:
Photoroom/zero-downtime-migrations@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Photoroom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_zdm-0.4.0-py3-none-win_arm64.whl.
File metadata
- Download URL: django_zdm-0.4.0-py3-none-win_arm64.whl
- Upload date:
- Size: 1.2 MB
- Tags: Python 3, Windows ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
74c81d9dc83aa3497ca94f4e58301b5ac63db27b3ab8f126e58e9d29d149c4a9
|
|
| MD5 |
20454d1c6042daddd1c52f5dfad86efa
|
|
| BLAKE2b-256 |
f6ff3a81d4230be3ee01f104765a45978c7af3090d9126c30e4135e117efaa0c
|
Provenance
The following attestation bundles were made for django_zdm-0.4.0-py3-none-win_arm64.whl:
Publisher:
release.yml on Photoroom/zero-downtime-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_zdm-0.4.0-py3-none-win_arm64.whl -
Subject digest:
74c81d9dc83aa3497ca94f4e58301b5ac63db27b3ab8f126e58e9d29d149c4a9 - Sigstore transparency entry: 2138283873
- Sigstore integration time:
-
Permalink:
Photoroom/zero-downtime-migrations@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Photoroom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_zdm-0.4.0-py3-none-win_amd64.whl.
File metadata
- Download URL: django_zdm-0.4.0-py3-none-win_amd64.whl
- Upload date:
- Size: 1.3 MB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0e2f071d56fa68fca5fb9a0554c8f29b0506c13a4bb9aea81ccfb0dfc2a15268
|
|
| MD5 |
f328cbba65d39ee486df6ae38e8722f0
|
|
| BLAKE2b-256 |
5a977e79d4a9a93c509378709aeeff64640f3eb2341f78face7dcca9131d5ce2
|
Provenance
The following attestation bundles were made for django_zdm-0.4.0-py3-none-win_amd64.whl:
Publisher:
release.yml on Photoroom/zero-downtime-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_zdm-0.4.0-py3-none-win_amd64.whl -
Subject digest:
0e2f071d56fa68fca5fb9a0554c8f29b0506c13a4bb9aea81ccfb0dfc2a15268 - Sigstore transparency entry: 2138283882
- Sigstore integration time:
-
Permalink:
Photoroom/zero-downtime-migrations@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Photoroom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_zdm-0.4.0-py3-none-manylinux_2_28_x86_64.whl.
File metadata
- Download URL: django_zdm-0.4.0-py3-none-manylinux_2_28_x86_64.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5ba89292f4e8417f6ad872d1514d03f0c3a2ece6473cf4bda7e67196dec5e1b3
|
|
| MD5 |
f8a2a09c6240d6e266d34de5c7631806
|
|
| BLAKE2b-256 |
8a3b4fb41f081d7f7e74838cd38d709d214381bc230b9bf81c9ee9272d8d84bc
|
Provenance
The following attestation bundles were made for django_zdm-0.4.0-py3-none-manylinux_2_28_x86_64.whl:
Publisher:
release.yml on Photoroom/zero-downtime-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_zdm-0.4.0-py3-none-manylinux_2_28_x86_64.whl -
Subject digest:
5ba89292f4e8417f6ad872d1514d03f0c3a2ece6473cf4bda7e67196dec5e1b3 - Sigstore transparency entry: 2138283854
- Sigstore integration time:
-
Permalink:
Photoroom/zero-downtime-migrations@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Photoroom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_zdm-0.4.0-py3-none-manylinux_2_28_aarch64.whl.
File metadata
- Download URL: django_zdm-0.4.0-py3-none-manylinux_2_28_aarch64.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3, manylinux: glibc 2.28+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
23fbf263504172ef397ebdf47d677bbada89c22de71df0cbd71d8801c28f192e
|
|
| MD5 |
8f019baa3445124a5354a7e170c848ef
|
|
| BLAKE2b-256 |
5c125f2662e193dcadc7366ee26e4f631e0c57c63223e61cc68f9c98dbc8a919
|
Provenance
The following attestation bundles were made for django_zdm-0.4.0-py3-none-manylinux_2_28_aarch64.whl:
Publisher:
release.yml on Photoroom/zero-downtime-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_zdm-0.4.0-py3-none-manylinux_2_28_aarch64.whl -
Subject digest:
23fbf263504172ef397ebdf47d677bbada89c22de71df0cbd71d8801c28f192e - Sigstore transparency entry: 2138283888
- Sigstore integration time:
-
Permalink:
Photoroom/zero-downtime-migrations@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Photoroom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_zdm-0.4.0-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: django_zdm-0.4.0-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 1.0 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4be716dfe526df4369f67f4b4927881efc8c935e996d71ab3095f5985949444f
|
|
| MD5 |
c6e6143a3490cfa5ba0fc71975eeb5ed
|
|
| BLAKE2b-256 |
a200d3934f5cc51b494949b5535a638efa70e8e70139aa84cc71586696622748
|
Provenance
The following attestation bundles were made for django_zdm-0.4.0-py3-none-macosx_11_0_arm64.whl:
Publisher:
release.yml on Photoroom/zero-downtime-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_zdm-0.4.0-py3-none-macosx_11_0_arm64.whl -
Subject digest:
4be716dfe526df4369f67f4b4927881efc8c935e996d71ab3095f5985949444f - Sigstore transparency entry: 2138283900
- Sigstore integration time:
-
Permalink:
Photoroom/zero-downtime-migrations@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Photoroom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_zdm-0.4.0-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: django_zdm-0.4.0-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fa3d3ffe8dc07e771f997da8a66e401e4ed62468b2d25ed941995aff6d7dfaa9
|
|
| MD5 |
7c4463105ef99c2f9e7fabe4cce0502d
|
|
| BLAKE2b-256 |
0e13edf95d242ce2df8f55b64c3f7c5d95ab997d7ed02f004b93989468f07963
|
Provenance
The following attestation bundles were made for django_zdm-0.4.0-py3-none-macosx_10_12_x86_64.whl:
Publisher:
release.yml on Photoroom/zero-downtime-migrations
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_zdm-0.4.0-py3-none-macosx_10_12_x86_64.whl -
Subject digest:
fa3d3ffe8dc07e771f997da8a66e401e4ed62468b2d25ed941995aff6d7dfaa9 - Sigstore transparency entry: 2138283868
- Sigstore integration time:
-
Permalink:
Photoroom/zero-downtime-migrations@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/Photoroom
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f991e9b33cdec61e3aef44434126f00305a8e6fa -
Trigger Event:
push
-
Statement type: