Skip to content

Risk Patterns

pytest-mrt detects 46 patterns across two categories.

Run static analysis instantly — no database needed:

mrt check migrations/versions/

How to read this page

Each pattern shows:

  • What the dangerous code looks like
  • What actually happens in production
  • How to fix it (when possible)

Error — will cause data loss or a broken rollback. Always fails the check.
Warning — worth reviewing before deploying. Use --strict to fail on these too.


Errors

DROP COLUMN in upgrade

def upgrade():
    op.drop_column("users", "phone")  # ✗

def downgrade():
    op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))

What happens: The downgrade re-adds the column structure, but all the phone numbers are gone. Users who called support to update their number? Gone. Rows that had phone IS NOT NULL? Now null. The column exists again but it's empty.

Fix: If you need to remove a column, do it in two separate deployments:

  1. First deploy: stop reading/writing the column in application code
  2. Second deploy: drop the column in a migration

This way rollback is safe because the app no longer depends on the column.


DROP TABLE in upgrade

def upgrade():
    op.drop_table("sessions")  # ✗

What happens: Every row is gone. Even if downgrade() recreates the table, it will be empty. Active user sessions, audit logs, queued jobs — all lost.

Fix: Same two-step approach. Archive the table first (ALTER TABLE sessions RENAME TO sessions_archived), verify nothing breaks, then drop.


TRUNCATE in migration

def upgrade():
    op.execute("TRUNCATE TABLE event_log")  # ✗

What happens: All data is destroyed with no undo. Unlike DROP TABLE, TRUNCATE cannot even be recreated empty — the data is simply gone.

Fix: Don't truncate in migrations. If you need to clear data for a cleanup migration, use DELETE FROM with a condition, and add the reverse operation in downgrade.


No-op downgrade

def upgrade():
    op.create_table("invitations", ...)

def downgrade():
    pass  # ✗

What happens: alembic downgrade -1 reports success. The alembic version table is decremented. But the invitations table still exists. Your next migration may try to create it again — and fail.

Fix: Always implement downgrade(). For every operation in upgrade(), there must be a corresponding reverse in downgrade().


Missing downgrade function

def upgrade():
    op.add_column("users", sa.Column("score", sa.Integer))

# no downgrade() at all  # ✗

What happens: alembic downgrade raises an AttributeError. The migration is permanently irreversible.


rename_table without reverse

def upgrade():
    op.rename_table("users", "accounts")  # ✗

def downgrade():
    pass

What happens: After rollback, the table is still called accounts. Your application code, ORM models, and any hardcoded SQL that references users will immediately start failing.

Fix:

def downgrade():
    op.rename_table("accounts", "users")  # reverse it


rename_column without reverse

def upgrade():
    op.alter_column("users", "name", new_column_name="full_name")  # ✗

def downgrade():
    pass

What happens: After rollback, the column is still full_name. Any code still using the old name name fails immediately.

Fix:

def downgrade():
    op.alter_column("users", "full_name", new_column_name="name")


DROP VIEW without reverse

def upgrade():
    op.execute("DROP VIEW active_users")  # ✗

def downgrade():
    pass

What happens: Any query, report, or BI tool that reads from active_users fails instantly after rollback.

Fix:

def downgrade():
    op.execute("""
        CREATE VIEW active_users AS
        SELECT * FROM users WHERE deleted_at IS NULL
    """)


ALTER TYPE ... ADD VALUE (PostgreSQL ENUM)

def upgrade():
    op.execute("ALTER TYPE user_status ADD VALUE 'suspended'")  # ✗

What happens: PostgreSQL cannot remove enum values. The moment any row is set to 'suspended', attempting to roll back will fail with a constraint error. There is no safe rollback path.

Fix: Use a VARCHAR with a CHECK constraint instead of a native ENUM type. Or accept that ENUM additions are permanent and plan accordingly.


Multi-step destructive migration

def upgrade():
    op.add_column("users", sa.Column("full_name", sa.String))
    op.execute("UPDATE users SET full_name = first_name || ' ' || last_name")
    op.drop_column("users", "first_name")  # ✗
    op.drop_column("users", "last_name")   # ✗

What happens: The first_name and last_name values are gone. If you need to roll back, downgrade() can recreate the columns but cannot reconstruct the original values.

Fix: Separate this into three migrations across three deployments:

  1. Add full_name column (nullable)
  2. Migrate data + update app code to write both
  3. Drop the old columns (only when confident in rollback window)

DROP COLUMN in batch_alter_table

def upgrade():
    with op.batch_alter_table("users") as batch_op:
        batch_op.drop_column("phone")  # ✗

op.batch_alter_table is SQLite's mechanism for schema changes (since SQLite doesn't support ALTER COLUMN directly). The same data loss rules apply — the column structure comes back in downgrade, but the values are gone.


Multiple heads (directory-level)

migrations/versions/
├── 001_create_users.py          down_revision = None
├── 002a_add_email.py            down_revision = '001'   ← branch A
└── 002b_add_phone.py            down_revision = '001'   ← branch B

What happens: When two developers create migrations independently from the same parent, Alembic doesn't know which order to run them in. alembic upgrade head will fail with Multiple head revisions until a merge migration is created.

Fix:

alembic merge heads -m "merge branches"

This creates a merge migration that resolves the conflict.


Warnings

NOT NULL without server_default

def upgrade():
    op.add_column("users", sa.Column("score", sa.Integer, nullable=False))  # ✗

What happens: If the table has any existing rows, this migration fails immediately — you can't add a NOT NULL column with no default when rows already exist (they'd violate the constraint).

Fix:

# Option 1: give it a default
op.add_column("users", sa.Column("score", sa.Integer, nullable=False, server_default="0"))

# Option 2: add as nullable, backfill, then add the constraint separately


Column type change

def upgrade():
    op.alter_column("users", "age", type_=sa.String(10))

Type conversions may be lossy. INTEGER → VARCHAR is usually safe. VARCHAR → INTEGER will fail if any values aren't numeric. Always verify the existing data is compatible before running.


Raw SQL (op.execute) without reverse

def upgrade():
    op.execute("UPDATE users SET tier = 'gold' WHERE spend > 10000")

def downgrade():
    op.drop_column("users", "tier")  # no corresponding UPDATE

pytest-mrt can't verify that a raw SQL statement is correctly reversed just by reading the code. This warning means: check manually that your downgrade undoes what the upgrade did.


Bulk UPDATE without reverse UPDATE

def upgrade():
    op.execute("UPDATE users SET name = UPPER(name)")

def downgrade():
    pass  # original casing is permanently gone

The data transformation only goes one way. Original values cannot be recovered.


ON DELETE CASCADE added

op.add_column("posts", sa.Column("user_id", sa.Integer,
    sa.ForeignKey("users.id", ondelete="CASCADE")))

Child rows are now silently deleted whenever the parent is deleted. This is often added accidentally and can cause unexpected mass deletions.


CREATE INDEX without CONCURRENTLY (PostgreSQL)

def upgrade():
    op.create_index("ix_users_email", "users", ["email"])  # ✗

Without CONCURRENTLY, PostgreSQL takes an ACCESS EXCLUSIVE lock on the table for the duration of the index build. On large tables, this blocks all reads and writes — causing downtime.

Fix:

op.create_index("ix_users_email", "users", ["email"], postgresql_concurrently=True)

Note: CONCURRENTLY cannot run inside a transaction, so Alembic's transaction wrapper needs to be disabled for this migration. See the Alembic docs for how to do this.


ADD COLUMN with DEFAULT (large tables, PostgreSQL < 11)

op.add_column("users", sa.Column("score", sa.Integer, server_default="0"))

On PostgreSQL < 11, adding a column with a non-null default rewrites the entire table. On a table with millions of rows, this can take minutes and hold an exclusive lock the entire time.

On PostgreSQL 11+, this is safe (optimized in the engine). Check your PostgreSQL version.


CREATE UNIQUE CONSTRAINT on existing data

def upgrade():
    op.create_unique_constraint("uq_users_email", "users", ["email"])

If any two rows already have the same email, this migration fails. Always check for duplicates before adding a unique constraint:

SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1;

DROP INDEX without recreating

def upgrade():
    op.drop_index("ix_users_email", table_name="users")

def downgrade():
    pass  # ✗ index not recreated

After rollback, the index is gone. Query performance degrades (full table scans) and any unique guarantee the index provided is also lost.


DROP CONSTRAINT without recreating

def upgrade():
    op.drop_constraint("fk_posts_user", "posts")

def downgrade():
    pass  # ✗ constraint not recreated

The data integrity guarantee is permanently removed after rollback. Future inserts may violate the constraint that used to exist.


ALTER SEQUENCE / setval

def upgrade():
    op.execute("ALTER SEQUENCE users_id_seq RESTART WITH 1000")

Sequences in PostgreSQL are not transactional — changes to a sequence are not rolled back even when the surrounding transaction is rolled back. After rollback, the sequence counter stays at 1000, causing ID gaps or potential collisions.


NOT NULL via raw SQL without reverse

def upgrade():
    op.execute("ALTER TABLE users ALTER COLUMN score SET NOT NULL")

def downgrade():
    pass  # ✗ NOT NULL not removed

After rollback, the column stays NOT NULL. Any application code that inserts with score=None starts failing.


NOT NULL without restoring nullable

def upgrade():
    op.alter_column("users", "score", nullable=False)

def downgrade():
    op.alter_column("users", "score")  # ✗ nullable not explicitly restored

The downgrade may leave score as NOT NULL, depending on database defaults, when it should have been nullable again.

Fix:

def downgrade():
    op.alter_column("users", "score", nullable=True)


DROP CONSTRAINT in batch_alter_table

def upgrade():
    with op.batch_alter_table("users") as batch_op:
        batch_op.drop_constraint("uq_users_email")  # ✗

def downgrade():
    pass

Constraint dropped inside SQLite's batch mode — downgrade must recreate it.


Django: Squashed migration patterns

MRT601 — Squashed migration: RunPython without reverse_code (error)

A squashed migration (one that has a replaces attribute) contains a RunPython operation with no reverse_code. Rolling back the squashed migration will silently do nothing — the data transformation from RunPython is not undone.

class Migration(migrations.Migration):
    replaces = [('myapp', '0005_a'), ('myapp', '0006_b')]
    operations = [
        migrations.RunPython(populate_data),  # ✗ no reverse_code
    ]

Fix: always supply a reverse_code:

migrations.RunPython(populate_data, reverse_code=undo_populate_data)
# or, if rollback is intentionally a no-op:
migrations.RunPython(populate_data, reverse_code=migrations.RunPython.noop)

Note: reverse_code can be the second positional argument — RunPython(forward, backward) is correctly detected as having a reverse.


MRT602 — Squashed migration: missing replaces list (warning)

A migration filename contains "squash" but declares no replaces attribute. Django uses replaces to know which original migrations this squash supersedes. Without it, Django may apply both the original migrations and the squashed one, leading to duplicate operations.

# filename: 0010_squashed_0001_0005.py
class Migration(migrations.Migration):
    dependencies = []
    operations = [...]
    # ✗ no replaces = [...]

Fix: add replaces listing the migrations this squash covers:

class Migration(migrations.Migration):
    replaces = [('myapp', '0001_initial'), ..., ('myapp', '0005_add_field')]
    dependencies = []
    operations = [...]

Rolling-deploy compatibility checks (MRT7xx)

These checks are opt-in via mrt check --check-compat. They flag operations that are unsafe during a rolling deploy — when the old app version and the new schema coexist briefly while instances are being replaced.

Run rollback-safety checks (mrt check) always. Add --check-compat when your deployment strategy involves rolling restarts rather than a full stop/start.

MRT701–MRT704 are supported for both Alembic and Django. The table below maps each code to the operation that triggers it in each backend. MRT705 (type change) is Alembic-only — Django's AlterField carries the full field definition with no reference to the previous type, so a type change cannot be detected statically.

Code Alembic Django
MRT701 op.drop_column migrations.RemoveField
MRT702 op.alter_column(new_column_name=…) migrations.RenameField
MRT703 op.drop_table migrations.DeleteModel / RenameModel / AlterModelTable
MRT704 op.add_column NOT NULL w/o server_default migrations.AddField NOT NULL w/o default
MRT705 op.alter_column(type_=…) — (Alembic only)

MRT701 — DROP COLUMN during rolling deploy (error)

Dropping a column (Alembic op.drop_column, Django RemoveField) immediately breaks any old app instance that still references it via ORM or raw SQL. The fix is a two-step process:

  1. Remove all code references to the column, then deploy the code change.
  2. Drop the column in a follow-up migration after all instances are on the new code.

MRT702 — RENAME COLUMN during rolling deploy (error)

Renaming a column (Alembic alter_column(new_column_name=…), Django RenameField) breaks old app instances referencing the old name. Use the expand-contract pattern: add the new column, backfill data, update code to use the new name, deploy, then drop the old column.

MRT703 — DROP TABLE during rolling deploy (error)

Dropping a table (Alembic drop_table, Django DeleteModel) crashes old app instances that query it. Remove all ORM models and direct references, deploy, then drop the table. For Django, RenameModel and AlterModelTable also fire MRT703 because they change the table name under the old app.

MRT704 — ADD NOT NULL column without default (error)

Adding a NOT NULL column without a default (Alembic server_default, Django default) causes INSERT failures from old app instances that don't know about the new column. Add a default to handle inserts from the old app, or make the column nullable initially.

MRT705 — Column type change during rolling deploy (warning, Alembic only)

Changing a column's type (Alembic alter_column(type_=…)) may cause type errors in old app instances. VARCHAR widening (e.g. VARCHAR(100) -> VARCHAR(255)) is generally safe. Type narrowing, kind changes (e.g. INTEGER -> BIGINT on some databases), or precision changes are not. Not detectable for Django (see note above).