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.1.tar.gz (12.4 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.1-py3-none-any.whl (12.7 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: alembic_migration_linter-0.1.1.tar.gz
  • Upload date:
  • Size: 12.4 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.1.tar.gz
Algorithm Hash digest
SHA256 0a6ef32d69f4dbb7af8fe32080266d34cefbc32e0d115f624d3ba15d797f26e2
MD5 b807ffd407ff6fa5fd9e2024d5886fe5
BLAKE2b-256 86088b273741558205bbd7a32d286c8ee56de06d2faef8ab3b60742c374ae430

See more details on using hashes here.

Provenance

The following attestation bundles were made for alembic_migration_linter-0.1.1.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.1-py3-none-any.whl.

File metadata

File hashes

Hashes for alembic_migration_linter-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e5df50c130764834f71c81f35c758cad7bf83e7c2849010ee33495ad10241541
MD5 6a60b3572775f39f11b2cf134977d485
BLAKE2b-256 30b44bc456b5fbfd3fbfac6a65a11c49e84c2a58c2c797d35f0360c1c16e21ab

See more details on using hashes here.

Provenance

The following attestation bundles were made for alembic_migration_linter-0.1.1-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