Practical checks when working with alembic migrations

Database migrations can be a source of frustration, especially when your team is moving fast. A couple of simple checks can make things easier and minimize developer headaches.

Examples using pre-commit, uv as the package manager and alembic to manage migrations, but these ideas apply to any ORM-based migration framework.

Read the "Preventing missing migrations" section Preventing missing migrations

When working on a feature, it is easy to forget to generate a migration.
The pull request fails CI because migrations aren't up-to-date, and now you - the developer - are sad.

To avoid this, we want to ensure all changes made in the code are reflected in migration files before pushing.

The alembic check command, added in version 1.9.0, is what we need:

alembic check

Now, we really want this to run automatically. If we're forgetting migrations, we're unlikely to think about running it.

Here's an example using pre-commit to run the command automatically on every git push. It uses uv run so the hook can run even if the virtual environment is not active. This goes into .pre-commit-config.yaml:

- repo: local
  hooks:
    - id: migrations up-to-date
      stages: [pre-push]  
      entry: uv run alembic check
      language: system
      pass_filenames: false

alembic check follows the standard behavior of a non-zero exit code on failure, making the pre-commit hook nicely fail and prevent the push if there are pending migrations.

Read the "Catching conflicts early" section Catching conflicts early

When working on a feature branch, it's common for main to move forward with new migrations. This can result in a multiple heads 1 situation that needs to be resolved by rebasing migrations.

For example, say you branch off main when the migration history looks like this (using incremental ids instead of hashes for clarity):

migrations/
    001_initial.py

You work on your feature and add a migration:

migrations/
    001_initial.py
    002_add_email.py

Meanwhile, main progresses:

migrations/
    001_initial.py
    002_add_phone.py

After rebasing your branch onto main, you need to adjust your migrations to:

migrations/
    001_initial.py
    002_add_phone.py            # From `main`
    003_add_email.py            # Renamed from `002_` in your branch

This resolves the conflict by renaming and reordering migration dependencies. Without this adjustment, running alembic upgrade head will fail, as Alembic cannot determine which of the two 002_* migrations is the target head.

The problem is easy to miss - in your local environment, you may have run migrations before merging or rebasing and not noticed the new migrations on main. Or perhaps you used alembic stamp to manually align your database state, masking the issue.

You can check for this situation with:

test $(alembic heads | wc -l) -eq 1

Again, this only becomes truly convenient once it's automated. To catch this problem early, you can add another pre-push hook to your .pre-commit-config.yaml:

- repo: local
  hooks:
    - id: one-migration-head
      name: one-migration-head
      stages: [pre-push]
      # Fail if more than one head in alembic history
      entry: bash -c "test $(uv run alembic heads | wc -l) -eq 1"
      language: system
      pass_filenames: false

This hook ensures there's only one migration head before pushing your code. It's a simple way to avoid surprises in continuous integration after you rebased your branch for the fifth time because they keep merging their pull requests before yours.

Read the "Ensuring migrations work from scratch" section Ensuring migrations work from scratch

As a final sanity check, running migrations from scratch ensures your schema can be reliably recreated in any environment. This can be a simple alembic upgrade head on a fresh database in your continuous integration system.

It helps spot bugs before deployment and exposes subtle issues that are easy to miss during development.

For example, a common pitfall is importing 'live' models into a migration file. The migration might run fine when created, but changes to the code cause it to break later.
This can lead to a new team member struggling to apply all migrations, forcing them to patch files or find a workaround. Welcome to the team, by the way!

You could also test downgrade paths as an extra precaution to catch simple bugs early - before you're forced to debug them during an emergency.

Read the "Conclusion" section Conclusion

Putting these checks in place should help you avoid common migration pitfalls and keep development moving smoothly.

If your team is running into other specific issues slowing down development, let me know - I help teams ship better and faster and am always interested in experiences from the field.

Happy automating!

Read the "Footnotes" section Footnotes

  1. Multiple heads is alembic's terminology. Django would say multiple leaf nodes. The point is that the order of migrations is not clear.