A Guide to Using Alembic in Python: Database Migration Management for SQLAlchemy

Python tutorial - IT technology blog
Python tutorial - IT technology blog

1. Three Common Ways to Manage Database Schemas in Python

How do you update a database table when you’ve just added a field to a SQLAlchemy model? This is a classic dilemma that every Python backend developer has faced. Essentially, there are 3 approaches:

  • Method 1: Running raw SQL manually — Whenever you add a column, you open DBeaver or pgAdmin and run an ALTER TABLE statement. This approach is quick for personal projects with 1–2 tables, but extremely risky in team environments.
  • Method 2: Using Base.metadata.drop_all() followed by create_all() — Commonly seen in beginner tutorials. Every time you modify a model, you wipe the database clean and recreate it. In production with 100,000 customer records, doing this means losing everything.
  • Method 3: Using a dedicated migration tool (Alembic) — Every schema change is saved as a source code file (revision file). You can commit them to Git, review before applying, and upgrade or rollback (downgrade) flexibly.

2. Comparing the Pros and Cons of Each Approach

Depending on the project stage and scale, each method has its own trade-offs:

Criteria Manual (Raw SQL) drop_all / create_all Alembic Migration
Initial Setup Time 0 minutes, no extra packages required Under 1 minute, just a single line of code Takes about 5–10 minutes of initial configuration
Data Safety High risk due to manual operations 100% data loss on every run Preserves data, flexible rollbacks
Teamwork Prone to schema drift between dev machines and servers Not feasible Seamlessly synchronized via Git branches
Auditability No centralized history Completely unsupported Clearly tracks every revision ID

3. Why Is Alembic the Standard for SQLAlchemy?

Alembic was created by Mike Bayer himself — the author of SQLAlchemy. As a result, the integration between these two tools is virtually seamless.

When a project has only 2–3 tables, manual updates might still work fine. But imagine a system scaling to 50 tables with 5 developers committing daily. At that point, manual database management inevitably leads to catastrophic schema drift across Local, Staging, and Production environments.

Alembic reads metadata directly from your Python code, then automatically compares it against the live database to generate update scripts. Consequently, the database schema always stays consistently in sync with your source code.

4. Step-by-Step Practical Guide to Alembic

Step 1: Install Dependencies

Install Alembic along with the appropriate database driver into your virtualenv:

pip install sqlalchemy alembic psycopg2-binary

Step 2: Initialize the Migration Directory Structure

In your project’s root directory, run the following command:

alembic init alembic

The generated directory structure will look like this:

project_root/
├── alembic/
│   ├── versions/        # Directory to store migration script files
│   ├── env.py           # File controlling the migration execution flow
│   └── script.py.mako   # Template for generating migration files
├── alembic.ini          # Configuration file for database URL and logging
├── models.py            # Declaration of SQLAlchemy models
└── main.py

Step 3: Configure Database Connection and Point Metadata in env.py

Suppose your models.py file contains the User model:

# models.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String(255), unique=True, nullable=False)
    username = Column(String(50), nullable=False)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, server_default=func.now())

Next, open the alembic/env.py file. You need to import Base from models.py and assign it to target_metadata so Alembic can detect it:

# alembic/env.py
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context

# Import Base from the models file
from models import Base

config = context.config
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# Specify target metadata for Alembic comparison
target_metadata = Base.metadata

In the alembic.ini file, provide the database connection string (if you want to avoid storing plain text values in configuration files, make sure to manage Python configs like a pro with YAML, TOML, and INI or manage secrets like a DevOps pro):

# alembic.ini
sqlalchemy.url = postgresql://postgres:secretpassword@localhost:5432/app_db

Step 4: Automatically Detect Changes (Autogenerate)

After modifying your models in Python code, let Alembic automatically compare them and generate a migration file using the --autogenerate flag:

alembic revision --autogenerate -m "create users table"

A new file will appear in alembic/versions/ (for example: 1a2b3c4d5e_create_users_table.py):

"""create users table

Revision ID: 1a2b3c4d5e
Revises: 
Create Date: 2026-10-02 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa

def upgrade() -> None:
    op.create_table(
        'users',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('email', sa.String(length=255), nullable=False),
        sa.Column('username', sa.String(length=50), nullable=False),
        sa.Column('is_active', sa.Boolean(), nullable=True),
        sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('email')
    )
    op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)

def downgrade() -> None:
    op.drop_index(op.f('ix_users_id'), table_name='users')
    op.drop_table('users')

Step 5: Apply Changes to the Database

Run the following command to apply all pending migrations up to the latest version (head):

alembic upgrade head

If you encounter an issue and need to roll back to the previous version immediately, simply run:

alembic downgrade -1

Check the current schema status and full revision history:

alembic current
alembic history --verbose

5. Battle-Tested Best Practices for Production

  • Don’t rely 100% on autogenerate: Alembic is smart, but it cannot read your mind. If you rename a column from fullname to name, it interprets this as dropping the old column and adding a new one. Consequently, all data in the old column will be permanently lost. Always inspect the generated revision file before running migrations.
  • Never hardcode database credentials: Never store database passwords in alembic.ini and commit them to Git. Configure env.py to load dynamic URLs from environment variables via os.getenv("DATABASE_URL") or libraries like pydantic-settings.
  • Handling migration conflicts (Branch Conflicts): When two developers create migrations on independent Git branches, running alembic upgrade head will fail with a Multiple head revisions error. Simply run alembic merge heads -m "merge branch migrations" to merge the branches before deploying.
Share: