Skip to content
Back to skills

Alembic Best Practices

ASecurity

Alembic migration patterns for naming conventions, safety checks, expand-contract, env.py configuration, and CI integration

  • 43 stars
  • 0 votes
  • 1 copy
  • 12 views
  • Added September 9, 2026
databasespythonrustsqlnodetestinggitapidatabasebackend

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 9, 2026

npx -y skills add baekenough/oh-my-customcode --skill alembic-best-practices --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Alembic Best Practices?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Alembic Best Practices
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/baekenough-alembic-best-practices-oh-my-customcode/badge)](https://www.skillsdirectory.com/skills/baekenough-alembic-best-practices-oh-my-customcode)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: alembic-best-practices
description: Alembic migration patterns for naming conventions, safety checks, expand-contract, env.py configuration, and CI integration
scope: core
version: 1.0.0
user-invocable: false
---

# Alembic Best Practices

Reference patterns for safe, maintainable Alembic database migrations.

## 1. Naming Convention

Always set `naming_convention` on `MetaData` before autogenerate runs. Without it, constraint names are database-generated and differ across engines, causing migration drift.

```python
from sqlalchemy import MetaData

convention = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_N_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}

metadata = MetaData(naming_convention=convention)
```

Set `file_template` in `alembic.ini` for timestamp-prefixed filenames:

```ini
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
```

## 2. Credential Management

**NEVER** store database credentials in `alembic.ini` or commit them to version control.

Override `sqlalchemy.url` in `env.py` from environment variables:

```python
# env.py — override alembic.ini URL with environment variable
import os
from alembic import context

config = context.config
db_url = os.environ.get("DATABASE_URL")
if db_url:
    config.set_main_option("sqlalchemy.url", db_url)
```

For PostgreSQL + asyncpg, ensure the sync URL uses `postgresql+psycopg2` (or `postgresql`) for offline/sync contexts and `postgresql+asyncpg` only for async contexts.

## 3. Autogenerate Trust Matrix

Autogenerate is a starting point, not a final answer. Always review generated scripts.

| Object / Change | Autogenerate Detects | Notes |
|-----------------|----------------------|-------|
| Table add/drop | Yes | Reliable |
| Column add/drop | Yes | Reliable |
| Column type change | Partial | Type equivalence varies by backend |
| Column rename | **Never** | Generates drop+add — will destroy data |
| Index add/drop | Yes | Only if reflected or declared |
| Named constraint add/drop | Yes | Requires `naming_convention` on MetaData |
| Anonymous constraint | **No** | No name = no detection |
| Default value change | Partial | Server defaults vs client defaults differ |
| PostgreSQL views / functions | **No** | Use alembic-utils |
| PostgreSQL enum add value | Partial | Requires `alembic-postgresql-enum` |
| Sequence changes | No | Manual op required |

## 4. Dangerous Pattern Detection

Review every generated migration against this checklist before committing:

**CRITICAL — Review Required:**

- [ ] `op.drop_column` + `op.add_column` on the same column name → likely unintended rename; confirm with user
- [ ] `op.create_foreign_key(None, ...)` → anonymous FK; must have an explicit name
- [ ] `op.add_column` with `nullable=False` and no `server_default` on a non-empty table → full-table rewrite, lock risk
- [ ] `op.create_index` without `postgresql_concurrently=True` on a large table → table-level lock
- [ ] `op.drop_table` or `op.drop_column` → confirm there are no application references
- [ ] Empty `def downgrade(): pass` → document justification or implement rollback
- [ ] `op.alter_column` type change across incompatible types (e.g., `VARCHAR` → `INTEGER`) → data loss risk

**WARNING — Verify Intent:**

- [ ] Multiple heads detected (`alembic heads` shows 2+) → merge before deploying
- [ ] `batch_alter_table` missing for SQLite → required for constraint modifications on SQLite
- [ ] `render_as_batch=True` not set in `env.py` for SQLite projects

## 5. Expand-Contract Pattern

For zero-downtime schema changes on live tables, use three separate migration phases:

**Phase 1 — Expand** (deploy without application changes):
```python
def upgrade():
    op.add_column("users", sa.Column("email_new", sa.String(255), nullable=True))

def downgrade():
    op.drop_column("users", "email_new")
```

**Phase 2 — Migrate** (data backfill, can run during deploy):
```python
def upgrade():
    op.execute("""
        UPDATE users SET email_new = email WHERE email_new IS NULL
    """)

def downgrade():
    pass  # Data loss acceptable; backfill was additive
```

**Phase 3 — Contract** (after all application nodes use new column):
```python
def upgrade():
    with op.batch_alter_table("users") as batch_op:
        batch_op.alter_column("email_new", nullable=False)
    op.drop_column("users", "email")
    op.alter_column("users", "email_new", new_column_name="email")

def downgrade():
    op.add_column("users", sa.Column("email", sa.String(255), nullable=True))
    op.execute("UPDATE users SET email = email_new WHERE email IS NULL")
    op.drop_column("users", "email_new")
```

## 6. Async env.py

Canonical pattern for async SQLAlchemy (asyncpg) with Alembic:

```python
# env.py — async configuration
import asyncio
from logging.config import fileConfig

from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context
from myapp.models import Base  # Import all models here

config = context.config
fileConfig(config.config_file_name)
target_metadata = Base.metadata


def run_migrations_offline() -> None:
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )
    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection):
    context.configure(connection=connection, target_metadata=target_metadata)
    with context.begin_transaction():
        context.run_migrations()


async def run_migrations_online() -> None:
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,  # Required: avoids pool issues during migration
    )
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()


if context.is_offline_mode():
    run_migrations_offline()
else:
    asyncio.run(run_migrations_online())
```

Key points:
- Use `NullPool` — migration scripts are one-shot; pooling causes connection leaks
- Import ALL models in `env.py` (directly or via a central `models/__init__.py`) so autogenerate sees every table
- `run_sync` bridges the async connection back to Alembic's sync API

## 7. Testing with pytest-alembic

Install: `pip install pytest-alembic`

Built-in tests (all enabled by default):

```python
# conftest.py
import pytest
from sqlalchemy import create_engine
from alembic.config import Config

@pytest.fixture
def alembic_config():
    return Config("alembic.ini")

@pytest.fixture
def alembic_engine():
    return create_engine("postgresql://test_user:test_pass@localhost/test_db")
```

Built-in test assertions:
- `test_single_head_revision` — exactly one head revision exists
- `test_upgrade` — all upgrades apply without error
- `test_model_definitions_match_ddl` — SQLAlchemy models match the migrated schema
- `test_up_down_consistency` — every upgrade can be cleanly downgraded

Custom data migration test:
```python
@pytest.mark.alembic
def test_user_email_backfill(alembic_runner):
    # Insert data before migration
    alembic_runner.migrate_up_before("abc123def456")
    alembic_runner.insert_into("users", [{"id": 1, "email": "test@example.com"}])

    # Apply the migration
    alembic_runner.migrate_up_one()

    # Assert post-migration state
    result = alembic_runner.execute("SELECT email_new FROM users WHERE id = 1")
    assert result.scalar() == "test@example.com"
```

## 8. CI Integration

**Detect uncommitted migrations** — fail CI if models changed but no migration was generated:

```yaml
# .github/workflows/migrations.yml
- name: Check for pending migrations
  run: alembic check
  env:
    DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
```

**Squawk** — static analysis for lock-risk DDL (no live DB required):

```yaml
- name: Install Squawk
  run: pip install squawk-cli  # or: brew install squawk

- name: Lint migrations for lock risks
  run: |
    alembic upgrade head --sql > migration.sql
    squawk migration.sql
```

Squawk detects: non-concurrent index creation, adding NOT NULL without default, renaming columns, dropping constraints without cascade, and other patterns that cause long locks.

## 9. Extensions

| Package | Purpose | Install |
|---------|---------|---------|
| `alembic-utils` | Replaceable PG objects: views, functions, triggers, RLS policies | `pip install alembic-utils` |
| `alembic-postgresql-enum` | Safe enum value additions without full table rewrites | `pip install alembic-postgresql-enum` |
| `audit-alembic` | Attach migration metadata to audit log tables | `pip install audit-alembic` |
| `sqla-utils` | Additional SQLAlchemy model utilities complementing alembic-utils | `pip install sqla-utils` |

### alembic-utils example (PostgreSQL view):

```python
from alembic_utils.pg_view import PGView

user_summary_view = PGView(
    schema="public",
    signature="user_summary",
    definition="SELECT id, email, created_at FROM users WHERE active = true",
)

# In env.py — register with autogenerate
from alembic_utils.replaceable_entity import register_entities
register_entities([user_summary_view])
```

## 10. Common Pitfalls

| Pitfall | Symptom | Fix |
|---------|---------|-----|
| Empty `target_metadata` | Autogenerate produces empty migration | Import all models in `env.py` before `Base.metadata` is referenced |
| Multiple heads | `alembic upgrade head` fails with merge conflict error | Run `alembic merge heads -m "merge"` |
| SQLite constraint modification | `NotImplementedError` on `op.alter_column` | Use `op.batch_alter_table` context manager |
| asyncpg URL in offline mode | `Can't load plugin: sqlalchemy.dialects:postgresql+asyncpg` | Use sync URL (`postgresql://`) for offline mode; override only for async online mode |
| Missing model imports | Tables not detected by autogenerate | Add `from myapp import models` to `env.py` (not just `Base`) |
| `server_default` vs `default` | `server_default` needed for NOT NULL on existing rows | Use `server_default=sa.text("''")`; remove it in the Contract phase |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…