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" sectionPreventing 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" sectionCatching 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" sectionEnsuring 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" sectionConclusion
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" sectionFootnotes
-
Multiple heads is alembic's terminology. Django would say multiple leaf nodes. The point is that the order of migrations is not clear. ↩