Skip to main content

Alembic Migration Linter

Detect backward incompatible database migrations for Alembic projects.

Based on the SQL analysis layer from django-migration-linter, adapted for Alembic's offline SQL rendering.

Installation

pip install alembic-migration-linter

Requires Python 3.11+ and an existing Alembic project with alembic.ini.

Quick Start

# Lint all migrations against PostgreSQL
alembic-lint --dialect postgresql

# Lint only changes since a revision (use a literal revision ID)
alembic-lint --since-revision abc1234567

# Exclude specific checks
alembic-lint --exclude-test ALTER_COLUMN

# Treat warnings as errors (CI-friendly)
alembic-lint --warnings-as-errors

Basic Usage

Lint a single revision:

alembic-lint --revision abc123

Use a non-default config file:

alembic-lint --config path/to/alembic.ini

Exclude noisy checks:

alembic-lint --exclude-test ALTER_COLUMN --exclude-test ADD_UNIQUE

Note: --revision and --since-revision require a literal revision ID (the value of the revision variable in the migration file). Symbolic references like head or head~5 are not supported.

Configuration

alembic.ini

Add a [linters] section to your alembic.ini to set defaults:

[alembic]
script_location = migrations

[linters]
dialect = postgresql
exclude_tests = ALTER_COLUMN
warnings_as_errors = true

CLI flags override config file values. For example, --dialect mysql on the command line will override dialect = postgresql in the config file.

Supported Config Keys

Key Type Default Description
dialect string postgresql Target database dialect
exclude_tests comma-separated list (none) Test codes to skip
warnings_as_errors true / false false Promote warnings to errors

Output Format

Each migration produces one line:

(0001_create_users)... OK
(0002_add_not_null)... ERR
    NOT_NULL
(0003_create_index)... WARNING
    CREATE_INDEX
(0004_data_fix)... IGNORE

A summary follows:

*** Summary ***
Valid migrations: 1/4
Erroneous migrations: 1/4
Migrations with warnings: 1/4
Ignored migrations: 1/4

Exit code is 0 when no errors are found, 1 when errors exist.

Incompatibility Rules

Errors (all dialects)

Code Trigger Why It Breaks Zero-Downtime
DROP_TABLE DROP TABLE Old app code references the table
DROP_COLUMN DROP COLUMN Old app code reads the column
RENAME_TABLE ALTER TABLE ... RENAME TO Old app code references old name
RENAME_COLUMN ALTER TABLE ... RENAME COLUMN Old app code references old name
ALTER_COLUMN ALTER COLUMN ... TYPE Type change may break old queries
NOT_NULL ADD COLUMN ... NOT NULL without default Old app inserts without the column
ADD_UNIQUE ADD CONSTRAINT ... UNIQUE Old app may have duplicate data

Warnings (PostgreSQL)

Code Trigger Impact
CREATE_INDEX CREATE INDEX without CONCURRENTLY Locks table during creation
CREATE_INDEX_EXCLUSIVE ALTER TABLE + CREATE INDEX in same transaction Prolongs exclusive lock
DROP_INDEX DROP INDEX without CONCURRENTLY Locks table during drop
REINDEX REINDEX Locks table during reindex

MySQL Notes

MySQL adds no warning-level rules. It refines the base ALTER_COLUMN error to also catch MySQL's ALTER TABLE ... MODIFY syntax, which rebuilds the table and blocks writes.

SQLite Notes

SQLite has limited ALTER TABLE support, so Alembic uses batch_alter_table which recreates tables. This triggers additional checks:

Code Behaviour
RENAME_TABLE Internal renames from batch_alter_table are excluded
DROP_TABLE Transaction-aware — detects drop + recreate patterns
NOT_NULL Rename-aware — accounts for table recreation during batch alter

These are modifications to the base rules, not separate codes. If batch_alter_table produces false positives, use --exclude-test to suppress them.

Safe Migration Patterns

Adding a NOT NULL Column

Two-step approach — add nullable first, then add default and set NOT NULL:

# Migration 1: add nullable column
def upgrade():
    op.add_column("users", sa.Column("status", sa.String(50), nullable=True))

# Migration 2: backfill and constrain
def upgrade():
    op.execute("UPDATE users SET status = 'active' WHERE status IS NULL")
    op.alter_column("users", "status", nullable=False, server_default="active")

Adding a Unique Constraint

Ensure no duplicates exist before adding the constraint:

# Migration 1: add index (non-unique) and backfill
def upgrade():
    op.create_index("idx_users_email", "users", ["email"])
    # Run a data migration to deduplicate

# Migration 2: add unique constraint
def upgrade():
    op.create_unique_constraint("uq_users_email", "users", ["email"])

Creating an Index (PostgreSQL)

Use raw SQL with CONCURRENTLY:

def upgrade():
    op.execute("CREATE INDEX CONCURRENTLY idx_users_email ON users (email)")

CI Integration

GitHub Actions

name: Migration Lint

on:
  pull_request:
    branches: [main]

jobs:
  lint-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install linter
        run: pip install alembic-migration-linter

      - name: Lint migrations
        run: alembic-lint --dialect postgresql --warnings-as-errors --no-cache

GitLab CI

lint-migrations:
  stage: test
  image: python:3.11-slim
  script:
    - pip install alembic-migration-linter
    - alembic-lint --dialect postgresql --warnings-as-errors --no-cache

CI Recommendations

  1. Use --no-cache — CI environments are ephemeral; caching adds no value and can mask issues
  2. Use --warnings-as-errors — catch locking issues before they reach production
  3. Use --quiet — suppress OK lines for cleaner logs when you only care about failures
  4. Lint against all target dialects — if you support PostgreSQL and MySQL, run the linter against both

Multi-dialect example:

strategy:
  matrix:
    dialect: [postgresql, mysql]

- name: Lint migrations
  run: alembic-lint --dialect ${{ matrix.dialect }} --warnings-as-errors --no-cache

Skipping Migrations

Some migrations are intentionally incompatible (e.g., initial schema setup, one-time data fixes). Skip them by matching the revision ID or filename.

CLI

# Skip specific revisions by ID
alembic-lint --ignore-revision initial_schema --ignore-revision data_fix_001

# Skip any migration whose revision ID or filename contains a substring
alembic-lint --ignore-revision-contains data_fix

Programmatic

from alembic_migration_linter import AlembicMigrationLinter

linter = AlembicMigrationLinter(
    config_path="alembic.ini",
    ignore_revisions=["initial_schema"],
    ignore_revision_contains="data_fix",
)

Both --ignore-revision and --ignore-revision-contains can be specified multiple times. The --ignore-revision-contains flag matches against both the revision ID and the filename. A migration file named 0001_skip_this_migration.py with revision "skip_this_migration" will be skipped by either --ignore-revision-contains skip or --ignore-revision-contains skip_this.

Troubleshooting

"Revision not found"

The revision ID must match the revision variable in the migration file exactly. Check with:

grep "^revision" migrations/versions/*.py

False positives on batch_alter_table

SQLite uses batch_alter_table which generates temporary table SQL. The analyser may flag internal operations. Exclude with --exclude-test if needed.

Raw SQL not detected

op.execute() produces SQL that is captured and analysed. If a raw SQL statement isn't being flagged, verify the SQL matches the analyser's regex patterns (check the incompatibility rules table).

Cache causing stale results

Use --no-cache to bypass the file cache, or clear it manually:

rm -rf ~/.cache/alembic-migration-linter/

License

New code: MIT

SQL analyser rules are from django-migration-linter (Apache-2.0), imported as a dependency — not copied.

Download files

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

Source Distribution

alembic_migration_linter-0.1.3.tar.gz (12.5 kB view details)

Uploaded Source

Built Distribution

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

alembic_migration_linter-0.1.3-py3-none-any.whl (12.8 kB view details)

Uploaded Python 3

File details

Details for the file alembic_migration_linter-0.1.3.tar.gz.

File metadata

  • Download URL: alembic_migration_linter-0.1.3.tar.gz
  • Upload date:
  • Size: 12.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for alembic_migration_linter-0.1.3.tar.gz
Algorithm Hash digest
SHA256 79a8d628f9caf3775cfd07bb6d91bbf13ffebd7f83ef2ae0e0241f280c77354e
MD5 c8d512bf97c22919ab96ec9ee66eff72
BLAKE2b-256 9e4336174cec05385a2d375dd1a3b5e7ff800362a1f41281a80e4c944ff0afcc

See more details on using hashes here.

Provenance

The following attestation bundles were made for alembic_migration_linter-0.1.3.tar.gz:

Publisher: publish.yml on Poogles/alembic-migration-linter

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

File details

Details for the file alembic_migration_linter-0.1.3-py3-none-any.whl.

File metadata

File hashes

Hashes for alembic_migration_linter-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 3d379ba61c7883887c13aea29170ae4cf567d7f53a553fb2566b410b65dce567
MD5 3e551fd542e12bcc6704a9ad06f6d024
BLAKE2b-256 afd8b06416a6a31d6b20a8bcfe31a4528adcd9a50ef3f18d58b4d15abcea39b4

See more details on using hashes here.

Provenance

The following attestation bundles were made for alembic_migration_linter-0.1.3-py3-none-any.whl:

Publisher: publish.yml on Poogles/alembic-migration-linter

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 Sentry Error logging StatusPage Status page