Skip to main content

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-migrations command alias has been removed. Use zdm. (alias zero-downtime-migrations=zdm in 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 found
  • 1 — lint violations found (errors). Warnings alone do NOT cause exit code 1 unless --warnings-as-errors is 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-step AddField(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):

  1. CLI flags (--select, --ignore, --warnings-as-errors)
  2. zero-downtime-migrations.toml found in the current directory or a trusted repo ancestor
  3. pyproject.toml [tool.zdm] section in the same directory
  4. 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 --check to 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_zdm-0.4.0.tar.gz (111.1 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

django_zdm-0.4.0-py3-none-win_arm64.whl (1.2 MB view details)

Uploaded Python 3Windows ARM64

django_zdm-0.4.0-py3-none-win_amd64.whl (1.3 MB view details)

Uploaded Python 3Windows x86-64

django_zdm-0.4.0-py3-none-manylinux_2_28_x86_64.whl (1.1 MB view details)

Uploaded Python 3manylinux: glibc 2.28+ x86-64

django_zdm-0.4.0-py3-none-manylinux_2_28_aarch64.whl (1.1 MB view details)

Uploaded Python 3manylinux: glibc 2.28+ ARM64

django_zdm-0.4.0-py3-none-macosx_11_0_arm64.whl (1.0 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

django_zdm-0.4.0-py3-none-macosx_10_12_x86_64.whl (1.1 MB view details)

Uploaded Python 3macOS 10.12+ x86-64

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

Hashes for django_zdm-0.4.0.tar.gz
Algorithm Hash digest
SHA256 1bf6a3632e36929a4384ac03af2b61605c6ce9d1a1416e1d3351aa1d35209a48
MD5 6a6f9da50dca983e57b0f99001d0b277
BLAKE2b-256 bb25913e038d064d7d3c0427dd7a9515a6105f3015ea5b6601ad0bc85371ac81

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_zdm-0.4.0.tar.gz:

Publisher: release.yml on Photoroom/zero-downtime-migrations

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for django_zdm-0.4.0-py3-none-win_arm64.whl
Algorithm Hash digest
SHA256 74c81d9dc83aa3497ca94f4e58301b5ac63db27b3ab8f126e58e9d29d149c4a9
MD5 20454d1c6042daddd1c52f5dfad86efa
BLAKE2b-256 f6ff3a81d4230be3ee01f104765a45978c7af3090d9126c30e4135e117efaa0c

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for django_zdm-0.4.0-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 0e2f071d56fa68fca5fb9a0554c8f29b0506c13a4bb9aea81ccfb0dfc2a15268
MD5 f328cbba65d39ee486df6ae38e8722f0
BLAKE2b-256 5a977e79d4a9a93c509378709aeeff64640f3eb2341f78face7dcca9131d5ce2

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_zdm-0.4.0-py3-none-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for django_zdm-0.4.0-py3-none-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 5ba89292f4e8417f6ad872d1514d03f0c3a2ece6473cf4bda7e67196dec5e1b3
MD5 f8a2a09c6240d6e266d34de5c7631806
BLAKE2b-256 8a3b4fb41f081d7f7e74838cd38d709d214381bc230b9bf81c9ee9272d8d84bc

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_zdm-0.4.0-py3-none-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for django_zdm-0.4.0-py3-none-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 23fbf263504172ef397ebdf47d677bbada89c22de71df0cbd71d8801c28f192e
MD5 8f019baa3445124a5354a7e170c848ef
BLAKE2b-256 5c125f2662e193dcadc7366ee26e4f631e0c57c63223e61cc68f9c98dbc8a919

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_zdm-0.4.0-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for django_zdm-0.4.0-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 4be716dfe526df4369f67f4b4927881efc8c935e996d71ab3095f5985949444f
MD5 c6e6143a3490cfa5ba0fc71975eeb5ed
BLAKE2b-256 a200d3934f5cc51b494949b5535a638efa70e8e70139aa84cc71586696622748

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_zdm-0.4.0-py3-none-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for django_zdm-0.4.0-py3-none-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 fa3d3ffe8dc07e771f997da8a66e401e4ed62468b2d25ed941995aff6d7dfaa9
MD5 7c4463105ef99c2f9e7fabe4cce0502d
BLAKE2b-256 0e13edf95d242ce2df8f55b64c3f7c5d95ab997d7ed02f004b93989468f07963

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page