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 TABLEstatement. 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 bycreate_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
fullnametoname, 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.iniand commit them to Git. Configureenv.pyto load dynamic URLs from environment variables viaos.getenv("DATABASE_URL")or libraries likepydantic-settings. - Handling migration conflicts (Branch Conflicts): When two developers create migrations on independent Git branches, running
alembic upgrade headwill fail with a Multiple head revisions error. Simply runalembic merge heads -m "merge branch migrations"to merge the branches before deploying.

