Módulo 9 • 14 min de lectura
09 - Migraciones con Alembic
Workflow profesional de migraciones: autogenerate, data migrations, rollbacks y CI/CD.
#alembic
#migrations
#database
#sqlalchemy
#devops
1. Alembic vs Drizzle Kit
JavaScript/TypeScript
// Drizzle Kit
// 1. Generar migración
npx drizzle-kit generate:pg
// 2. Aplicar
npx drizzle-kit push:pg
// Genera SQL puro, sin lógica de datos Python
# Alembic
# 1. Generar migración
alembic revision --autogenerate -m "add users table"
# 2. Aplicar
alembic upgrade head
# Genera Python, permite data migrations Ventaja clave de Alembic: Las migraciones son código Python, no solo SQL. Puedes ejecutar lógica compleja durante la migración (transformar datos, backfill, etc.).
2. Setup Inicial
# Instalar
uv add alembic
# Inicializar (crea carpeta alembic/)
alembic init alembic
Estructura generada
alembic/
├── env.py # Configuración del entorno
├── script.py.mako # Template para nuevas migraciones
├── versions/ # Archivos de migración
│ └── .gitkeep
alembic.ini # Configuración principal
Configurar para Async
# alembic/env.py
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.database import Base # Tu Base declarativa
from app.core.config import settings
config = context.config
# Configurar logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Metadata de tus modelos
target_metadata = Base.metadata
def get_url():
return settings.DATABASE_URL
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode (genera SQL sin conectar)."""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine."""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
alembic.ini
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
# La URL se configura en env.py
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
3. Workflow de Migraciones
Crear Migración Autogenerada
# Detecta cambios entre modelos y DB
alembic revision --autogenerate -m "add posts table"
Migración Generada
# alembic/versions/xxxx_add_posts_table.py
"""add posts table
Revision ID: a1b2c3d4e5f6
Revises: 9z8y7x6w5v4u
Create Date: 2024-02-01 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '9z8y7x6w5v4u'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'posts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_posts_author_id'), 'posts', ['author_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_posts_author_id'), table_name='posts')
op.drop_table('posts')
Comandos Esenciales
# Ver estado actual
alembic current
# Ver historial
alembic history --verbose
# Aplicar todas las migraciones pendientes
alembic upgrade head
# Aplicar una migración específica
alembic upgrade a1b2c3d4e5f6
# Rollback una migración
alembic downgrade -1
# Rollback a versión específica
alembic downgrade 9z8y7x6w5v4u
# Generar SQL sin aplicar (para review)
alembic upgrade head --sql > migration.sql
4. Data Migrations
La killer feature de Alembic: ejecutar código Python durante migraciones.
Ejemplo: Backfill de Campo Calculado
"""add full_name column and backfill
Revision ID: b2c3d4e5f6g7
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session
def upgrade() -> None:
# 1. Añadir columna (nullable inicialmente)
op.add_column('users', sa.Column('full_name', sa.String(200), nullable=True))
# 2. Backfill con datos existentes
bind = op.get_bind()
session = Session(bind=bind)
# Usar SQL directo para eficiencia
session.execute(
sa.text("""
UPDATE users
SET full_name = first_name || ' ' || last_name
WHERE full_name IS NULL
""")
)
session.commit()
# 3. Hacer NOT NULL después del backfill
op.alter_column('users', 'full_name', nullable=False)
def downgrade() -> None:
op.drop_column('users', 'full_name')
Ejemplo: Migración de Datos Entre Tablas
"""split address into separate table
Revision ID: c3d4e5f6g7h8
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session
def upgrade() -> None:
# 1. Crear nueva tabla
op.create_table(
'addresses',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('street', sa.String(200)),
sa.Column('city', sa.String(100)),
sa.Column('postal_code', sa.String(20)),
)
# 2. Migrar datos
bind = op.get_bind()
session = Session(bind=bind)
session.execute(sa.text("""
INSERT INTO addresses (user_id, street, city, postal_code)
SELECT id, address_street, address_city, address_postal
FROM users
WHERE address_street IS NOT NULL
"""))
session.commit()
# 3. Eliminar columnas viejas
op.drop_column('users', 'address_street')
op.drop_column('users', 'address_city')
op.drop_column('users', 'address_postal')
def downgrade() -> None:
# Restaurar columnas
op.add_column('users', sa.Column('address_street', sa.String(200)))
op.add_column('users', sa.Column('address_city', sa.String(100)))
op.add_column('users', sa.Column('address_postal', sa.String(20)))
# Migrar datos de vuelta
bind = op.get_bind()
session = Session(bind=bind)
session.execute(sa.text("""
UPDATE users u
SET
address_street = a.street,
address_city = a.city,
address_postal = a.postal_code
FROM addresses a
WHERE a.user_id = u.id
"""))
session.commit()
op.drop_table('addresses')
5. Migraciones Seguras en Producción
Reglas de Oro
- Nunca hagas DROP COLUMN directamente — Primero haz la columna nullable, despliega código que no la use, luego elimina
- Añade columnas como nullable — Luego backfill, luego NOT NULL
- Índices CONCURRENTLY — En PostgreSQL, crear índices sin bloquear
Crear Índice Sin Bloqueo
from alembic import op
def upgrade() -> None:
# Usar execute() para SQL raw con CONCURRENTLY
op.execute("""
CREATE INDEX CONCURRENTLY ix_posts_created_at
ON posts (created_at)
""")
def downgrade() -> None:
op.execute("DROP INDEX CONCURRENTLY ix_posts_created_at")
Rename de Columna Seguro (Zero Downtime)
# Migración 1: Añadir nueva columna
def upgrade() -> None:
op.add_column('users', sa.Column('email_address', sa.String(255)))
# Trigger para sincronizar durante transición
op.execute("""
CREATE OR REPLACE FUNCTION sync_email()
RETURNS TRIGGER AS $$
BEGIN
NEW.email_address := NEW.email;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_email_trigger
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_email();
""")
# Backfill existentes
op.execute("UPDATE users SET email_address = email")
# Migración 2 (después de desplegar código que use email_address):
def upgrade() -> None:
op.drop_column('users', 'email')
op.execute("DROP TRIGGER sync_email_trigger ON users")
op.execute("DROP FUNCTION sync_email()")
6. Testing de Migraciones
# tests/test_migrations.py
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
@pytest.fixture
def alembic_config():
config = Config("alembic.ini")
config.set_main_option("sqlalchemy.url", "postgresql://test:test@localhost/test_db")
return config
def test_migrations_up_down(alembic_config):
"""Test que todas las migraciones aplican y revierten correctamente."""
# Upgrade all
command.upgrade(alembic_config, "head")
# Downgrade all
command.downgrade(alembic_config, "base")
# Upgrade again (verifica idempotencia)
command.upgrade(alembic_config, "head")
def test_migration_generates_expected_tables(alembic_config):
"""Test que las migraciones crean las tablas esperadas."""
command.upgrade(alembic_config, "head")
engine = create_engine(alembic_config.get_main_option("sqlalchemy.url"))
with engine.connect() as conn:
result = conn.execute(text("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
"""))
tables = {row[0] for row in result}
assert "users" in tables
assert "posts" in tables
assert "alembic_version" in tables
7. CI/CD con Migraciones
GitHub Actions
# .github/workflows/migrate.yml
name: Database Migrations
on:
push:
branches: [main]
paths:
- 'alembic/**'
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install uv
uv sync --frozen
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
uv run alembic upgrade head
Script de Despliegue
#!/bin/bash
# deploy.sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting application..."
exec gunicorn -k uvicorn.workers.UvicornWorker main:app
Docker Entrypoint
# Dockerfile
COPY --chmod=755 scripts/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "main:app"]
#!/bin/bash
# scripts/entrypoint.sh
set -e
# Esperar a que la DB esté lista
echo "Waiting for database..."
while ! pg_isready -h $DB_HOST -p $DB_PORT -U $DB_USER; do
sleep 1
done
# Aplicar migraciones
echo "Running migrations..."
alembic upgrade head
# Ejecutar comando principal
exec "$@"
8. Múltiples Bases de Datos
# alembic/env.py para múltiples DBs
from alembic import context
def run_migrations_online():
# Determinar qué DB migrar basándose en argumento
db_name = context.get_x_argument(as_dictionary=True).get('db', 'main')
if db_name == 'main':
url = settings.MAIN_DATABASE_URL
metadata = main_metadata
elif db_name == 'analytics':
url = settings.ANALYTICS_DATABASE_URL
metadata = analytics_metadata
# ... resto de configuración
# Uso
alembic -x db=main upgrade head
alembic -x db=analytics upgrade head
9. Troubleshooting Común
”Target database is not up to date”
# Ver estado actual
alembic current
# Marcar como aplicada sin ejecutar (si ya aplicaste manualmente)
alembic stamp head
Conflictos de Branches
# Ver branches
alembic branches
# Merge branches
alembic merge -m "merge branches" rev1 rev2
Migración Autogenerate No Detecta Cambios
Asegúrate de importar todos los modelos en env.py:
# alembic/env.py
from app.models.user import User # Importar explícitamente
from app.models.post import Post
from app.database import Base
target_metadata = Base.metadata
10. Comparativa Final
| Aspecto | Drizzle Kit | Alembic |
|---|---|---|
| Output | SQL puro | Python + SQL |
| Data migrations | ❌ Manual | ✅ Integrado |
| Rollback | Manual | ✅ downgrade() |
| Autogenerate | ✅ | ✅ |
| Branches | ❌ | ✅ |
| Testing | Manual | ✅ Con fixtures |
Conclusión
Alembic ofrece un control superior sobre migraciones comparado con herramientas de Node.js:
- Migraciones como código — Python completo, no solo SQL
- Data migrations — Transforma datos durante schema changes
- Downgrades — Rollback estructurado
- Branches — Maneja desarrollo paralelo
Pattern Senior: Siempre revisa las migraciones autogeneradas antes de aplicar. Alembic puede generar operaciones destructivas si no entiende tu intención.
En el siguiente capítulo, implementaremos autenticación y autorización con OAuth2 y JWT.