Skip to content

Quick start

This guide will walk you through setting up alembic-gauntlet for your project.

Prerequisites

  • Python 3.10+
  • PostgreSQL database
  • An async PostgreSQL driver (asyncpg)
  • Existing Alembic migrations
  • SQLAlchemy ORM models

Installation

Install alembic-gauntlet with the asyncio extra:

pip install "alembic-gauntlet[asyncio]"

The extra pulls in pytest-asyncio. The tests you inherit and the fixtures that feed them are plain async def, so without it your suite errors out — see Configure pytest for the one setting it needs.

For automatic PostgreSQL container management with Testcontainers:

pip install "alembic-gauntlet[testcontainers]"

Basic setup

1. Create a test file

Create a new file in your tests directory (e.g., tests/migrations/test_migrations.py):

import pytest
from alembic_gauntlet import MigrationTestBase
from sqlalchemy import MetaData
from myapp.db import Base  # Your SQLAlchemy declarative base


@pytest.mark.integration
class TestMyMigrations(MigrationTestBase):
    """Migration tests for myapp."""

    @pytest.fixture
    def orm_metadata(self) -> MetaData:
        """Provide ORM metadata for schema consistency checks."""
        return Base.metadata

    @pytest.fixture(scope="session")
    def migration_db_url(self) -> str:
        """Provide database URL for migrations."""
        return "postgresql+asyncpg://user:pass@localhost:5432/testdb"

That's it! You now have 7 tests automatically:

  • test_stairway_upgrade_downgrade — each migration forward and back
  • test_migrations_up_to_date — schema matches ORM models
  • test_check_constraints_match — CHECK constraints match ORM models, by name
  • test_enum_values_match — enum values match ORM models, in order
  • test_single_head_revision — no unmerged branches
  • test_downgrade_all_the_way — full downgrade to base
  • test_naming_conventions — indexes and FKs follow conventions

2. Configure pytest

Turn on pytest-asyncio's auto mode and add the integration marker:

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
markers = [
    "integration: marks tests as integration tests (require database)",
]

Or in pytest.ini:

[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
markers =
    integration: marks tests as integration tests (require database)

asyncio_mode is a requirement, not a preference. The inherited tests and the migration_engine and isolated_migration_schema fixtures are declared with plain @pytest.fixture and no asyncio marker; under the default strict mode the tests fail with async def functions are not natively supported and the fixtures arrive unawaited.

3. Run the tests

Run all migration tests:

pytest tests/migrations/ -v

Or run only integration tests:

pytest -m integration -v

Expected output

When tests pass, you'll see:

tests/migrations/test_migrations.py::TestMyMigrations::test_stairway_upgrade_downgrade PASSED
tests/migrations/test_migrations.py::TestMyMigrations::test_migrations_up_to_date PASSED
tests/migrations/test_migrations.py::TestMyMigrations::test_check_constraints_match PASSED
tests/migrations/test_migrations.py::TestMyMigrations::test_enum_values_match PASSED
tests/migrations/test_migrations.py::TestMyMigrations::test_single_head_revision PASSED
tests/migrations/test_migrations.py::TestMyMigrations::test_downgrade_all_the_way PASSED
tests/migrations/test_migrations.py::TestMyMigrations::test_naming_conventions PASSED

What each test does

test_stairway_upgrade_downgrade

Stairway test — ensures every migration can upgrade and downgrade without errors.

This test: 1. Gets all revisions from Alembic 2. For each revision: - Upgrades to that revision - Downgrades one step - Upgrades again

Catches: - Migrations that work forward but fail on downgrade - Missing downgrade operations - Non-idempotent migrations

test_migrations_up_to_date

Schema consistency check — ensures your migrations match your ORM models.

This test: 1. Runs all migrations to HEAD 2. Compares database schema with ORM metadata 3. Reports any differences

Catches: - Forgot to run alembic revision --autogenerate - Model changes not reflected in migrations - Drift between database and code

Does not catch what compare_metadata() does not compare: server defaults, unless you set migration_diff_compare_server_default = True (see Configuration for which spellings agree), CHECK constraints and enum values. The next two tests cover those.

test_check_constraints_match

CHECK constraint check — ensures every CHECK constraint your models declare exists.

This test: 1. Runs all migrations to HEAD 2. Resolves each named CheckConstraint in your metadata to the name the DDL would give it 3. Compares those names with the constraints on each table

Catches: - A CHECK constraint the model declares and no migration created - A CHECK constraint left in the database after the model dropped it - A migration that named the constraint differently from the model

Unnamed constraints are not compared, and expressions are never compared — PostgreSQL rewrites them. Name every CHECK constraint, ideally through a ck naming convention.

test_enum_values_match

Enum value check — ensures every enum type has the values your models have.

This test: 1. Runs all migrations to HEAD 2. Reads every enum type from the database 3. Compares the values of each native Enum column with the type's labels, in order

Catches: - A value added to the model and never added with ALTER TYPE ... ADD VALUE - A value the migration created that the model does not have - A value added in the wrong position

test_single_head_revision

Branch detection — ensures no unmerged migration branches.

This test: 1. Checks Alembic revision tree 2. Ensures only one HEAD revision exists

Catches: - Multiple developers creating migrations on different branches - Merge conflicts in migration history - Unmerged feature branches

test_downgrade_all_the_way

Full downgrade test — ensures complete rollback works.

This test: 1. Upgrades to HEAD 2. Downgrades all the way to base 3. Verifies no errors

Catches: - Incomplete downgrade operations - Missing foreign key drops - Orphaned database objects

test_naming_conventions

Naming convention validation — ensures consistent naming.

This test: 1. Runs migrations to HEAD 2. Inspects all indexes and foreign keys 3. Validates names match conventions

Catches: - Inconsistent index naming (idx_ vs _idx) - Foreign keys without proper suffix (_fkey) - Unconventional constraint names

Default conventions:

Object Allowed prefixes Allowed suffixes
Index idx_, uq_ _idx, _pkey, _key
Foreign key fk_ _fkey
CHECK constraint chk_ —
UNIQUE constraint uq_ —
PRIMARY KEY pk_ _pkey

If your MetaData carries a naming_convention, the rules are derived automatically — no class attributes needed. See Configuration for details.

Using Testcontainers

For automatic PostgreSQL container management, install the testcontainers extra and import the migration_db_url fixture it ships into your conftest.py:

# tests/conftest.py
from alembic_gauntlet.contrib.testcontainers import migration_db_url  # noqa: F401

Your test class then only needs orm_metadata — the fixture supplies the database:

import pytest
from alembic_gauntlet import MigrationTestBase


@pytest.mark.integration
class TestMyMigrations(MigrationTestBase):
    """Migrations with automatic PostgreSQL container."""

    @pytest.fixture
    def orm_metadata(self):
        from myapp.db import Base
        return Base.metadata

The fixture is session-scoped, so one postgres:17-alpine container:

  • starts once per test session
  • yields its connection URL rewritten to postgresql+asyncpg://
  • is stopped when the session ends

No manual database setup required. Override migration_db_url in your own conftest to point at a different database.

Troubleshooting

Test fails: "Schema differences detected"

Your migrations are out of sync with ORM models. Run:

alembic revision --autogenerate -m "sync models"

Test fails: "CHECK constraints are out of sync"

A named CHECK constraint is in your models and not in the database, or the other way round. The message names it. Add the migration, or name the constraint the same way on both sides — under a ck naming convention, op.create_check_constraint("amount_positive", ...) resolves to the same name as the model.

Test fails: "Enum values are out of sync"

The message shows the values in the database and in the model. Add a migration with op.execute("ALTER TYPE order_status ADD VALUE 'shipped'"), positioned with BEFORE/AFTER where the model has it.

Test fails: "Multiple head revisions"

You have unmerged migration branches. Merge them:

alembic merge -m "merge heads" head_1 head_2

Test fails on downgrade

Your migration is missing downgrade operations. Edit the migration file and add proper downgrade() logic.

Connection errors

Verify database URL and credentials:

@pytest.fixture(scope="session")
def migration_db_url(self) -> str:
    # Check this URL is correct
    return "postgresql+asyncpg://user:pass@localhost:5432/testdb"

Next steps