Database / Alembic DB migration tool Interview questions
Last updated
1. What is Alembic?
Alembic is a lightweight database migration tool for Python, created by Mike Bayer, the author of SQLAlchemy. It records schema changes as versioned Python scripts so every environment can be brought to the same structure in a repeatable way.
Each script is called a revision and contains an upgrade() and a downgrade() function. Alembic stores the current revision in a table named alembic_version, which is how it knows which scripts are still pending.
Because it sits on top of SQLAlchemy, it works with PostgreSQL, MySQL, SQLite, Oracle, SQL Server and any other backend SQLAlchemy supports. It can also draft migrations for you by comparing your models with the live database (autogenerate).
Take quiz
Django ORM
Hibernate
SQLAlchemy
Peewee
In alembic.ini
In a hidden .alembic_state file
In the Mako template
In the alembic_version table
2. Why do we need a database migration tool like Alembic?
Hand-run SQL scripts drift apart. One developer adds a column locally, someone forgets to run it on staging, and production breaks on release day. A migration tool turns schema changes into version-controlled, ordered steps that every environment applies the same way.
Alembic gives you:
- Repeatability - one command builds the current schema from an empty database.
- Reviewability - migrations are plain files that go through pull requests.
- Reversibility - each revision can define how to undo itself.
- Traceability - the database knows exactly which revision it is on.
It also removes the temptation to call create_all() in production, which never alters existing tables.
Take quiz
Slower SQL execution
Larger Python packages
Loss of Python type hints
Schema drift between dev, staging and production
It does not alter tables that already exist
It deletes the alembic_version table
It only works with SQLite
It needs a Mako template
3. How do you install Alembic?
Install it from PyPI inside your project's virtual environment:
pip install alembic alembic --version
Pip pulls in SQLAlchemy, Mako and typing_extensions automatically. You still need a database driver for your backend, for example psycopg2-binary for PostgreSQL or pymysql for MySQL.
Pin the version in requirements.txt or pyproject.toml so every developer and CI job uses the same release. After installing, the alembic command is available on your PATH.
If you use Poetry or uv, the equivalent is poetry add alembic or uv add alembic. Installing inside the same environment as your app matters, because env.py imports your models.
Take quiz
pip install alembic
pip install python-alembic-cli
pip install sqlalchemy-migrate-alembic
npm install alembic
Only Mako
A driver such as psycopg2
A separate migration server
The Django admin package
4. What does the alembic init command do?
alembic init <directory> creates a migration environment in your project. It writes files and folders only; it never touches the database.
| Generated item | Role |
alembic.ini |
CLI configuration (script location, URL, logging) |
alembic/env.py |
Python script that connects and runs migrations |
alembic/script.py.mako |
Template for new revision files |
alembic/versions/ |
Folder that holds revision scripts |
alembic/README |
Short note about the chosen template |
Use -t async for an async SQLAlchemy engine or -t multidb for several databases:
alembic init alembic alembic init -t async migrations
Take quiz
revisions_out
versions
db_scripts
history
Yes, it creates all model tables
Yes, it creates alembic_version immediately
No, it only creates files and folders
Only when -t async is used
5. What is the purpose of alembic.ini?
alembic.ini is the configuration file the alembic command reads at startup. It tells Alembic where the migration folder is and how to name and log things.
script_location- path to the migration foldersqlalchemy.url- default database URLfile_template- naming pattern for revision filesprepend_sys_path- adds your project to the import pathversion_locations- extra folders for revisions[loggers]sections - logging setup used by env.py
It is parsed with Python's configparser, so a literal % must be written as %%, which matters for URL-encoded passwords. Use -c other.ini to point at a different file.
Take quiz
version_table_dir
migration_root
script_location
env_path
As \%
As %25 only
It is not allowed
As %%
6. What is the purpose of env.py in Alembic?
env.py is the Python script Alembic executes every time you run a command such as upgrade or revision --autogenerate. It decides how Alembic connects to the database and what metadata to compare against.
A standard file has two functions, run_migrations_offline() and run_migrations_online(), and picks one with context.is_offline_mode(). It also loads logging config and reads alembic.ini values through context.config.
from alembic import context config = context.config target_metadata = Base.metadata if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online()
This is the right place to inject the database URL from an environment variable or to set options like compare_type.
Take quiz
Only once, during alembic init
Only when the app server starts
Only for downgrades
Each time a command like upgrade or revision --autogenerate is executed
context.config
op.get_bind()
metadata.bind
script.ini_file
7. What is a revision in Alembic?
A revision is one migration script in the versions/ folder. It represents a single, named step in the schema history and carries an ID, a link to its parent, and the upgrade() / downgrade() code.
IDs are short random hex strings such as ae1027a6acf, so two developers can create migrations on separate branches without number clashes. Revisions form a chain through their parent links, not through file names.
"""create users table Revision ID: 3f2a9c1d7e45 Revises: Create Date: 2026-11-11 09:30:00 """ revision = "3f2a9c1d7e45" down_revision = None
The docstring, including the message you passed with -m, is what alembic history prints.
Take quiz
One versioned step of schema changes with upgrade and downgrade code
A full database backup
A SQLAlchemy model class
A set of environment variables
As sequential integers 1, 2, 3
As short random hex strings
From the file's modification time
From the Git commit hash
8. What are revision and down_revision in a migration file?
revision is the unique ID of the current script. down_revision is the ID of its parent, the migration that must run before it. The very first revision sets down_revision = None.
Alembic builds the migration order by following these links, so file names and timestamps do not matter. A merge revision uses a tuple for down_revision because it has two parents.
# 2nd migration revision = "b7c2d4e8f901" down_revision = "3f2a9c1d7e45" # merge migration revision = "c9d1e3f50a12" down_revision = ("b7c2d4e8f901", "a1b2c3d4e5f6")
Editing down_revision by hand is how you re-order or re-attach a migration, but it must be done before the revision is applied anywhere shared.
Take quiz
'base'
None
'0000'
An empty tuple
A comma-separated filename
The string 'heads'
A tuple of the parent revision IDs
Nothing, it omits the field
9. How do you create a new migration script?
Run alembic revision -m "message". Alembic creates a new file in versions/ using script.py.mako, links it to the current head, and leaves empty upgrade() and downgrade() bodies.
- Run
alembic revision -m "add orders table". - Open the generated file.
- Write the operations with
op.*calls. - Test with
alembic upgrade headandalembic downgrade -1.
Add --autogenerate to let Alembic draft the body from your models. Other useful flags are --rev-id for a custom ID, --head to choose the parent, and --branch-label for branched histories.
alembic revision -m "add orders table" alembic revision --autogenerate -m "add orders table"
Take quiz
alembic new -m "add orders"
alembic create "add orders"
alembic revision -m "add orders"
alembic migrate --empty
--diff
--detect
--sync
--autogenerate
10. What are the upgrade() and downgrade() functions?
upgrade() applies the schema change when Alembic moves forward past this revision. downgrade() reverses it when Alembic moves backward. Both are plain Python functions that call op methods.
def upgrade(): op.add_column("users", sa.Column("phone", sa.String(20))) def downgrade(): op.drop_column("users", "phone")
A good downgrade() undoes the steps in reverse order. Some changes cannot be undone safely, for example a dropped column that held data. In that case raise an exception or document it, rather than leaving a silent pass.
Alembic never calls both in one run. Which one executes depends only on the direction of the command, upgrade or downgrade.
Take quiz
upgrade()
revert()
rollback()
downgrade()
The inverse of upgrade(), in reverse order
A copy of upgrade()
A DROP DATABASE statement
Only the revision ID
11. How do you apply migrations with alembic upgrade?
alembic upgrade head reads the current revision from alembic_version, finds every pending revision up to the target, runs each upgrade() in order, and updates the version table.
| Command | Effect |
alembic upgrade head |
Apply everything up to the newest revision |
alembic upgrade +1 |
Apply only the next pending revision |
alembic upgrade ae10 |
Go to a revision (a unique prefix is enough) |
alembic upgrade ae10+2 |
Two steps after that revision |
If the database is already at the target, nothing happens, which makes the command safe to run on every deploy.
Take quiz
Applies only the next pending revision
Applies all pending revisions
Creates a new revision
Upgrades the Alembic package
The first revision
The newest revision in the chain
The revision currently in the database
The largest migration file
12. How do you roll back a migration in Alembic?
Use alembic downgrade. Alembic runs the downgrade() of each revision from the current one back to the target, then updates alembic_version.
alembic downgrade -1 # undo the last revision alembic downgrade ae10 # go back to a specific revision alembic downgrade base # undo everything
Rollbacks can lose data, because a downgrade that drops a column or table cannot bring the values back. Test downgrades in a copy of production data, and in live systems many teams prefer to roll forward with a fix migration instead.
Take quiz
alembic rollback
alembic downgrade -1
alembic undo
alembic upgrade -1
Deletes the versions folder
Resets alembic.ini
Reverts all revisions back to the empty starting point
Reverts only the latest revision
13. What is the alembic_version table?
alembic_version is the bookkeeping table Alembic creates on the first migration. It has one column, version_num (VARCHAR(32)), and normally one row holding the current revision ID.
After each successful migration step Alembic updates that row. If the history has branches, there can be several rows, one per head. Because the column is 32 characters wide, very long custom revision IDs will fail to insert.
You can rename the table with version_table and place it in another schema with version_table_schema inside context.configure().
context.configure( connection=connection, target_metadata=target_metadata, version_table="schema_history", )
Take quiz
A checksum of every SQL statement
A log of every user login
The ID of the revision the database is currently at
A list of model classes
version_name
state_table
tracking_table
version_table
14. What is the purpose of script.py.mako?
script.py.mako is the Mako template Alembic renders whenever it creates a new revision file. It controls the header, imports and function skeletons that every migration starts with.
"""${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"}
Edit it when you want every migration to import something extra, such as import sqlmodel, or to add a team header. Changes affect only new revisions, not existing files.
Take quiz
Jinja2
Django templates
Handlebars
Mako
To change the boilerplate that every new revision starts with
To choose the database URL
To decide migration order
To disable downgrade()
15. What does the alembic current command show?
alembic current prints the revision ID(s) the connected database is at, by reading the alembic_version table. A (head) marker means the database is already on the newest revision.
$ alembic current b7c2d4e8f901 (head)
Add -v for the full message and parent. If nothing is printed, the database has never been migrated or stamped. Since it queries the database, it needs a working connection, unlike alembic history.
It is the quickest sanity check after a deploy: compare its output with alembic heads. If the two IDs match, the database is fully migrated.
Take quiz
The database is on the newest revision
The last migration failed
Two heads exist
Alembic is in offline mode
No, it reads only version files
Yes, it reads alembic_version
No, it reads only alembic.ini
Only when --sql is passed
16. What does the alembic history command show?
alembic history lists every revision in the chain from newest to oldest, showing parent, ID and message. It reads the files in versions/ and does not need a database connection.
$ alembic history -i b7c2d4e8f901 -> c9d1e3f50a12 (head), add orders 3f2a9c1d7e45 -> b7c2d4e8f901 (current), add phone <base> -> 3f2a9c1d7e45, create users
-vshows full details-r base:headlimits the range-imarks the current database revision, which does need a connection
Because it only reads files, it is safe to run in CI or on a laptop with no database, which makes it handy for spotting forked histories.
Take quiz
The alembic_version table only
The revision scripts in the versions folder
information_schema
pip metadata
--mark-db
-d
-i (--indicate-current)
--current-only
17. What is a head revision in Alembic?
A head is a revision that no other revision lists as its down_revision, in other words the newest tip of a chain. In a linear history there is exactly one head, and head is shorthand for it.
List them with alembic heads. If two developers create migrations from the same parent, you end up with two heads, and alembic upgrade head fails with a "Multiple head revisions" error.
| Term | Meaning |
head |
The single newest revision |
heads |
All head revisions, used with multiple branches |
base |
The empty state before the first revision |
Fix it with alembic merge, or upgrade to heads if the branches are truly independent.
Take quiz
The one with the oldest date
The one named 'initial'
One that no other revision uses as its down_revision
The one currently in alembic_version only
It picks the newest file by date
It applies only the first alphabetically
It deletes the older branch
It fails and reports multiple head revisions
18. How do you set the database URL in Alembic?
The simplest way is the sqlalchemy.url key in alembic.ini. That works for local work, but committing real credentials is a bad idea, so most projects override it in env.py.
import os config = context.config config.set_main_option( "sqlalchemy.url", os.environ["DATABASE_URL"] )
A third option is a custom command-line argument read with context.get_x_argument(as_dictionary=True):
alembic -x db=staging upgrade head
Remember that set_main_option goes through configparser, so escape any % in the URL as %%.
Whichever option you pick, keep one source of truth. Mixing an ini value and an environment variable is how migrations end up running against the wrong database.
Take quiz
In script.py.mako
In the alembic_version table
In the versions folder name
In env.py, reading an environment variable
-x
-e
--url-extra
-p
19. What is the op object in Alembic?
op (from alembic import op) is a proxy to the Operations object bound to the migration currently running. Every schema change inside upgrade() and downgrade() is a method call on it.
| Method | Purpose |
op.create_table() |
Create a table |
op.add_column() |
Add a column |
op.alter_column() |
Change type, nullability, default or name |
op.create_index() |
Create an index |
op.execute() |
Run raw SQL |
op.batch_alter_table() |
Batch mode for SQLite |
The same call renders different SQL for different databases, because Alembic picks the dialect from the connection. op.get_bind() returns the active connection when you need to query data.
Take quiz
The Operations interface for the migration being run
The SQLAlchemy ORM session
The Mako template
The alembic.ini parser
op.raw()
op.execute()
op.sql_run()
op.query()
20. How do you create a table in an Alembic migration?
Call op.create_table() in upgrade() with the table name and sa.Column objects, and mirror it with op.drop_table() in downgrade().
def upgrade(): op.create_table( "orders", sa.Column("id", sa.Integer, primary_key=True), sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False), sa.Column("total", sa.Numeric(10, 2), nullable=False), sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), ) op.create_index("ix_orders_user_id", "orders", ["user_id"]) def downgrade(): op.drop_index("ix_orders_user_id", table_name="orders") op.drop_table("orders")
Give constraints and indexes explicit names. Unnamed ones get database-generated names that differ between backends and are painful to drop later.
If you generated the migration with --autogenerate, the same create_table call appears automatically, so this is mainly useful when you write or fix a migration by hand.
Take quiz
op.new_table()
op.create_table()
op.add_table()
sa.create_table()
Alembic refuses unnamed ones
Names make queries faster
Generated names vary by backend and are hard to drop later
It is required by SQLAlchemy Core
21. How does alembic revision --autogenerate work?
Autogenerate compares the MetaData in your models (target_metadata) with the schema reflected from the live database, then writes the differences as op calls into a new revision file.
- Alembic runs
env.pyand connects to the database. - It reflects the existing tables, columns, indexes and foreign keys.
- It diffs them against
target_metadata. - It renders the diff as Python in
upgrade()and the inverse indowngrade().
The output is a draft. Always read it, because renames show up as a drop plus an add, and some changes are not detected at all. The database must be at the latest revision first, otherwise Alembic refuses to autogenerate.
alembic upgrade head alembic revision --autogenerate -m "add phone to users"
Take quiz
Two Git commits
Two alembic.ini files
Model metadata versus the reflected live database schema
The previous and next revision files
As an alter_column rename
As a no-op
As a table rebuild
As a drop of the old column and an add of the new one
22. Which changes can autogenerate not detect?
Autogenerate is good at structure but blind to anything it cannot reflect or infer. Treat the generated file as a starting point.
| Detected | Not detected |
| Added or removed tables and columns | Table or column renames (seen as drop + add) |
| Nullable changes | Anonymous or unnamed constraints |
| Basic index and unique constraint changes | CHECK constraints |
| Foreign key changes | Views, triggers, stored procedures |
Column type changes (compare_type) |
Data changes and some Enum or custom type changes |
Server defaults are only compared when compare_server_default=True is set. If you rely on any item in the right column, write those migrations by hand.
Take quiz
As a rename operation
It ignores the change
It raises an error
As a removal plus an addition
CHECK constraints
Added tables
Dropped columns
Nullable changes
23. How do you configure target_metadata in env.py?
Set target_metadata to the MetaData object your models are registered on, then pass it to context.configure(). Autogenerate uses it as the 'desired' schema.
from myapp.db import Base import myapp.models # noqa: registers all model classes target_metadata = Base.metadata context.configure( connection=connection, target_metadata=target_metadata, )
The most common mistake is forgetting to import the model modules. An unimported model is not on the metadata, so autogenerate thinks the table should be dropped. With several metadata objects, target_metadata can be a list.
If it is left as None, autogenerate produces nothing useful.
Take quiz
The MetaData of your declarative Base
The sqlalchemy.url string
The alembic_version table
A Mako template object
Alembic imports it automatically
Autogenerate may propose dropping its table
The upgrade crashes at init
The model is created twice
24. What is the difference between offline and online mode in Alembic?
Online mode connects to the database and executes the migration. Offline mode never connects; it emits the SQL as text, usually to a file or stdout, so a DBA can review and run it.
| Aspect | Online | Offline |
| Connection | Live engine | None, URL only for dialect |
| Output | Changes applied | SQL script |
| Command | alembic upgrade head |
alembic upgrade head --sql |
| Function in env.py | run_migrations_online() |
run_migrations_offline() |
Offline mode uses literal_binds=True so values are written into the SQL instead of bound parameters. Anything in a migration that queries data, such as op.get_bind().execute(select...), cannot work offline.
Take quiz
--dry
--sql
--offline-only
Offline mode skips Python
SQL is encrypted
There is no database connection to run the query on
Only DDL is parsed
25. How do you generate a SQL script without connecting to the database?
Add --sql to upgrade or downgrade. Alembic runs in offline mode and prints the SQL, including the alembic_version updates.
alembic upgrade head --sql > release.sql alembic upgrade ae10:head --sql > patch.sql alembic downgrade head:ae10 --sql > rollback.sql
Without a database Alembic cannot read the current revision, so a plain upgrade head --sql starts from base. For an incremental script, give an explicit start:end range.
This is the usual route when production changes must pass through a DBA or a change-approval process.
Take quiz
A second alembic.ini
The --dry-run flag
A start:end revision range
A running database
From the last Git tag
From the newest file
From the previous run's output
From base, because the current revision is unknown
26. How do you add a NOT NULL column to a table that already has rows?
A direct add_column(..., nullable=False) fails on a populated table because existing rows have no value. Use a three-step approach.
- Add the column as nullable (or with a
server_default). - Backfill existing rows with an
UPDATE. - Alter the column to
nullable=False.
def upgrade(): op.add_column("users", sa.Column("status", sa.String(10), nullable=True)) op.execute("UPDATE users SET status = 'active'") op.alter_column("users", "status", existing_type=sa.String(10), nullable=False)
On big tables, run the backfill in batches to avoid long locks. Supplying server_default is shorter when a constant value is fine, and you can drop the default afterwards if the app should always provide the value.
Take quiz
Alembic blocks NOT NULL
SQL does not allow NOT NULL on any existing table
The alembic_version table locks it
Existing rows would have no value for it
Add nullable, backfill, then set NOT NULL
Set NOT NULL, add column, backfill
Backfill, add column, drop table
Add NOT NULL, stamp, upgrade
27. What is batch_alter_table and why is it used with SQLite?
SQLite supports only a few ALTER TABLE forms. It cannot drop a column in older versions, change a type, or add a constraint to an existing table. op.batch_alter_table() works around this with move and copy.
- Create a new temporary table with the desired structure.
- Copy the data across.
- Drop the old table.
- Rename the new table to the original name.
with op.batch_alter_table("users") as batch_op: batch_op.alter_column("age", type_=sa.BigInteger) batch_op.drop_column("legacy_flag")
Set render_as_batch=True in context.configure() so autogenerate emits batch blocks automatically. Unnamed constraints need a naming_convention, otherwise batch mode cannot refer to them. On other databases, batch mode just runs ordinary ALTER statements.
Take quiz
It creates a new table, copies data, drops the old one and renames
It sends ALTER TABLE ... FORCE
It edits the SQLite file header
It uses a stored procedure
batch_mode=True
render_as_batch=True
sqlite_safe=True
use_batch=1
28. How do you run raw SQL in an Alembic migration?
Use op.execute() with a SQL string or a SQLAlchemy construct. It works in both online and offline modes.
from sqlalchemy import text def upgrade(): op.execute("UPDATE orders SET status = 'new' WHERE status IS NULL") op.execute(text("CREATE VIEW active_users AS SELECT * FROM users WHERE active")) def downgrade(): op.execute("DROP VIEW active_users")
Views, triggers, functions and CHECK constraints are typical reasons for raw SQL, since autogenerate cannot produce them. For bulk inserts of static data, op.bulk_insert() is cleaner. Avoid string-formatting user values into SQL; use bound parameters through text() and op.get_bind() when values vary.
Take quiz
op.sql()
op.execute()
op.raw_query()
op.text_run()
Alembic forbids op.create_view
They only exist in SQLite
Autogenerate cannot detect or create them
They are stored in env.py
29. How do you perform data migrations safely in Alembic?
Treat data changes as separate, small and repeatable. The biggest rule: do not import your application's ORM models into a migration. Models change over time and would break old revisions.
Define a lightweight table in the migration itself:
users = sa.table("users", sa.column("id", sa.Integer), sa.column("email", sa.String)) def upgrade(): conn = op.get_bind() conn.execute( users.update().values(email=sa.func.lower(users.c.email)) )
- Keep schema and data steps in separate revisions when possible.
- Batch updates on large tables, for example by primary key ranges.
- Write a real
downgrade()or document why it is impossible. - Use
op.bulk_insert()for seed or lookup rows.
Take quiz
Models cannot be imported in Python files
Alembic rejects them at init
Models change later and can break old revisions
ORM classes are slower
Base.metadata.reflect()
op.model()
sa.Session()
sa.table() with sa.column()
30. What is the alembic stamp command used for?
alembic stamp <rev> writes a revision ID into alembic_version without running any migration. It tells Alembic: 'treat this database as already at this revision'.
- Adopting Alembic on a database that already has the schema:
alembic stamp headafter writing a baseline migration. - Recovering after a manual fix or a failed run left the table out of sync.
- Marking a freshly built database (via
create_all()) as current.
alembic stamp head alembic stamp ae1027a6acf alembic stamp --purge base
Use it carefully. Stamping the wrong revision makes Alembic skip or repeat migrations. --purge clears existing version rows first, which helps when the stored ID no longer exists.
Take quiz
Applies all pending migrations
Creates a new head revision
Deletes alembic_version
Records head in alembic_version without running migrations
When the schema already exists and Alembic must be told its revision
When creating an empty versions folder
When changing the database password
When renaming models
31. How do relative and partial revision identifiers work in Alembic?
Alembic accepts shortcuts so you rarely type a full 12-character ID.
| Form | Example | Meaning |
| Partial ID | ae10 |
Any unique prefix of a revision ID |
| Relative to current | +2, -1 |
Two steps up, one step down from current |
| Relative to a revision | ae10+1 |
One step after ae10 |
| Symbolic | head, base, heads |
Newest, empty start, all heads |
alembic upgrade +2 alembic downgrade -1 alembic upgrade ae10+1
If a prefix matches more than one revision, Alembic reports it as ambiguous and you need to type more characters.
Take quiz
Go back one revision from the current one
Delete one file
Go back one table
Reduce the version number by 1 in the ini
It picks the newest
Alembic reports it as ambiguous
It picks the oldest
It merges them
32. What happens when Alembic finds multiple head revisions?
Commands that target a single head stop with an error such as Multiple head revisions are present for given argument 'head'. Alembic cannot guess which branch should be the main line.
It usually happens when two developers each create a migration from the same parent on separate Git branches. After merging, the chain forks into two tips.
alembic headslists the tips.alembic upgrade headsapplies every branch.alembic merge heads -m "merge"joins them into one head.
Many teams add a CI check that fails when alembic heads returns more than one line, so the fork is caught before release.
Take quiz
Running upgrade twice
Two migrations created from the same parent on different branches
Editing alembic.ini
Using too many tables
Compare file sizes
Check the Mako template
Fail if alembic heads prints more than one revision
Count the tables
33. How do you merge multiple heads in Alembic?
Create a merge revision that lists all current heads as parents:
alembic merge heads -m "merge feature branches" # or name them explicitly alembic merge -m "merge" b7c2d4e8f901 a1b2c3d4e5f6
The new file has a tuple down_revision and usually empty upgrade() and downgrade(). After that, the history has a single head again and alembic upgrade head works.
revision = "c9d1e3f50a12" down_revision = ("b7c2d4e8f901", "a1b2c3d4e5f6")
Before merging, check that the two branches do not touch the same table or column in conflicting ways. Alembic only joins the graph; it does not reconcile conflicting schema changes.
Take quiz
A single string 'merge'
None
A tuple of the parent revisions
A list of table names
Yes, it rewrites both migrations
Yes, it drops conflicting columns
Yes, it runs autogenerate
No, it only joins the revision graph
34. What is the difference between Alembic and Flask-Migrate?
Flask-Migrate is a thin Flask extension by Miguel Grinberg that wraps Alembic for Flask-SQLAlchemy projects. Alembic does the real work; Flask-Migrate wires it into Flask's app context and CLI.
| Aspect | Alembic | Flask-Migrate |
| Scope | Any SQLAlchemy project | Flask + Flask-SQLAlchemy |
| Command | alembic upgrade head |
flask db upgrade |
| Setup | Edit env.py yourself | Pre-configured env.py using the Flask app |
| Autogenerate | alembic revision --autogenerate |
flask db migrate |
Flask-Migrate reads the database URL and metadata from your Flask config, so you skip most of the manual env.py work. The migration files themselves are standard Alembic revisions.
Take quiz
A replacement engine
A separate migration format
A SQLAlchemy plugin for MySQL only
A Flask wrapper around Alembic
flask db migrate
flask db create
flask db diff
flask db sync
35. How do you use Alembic with a FastAPI project?
FastAPI has no built-in migrations, so Alembic is added as a separate tool next to the app. It only needs your SQLAlchemy Base.metadata and a database URL.
- Run
alembic init alembicin the project root. - In
env.py, import yourBaseand all model modules, and settarget_metadata = Base.metadata. - Load the URL from your settings object instead of
alembic.ini. - Generate and apply with
alembic revision --autogenerateandalembic upgrade head.
from app.core.config import settings from app.db.base import Base # imports every model config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) target_metadata = Base.metadata
If the app uses an async driver such as asyncpg, initialise with -t async. Run upgrade head as a deploy step, not inside the FastAPI startup event, so several workers do not race.
Take quiz
The app settings, set into the config in env.py
The FastAPI route decorators
The OpenAPI schema
The uvicorn command line only
Startup events cannot import Alembic
Multiple workers could run them at the same time
It disables autogenerate
It breaks Pydantic
36. What is the difference between Alembic and Django migrations?
Django migrations are built into Django's ORM and track app models automatically. Alembic is a standalone tool for SQLAlchemy, with more manual control.
| Aspect | Alembic | Django migrations |
| ORM | SQLAlchemy | Django ORM |
| Create | alembic revision --autogenerate |
makemigrations |
| Apply | alembic upgrade head |
migrate |
| History table | One alembic_version row (per head) |
django_migrations, one row per applied migration |
| Ordering | down_revision links |
dependencies lists, per app |
| Renames | Detected only by hand | Can ask whether a field was renamed |
Django keeps a per-app history and builds model state from the migration files. Alembic keeps one global chain, which is simpler but gives you more room to make mistakes in env.py.
Take quiz
alembic_version
django_migrations
schema_history
migrate_log
Django ORM
Peewee only
SQLAlchemy
Pony ORM only
37. Why should you use a naming convention with Alembic?
Constraints you do not name get database-generated names, such as users_email_key on PostgreSQL, and those differ per backend. Dropping or altering them later then needs guesswork. A naming_convention on MetaData gives every constraint a predictable name.
from sqlalchemy import MetaData convention = { "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_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", } Base = declarative_base(metadata=MetaData(naming_convention=convention))
Autogenerate then writes explicit names into migrations, op.drop_constraint() works reliably, and SQLite batch mode can find constraints. Set it before your first migration; changing it later means renaming existing constraints.
Take quiz
Faster queries
Automatic table renames
Predictable names for constraints and indexes
A separate alembic_version table
After the first production incident
Only when using SQLite
Never, Alembic names everything
Before the first migration
38. How do you rename a column in Alembic without losing data?
Write the rename by hand with op.alter_column(..., new_column_name=...). Autogenerate would instead emit a drop and add, which destroys the data in that column.
def upgrade(): op.alter_column( "users", "fullname", new_column_name="full_name", existing_type=sa.String(100), ) def downgrade(): op.alter_column( "users", "full_name", new_column_name="fullname", existing_type=sa.String(100), )
MySQL needs existing_type because its syntax re-declares the column. For a live system with older app versions still running, use expand and contract: add the new column, copy data, switch the code, then drop the old column in a later release.
Take quiz
It truncates the table
It locks the database
It changes the primary key
It generates a drop and an add
new_column_name
rename_to
target_name
column_alias
39. How do compare_type and compare_server_default affect autogenerate?
Both are context.configure() options that tell autogenerate to also look at column types and server defaults, not just presence of columns.
| Option | Detects | Default |
compare_type |
Type changes such as String(50) to String(100) |
On in current versions |
compare_server_default |
Changes to server_default |
Off |
context.configure( connection=connection, target_metadata=target_metadata, compare_type=True, compare_server_default=True, )
Server default comparison can produce false positives because databases normalise default expressions, for example now() versus CURRENT_TIMESTAMP. Review the output carefully or supply a custom comparison function.
Turning on compare_type is a good habit for new projects, since silent type drift between models and the database otherwise goes unnoticed until a query fails.
Take quiz
compare_server_default
compare_defaults_only
detect_defaults
server_default_check
Alembic ignores defaults
Databases rewrite default expressions into a normalised form
Defaults are encrypted
Python cannot parse SQL
40. How do you exclude tables from autogenerate using include_object?
Pass an include_object function to context.configure(). Alembic calls it for each table, column, index and constraint, and anything for which it returns False is skipped.
def include_object(obj, name, type_, reflected, compare_to): if type_ == "table" and name in {"spatial_ref_sys", "celery_taskmeta"}: return False return True context.configure( connection=connection, target_metadata=target_metadata, include_object=include_object, )
This is useful for tables owned by extensions or other services, which would otherwise be proposed for dropping because they are not in your metadata. A lighter option is include_name, which filters by name before reflection.
Take quiz
The object is dropped
The object is skipped by autogenerate
Alembic stops with an error
The object is renamed
They are read-only for Alembic
They break Mako
They exist in the database but not in your models
They use a different driver
41. How do you manage migrations for multiple databases with Alembic?
There are two common patterns. The built-in multidb template keeps one revision chain for several databases; separate configs keep one chain per database.
- multidb template:
alembic init -t multidb, withdatabases = engine1, engine2inalembic.ini. Revisions containupgrade_engine1(),upgrade_engine2()and so on. - Separate environments: one folder and ini per database, selected with
alembic -c db2.ini upgrade head, or sections picked with-n.
Use the multidb template when the databases always move together. Choose separate chains when they are owned by different services, because their release cycles will differ.
Take quiz
cluster
shared
multidb
federated
-i
--conf-path
-f
-c
42. What is the difference between Alembic and Flyway or Liquibase?
All three apply versioned schema changes, but they come from different ecosystems. Flyway and Liquibase are JVM tools built around SQL or XML/YAML changelogs. Alembic is Python-first and ties into SQLAlchemy.
| Aspect | Alembic | Flyway | Liquibase |
| Language | Python scripts | Plain SQL or Java | XML, YAML, JSON or SQL |
| Autogenerate | Yes, from SQLAlchemy models | No (paid diff features) | Yes, diff and generateChangeLog |
| Ordering | Revision graph, supports branches | Version numbers | Changelog order |
| Integrity check | No checksum | Checksum validation | MD5 checksums |
Pick Alembic when your models are in SQLAlchemy and you want migrations in the same language. Pick Flyway or Liquibase for polyglot teams or when SQL is the source of truth.
Take quiz
Flyway
Liquibase
Both Flyway and Liquibase
Alembic
Python
XML only
Java only
Plain SQL files only
43. Explain the execution flow of alembic upgrade head?
alembic upgrade head loads the config, reads the revision scripts, runs env.py, and applies each pending revision inside a transaction when the database supports it.
sequenceDiagram
participant CLI as alembic CLI
participant SD as ScriptDirectory
participant ENV as env.py
participant DB as Database
CLI->>SD: read alembic.ini, load versions/
SD-->>CLI: revision graph
CLI->>ENV: run env.py
ENV->>DB: connect, begin transaction
DB-->>ENV: current rev from alembic_version
loop each pending revision
ENV->>DB: run upgrade()
ENV->>DB: update alembic_version
end
ENV->>DB: commit
- The CLI parses
alembic.iniand resolvesscript_location. - ScriptDirectory reads files in
versions/and builds the revision map fromdown_revisionlinks. env.pyruns, creates the engine and callscontext.configure().- The MigrationContext reads the current revision and computes the path to the target.
- Each
upgrade()runs and the version row is updated. - The transaction commits.
A failure rolls back the whole run on PostgreSQL. On MySQL, DDL commits implicitly, so earlier steps stay applied.
Take quiz
ScriptDirectory
MigrationContext only
The Mako renderer
SQLAlchemy Inspector
The database trigger
Alembic, after each revision's upgrade() runs
The app on restart
The Mako template
44. Explain the internal working of Alembic autogenerate?
Autogenerate is a pipeline: reflect, compare, convert, render. It lives in alembic.autogenerate and the public entry point for the comparison step is compare_metadata().
flowchart LR
A["Models: target_metadata"] --> C[compare_metadata]
B["Live DB: Inspector reflection"] --> C
C --> D["Diff tuples: add_table, remove_column ..."]
D --> E["MigrationScript directives"]
E --> F["Render to Python op calls"]
F --> G["New revision file"]
- SQLAlchemy's Inspector reflects tables, columns, indexes and foreign keys from the database.
- Alembic compares them with
target_metadataand produces diff tuples such as('add_column', ...). - The diffs become directive objects (
UpgradeOps,DowngradeOps). - Renderers turn directives into
op.*source code, which is placed intoscript.py.mako.
You can modify the directives before rendering with process_revision_directives, for example to skip empty migrations:
def process_revision_directives(context, revision, directives): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = []
Take quiz
Session
Inspector
Mapper
Dialect compiler
post_render_hook
pre_commit_script
process_revision_directives
edit_revision
45. How do branch labels and depends_on work in Alembic?
Alembic's history is a graph, not just a line. Branch labels name independent lines of migrations, and depends_on adds a cross-branch ordering rule without making one the parent of the other.
alembic revision -m "plugin base" --head=base --branch-label=plugin_a alembic upgrade plugin_a@head alembic revision -m "needs core" --head=plugin_a@head --depends-on=ae1027a6acf
--head=basestarts a new, separate branch.--branch-labelgives it a name you can use aslabel@head.depends_on = "ae1027a6acf"inside a revision says it must run after that revision, even in another branch.
This suits plugin-style projects where each module owns its tables but may depend on a core schema. Use version_locations to store each branch's files in its own folder.
Take quiz
A second database URL
A rollback script
An ordering dependency on a revision in another branch
A new head
alembic upgrade --branch plugin_a
alembic upgrade plugin_a:only
alembic upgrade plugin_a.head
alembic upgrade plugin_a@head
46. How do you run Alembic with an async SQLAlchemy engine?
Initialise with the async template, which provides an env.py that creates an AsyncEngine and runs the sync migration code inside run_sync.
alembic init -t async alembic
from sqlalchemy.ext.asyncio import async_engine_from_config import asyncio def do_run_migrations(connection): context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() async def run_async_migrations(): engine = async_engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", ) async with engine.connect() as conn: await conn.run_sync(do_run_migrations) await engine.dispose() asyncio.run(run_async_migrations())
The URL must use an async driver, for example postgresql+asyncpg://. The migration scripts themselves stay synchronous, since Alembic's op calls are not coroutines.
Take quiz
asyncio-lite
coroutine
aio
async
No, they stay synchronous and run via run_sync
Yes, every op call is awaited
Yes, but only upgrade()
Only downgrade() is async
47. How do you troubleshoot "Can't locate revision identified by" error?
This error means the database's alembic_version holds an ID that Alembic cannot find in the versions/ folder. The database and the code are out of step.
- Run
alembic currentand note the ID. - Search
versions/for it. A missing file is the usual cause: a deleted or unmerged migration, or a branch not checked out. - Run
alembic headsandalembic historyto see what the code knows. - Confirm you are pointed at the right database; staging and production IDs get mixed up often.
Fixes depend on the cause. Restore or merge the missing file if the revision was real. If the ID is truly stale, correct the pointer:
alembic stamp --purge head # only if the schema really matches head
Never stamp blindly. Compare the actual schema first, or you will skip migrations.
Take quiz
The revision file for the stored ID is missing from versions/
alembic.ini has a typo in logging
Mako is not installed
Too many revisions exist
Delete alembic_version
Confirm the real schema matches the revision you stamp
Run downgrade base
Regenerate every revision
48. How can you avoid locking large tables with Alembic on PostgreSQL?
Long locks come from DDL that rewrites a table or waits behind running queries. Plan each operation so it holds the lock only briefly.
- Indexes: use
CREATE INDEX CONCURRENTLY, which cannot run inside a transaction. - Lock timeout: set
lock_timeoutso the migration fails fast instead of blocking traffic. - Backfills: update in small batches, not one huge statement.
- Constraints: add as
NOT VALIDandVALIDATElater.
def upgrade(): op.execute("SET lock_timeout = '5s'") with op.get_context().autocommit_block(): op.create_index( "ix_orders_created_at", "orders", ["created_at"], postgresql_concurrently=True, )
autocommit_block() pauses the migration transaction so the concurrent index build is allowed. Adding a nullable column without a default is a quick, metadata-only change.
Take quiz
It disables Alembic
That statement cannot run inside a transaction
It speeds up SELECT
It avoids revision IDs
It speeds up the backfill
It locks more tables
The migration fails quickly rather than blocking traffic for a long time
It skips constraints
49. How do you run Alembic migrations safely in CI/CD?
Treat migrations as a controlled release step: validate them on every pull request, then apply them once per deploy, before the new code starts.
- Check one head: fail if
alembic headslists more than one. - Check drift:
alembic checkfails if models and migrations disagree. - Test the chain:
alembic upgrade headon an empty database, plus a downgrade test where it matters. - Deploy: run
alembic upgrade headas a single job, not on every app instance. - Back up before risky changes in production.
Write migrations to be backward compatible (expand then contract), so the old app version keeps working while the new schema rolls out. For approval workflows, generate SQL with --sql and attach it to the release.
Take quiz
alembic verify-heads
alembic diff-only
alembic check
alembic lint
In every app instance at startup
In a cron job at midnight
Inside each HTTP request
As one dedicated job before the new app version starts
50. What is transactional DDL and how does Alembic handle it?
Transactional DDL means schema changes such as CREATE TABLE can be rolled back like normal data changes. PostgreSQL supports it, so a failed migration leaves no partial changes. MySQL commits DDL implicitly, so a failure can leave the schema half-migrated.
| Database | Transactional DDL | Effect of failure mid-migration |
| PostgreSQL | Yes | Whole run rolls back |
| SQLite | Mostly | Usually rolls back |
| MySQL / MariaDB | No | Earlier statements stay applied |
| Oracle | No | Earlier statements stay applied |
Alembic checks the dialect's transactional_ddl flag and wraps the run in one transaction when it is supported. By default all pending revisions share a single transaction. Set transaction_per_migration=True in context.configure() to commit after each revision instead.
On non-transactional databases, keep each migration small, and be ready to fix state with alembic stamp.