Módulo 5: Alembic Migrations
Migration inicial: el "punto cero"
Descripción
La migration inicial es la que captura todo tu schema actual en código. A partir de ella, todas las migrations futuras describirán cambios incrementales. Es el "punto cero" desde el que reconstruyes la DB en cualquier ambiente con un solo comando.
Hay dos escenarios:
- Schema vacío (DB recién creada) — generas migration con autogenerate y la aplicas. Crea las tablas.
- Schema ya existente (caso del blog: ya creaste tablas con
db/setup.sql) — necesitas "adoptar" Alembic sin re-ejecutar el CREATE TABLE.
Esta cápsula cubre ambos. Verás alembic stamp para el caso 2 y autogenerate desde scratch para el caso 1.
Caso 1: schema vacío (workflow normal en proyectos nuevos)
Esta es la situación ideal. Empiezas con DB vacía:
# Resetear: drop todo y recrear vacío
docker compose down -v
docker compose up -d
sleep 5
Ahora la DB blog_dev existe pero está sin tablas. Verifica:
docker exec -it blog-postgres psql -U blog_user -d blog_dev -c "\dt"
# Did not find any relations.
En este caso, NO ejecutamos
db/setup.sql. Vamos a dejar que Alembic cree las tablas desde la migration inicial.
Generar la migration inicial
alembic revision --autogenerate -m "initial schema"
Output:
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'categories'
INFO [alembic.autogenerate.compare] Detected added table 'tags'
INFO [alembic.autogenerate.compare] Detected added table 'users'
INFO [alembic.autogenerate.compare] Detected added table 'posts'
INFO [alembic.autogenerate.compare] Detected added table 'comments'
INFO [alembic.autogenerate.compare] Detected added table 'post_tags'
Generating /path/to/alembic/versions/2026-04-25_1430_a1b2c3_initial_schema.py ... done
Alembic detectó todas las tablas en Base.metadata y generó una migration que las crea.
Revisar la migration generada
ESTO ES CRÍTICO: lee el archivo generado antes de aplicar.
# alembic/versions/2026-04-25_1430_a1b2c3_initial_schema.py
"""initial schema
Revision ID: a1b2c3
Revises:
Create Date: 2026-04-25 14:30:00.123456
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "a1b2c3"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"categories",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("slug", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("length(name) BETWEEN 1 AND 50", name="ck_categories_categories_name_check"),
sa.CheckConstraint("slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$'", name="ck_categories_categories_slug_check"),
sa.PrimaryKeyConstraint("id", name="pk_categories"),
sa.UniqueConstraint("name", name="uq_categories_name"),
sa.UniqueConstraint("slug", name="uq_categories_slug"),
)
op.create_table(
"tags",
# ... similar a categories
)
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("email", sa.Text(), nullable=False),
sa.Column("username", sa.Text(), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
# ... etc.
sa.PrimaryKeyConstraint("id", name="pk_users"),
sa.UniqueConstraint("email", name="uq_users_email"),
sa.UniqueConstraint("username", name="uq_users_username"),
)
op.create_table(
"posts",
# ... con foreign keys a users y categories
)
op.create_table(
"comments",
# ... con FKs y self-reference
)
op.create_table(
"post_tags",
# ... junction table
)
def downgrade() -> None:
op.drop_table("post_tags")
op.drop_table("comments")
op.drop_table("posts")
op.drop_table("users")
op.drop_table("tags")
op.drop_table("categories")
Cosas que debes verificar al revisar
- Orden de creación correcto: tablas con FK deben crearse después de la tabla referida. Alembic generalmente lo hace bien, pero verifica.
downgradeinvierte correctamente: drops en orden inverso a creates.- Server defaults se reflejan:
gen_random_uuid(),now(),'true','false', etc. - Constraints están todas: PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK.
- Tipos correctos:
postgresql.UUID(as_uuid=True),DateTime(timezone=True),Text, etc. - Indexes esperados: los del módulo 3 NO aparecen aún porque no están en los modelos. Los agregaremos en migrations posteriores.
Aplicar la migration
alembic upgrade head
Output:
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> a1b2c3, initial schema
Verifica:
docker exec -it blog-postgres psql -U blog_user -d blog_dev -c "\dt"
Esperado: 7 tablas (las 6 del schema + alembic_version).
List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+-----------
public | alembic_version | table | blog_user
public | categories | table | blog_user
public | comments | table | blog_user
public | post_tags | table | blog_user
public | posts | table | blog_user
public | tags | table | blog_user
public | users | table | blog_user
Verificar alembic_version
docker exec -it blog-postgres psql -U blog_user -d blog_dev -c "SELECT * FROM alembic_version;"
Output:
version_num
-------------
a1b2c3
Tu DB ahora "sabe" en qué revisión está.
Re-poblar datos
La migration solo crea las tablas vacías. Para tener datos de prueba:
docker exec -i blog-postgres psql -U blog_user -d blog_dev < db/seed.sql
docker exec -i blog-postgres psql -U blog_user -d blog_dev < db/indexes.sql
En módulos siguientes (cápsula 06) veremos cómo poner los datos iniciales dentro de una migration ("data migration").
Caso 2: schema ya existente (adoptar Alembic en proyecto vivo)
Si ya tienes el schema creado con db/setup.sql y NO quieres recrearlo, usa alembic stamp:
Paso 1: Asegurar que la DB tiene el schema
docker exec -i blog-postgres psql -U postgres -d blog_dev < db/setup.sql
docker exec -i blog-postgres psql -U blog_user -d blog_dev < db/seed.sql
Paso 2: Generar la migration inicial (sin aplicarla)
alembic revision --autogenerate -m "initial schema"
⚠️ Como el schema ya existe, autogenerate NO debería detectar cambios. Si detecta diferencias, hay un mismatch entre tus modelos y el schema real (posibles diferencias en defaults, tipos, etc.).
Paso 3: Stamp en lugar de upgrade
alembic stamp head
Esto actualiza la tabla alembic_version para apuntar a head sin ejecutar las migrations. Le dice a Alembic: "asume que el schema actual ya corresponde a head".
alembic current
# a1b2c3 (head)
Cuándo usar Caso 1 vs Caso 2
| Escenario | Caso |
|---|---|
| Proyecto nuevo, sin DB existente | Caso 1 |
| DB existente que quieres adoptar | Caso 2 |
| Forzar reset desde Alembic | Caso 1 + reset previo |
| Producción ya con schema, agregar Alembic | Caso 2 |
Para esta guía, recomendamos Caso 1 porque es más limpio: la migration inicial actúa como documentación del schema.
Crear DB de testing con la migration
Para CI/CD y tests, querrás recrear la DB desde migrations:
# Levantar DB limpia
docker compose down -v && docker compose up -d
sleep 5
# Aplicar todas las migrations
alembic upgrade head
# (Opcional) cargar fixtures
docker exec -i blog-postgres psql -U blog_user -d blog_dev < db/seed.sql
Esto debe ser idempotente y reproducible. Si funciona, tu setup es sólido.
Pre-requisito: el usuario blog_user y la DB ya deben existir
Alembic NO crea la DB ni el usuario — eso lo hace PostgreSQL al iniciar. El bootstrap inicial sigue siendo:
docker compose up -d(crea DB y usuario default)db/setup.sqlsolo para crearblog_usercon permisosalembic upgrade headpara crear las tablas
Modifica db/setup.sql para que solo cree el usuario y permisos (no las tablas):
-- db/setup.sql — VERSIÓN POST-ALEMBIC
-- Solo bootstrap: extensiones + usuario. Las tablas las crea Alembic.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'blog_user') THEN
CREATE ROLE blog_user WITH LOGIN PASSWORD 'blog_user_pass';
END IF;
END $$;
GRANT ALL PRIVILEGES ON DATABASE blog_dev TO blog_user;
GRANT ALL ON SCHEMA public TO blog_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO blog_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO blog_user;
\echo 'Bootstrap completado. Ahora ejecuta: alembic upgrade head'
Hacer downgrade y volver a upgrade
alembic current
# a1b2c3 (head)
alembic downgrade base
# Revierte: drops todas las tablas
# alembic_version queda en estado "vacío"
alembic current
# (sin output: no current revision)
alembic upgrade head
# Recrea todas las tablas
Esto es un excelente test: si tu migration funciona en ambos sentidos, está bien escrita.
Pierdes los datos al hacer
downgrade base. En desarrollo está bien; en producción es un evento serio.
Errores comunes
Target database is not up to date
La DB tiene una revisión anterior a head. Solución: alembic upgrade head.
Autogenerate genera una migration que repite tablas existentes
Hay mismatch entre tus modelos y el schema real. Soluciones:
- Asegurar que
Base.metadatay la DB son idénticos - Si la DB es la fuente de verdad, ajusta los modelos
- Si los modelos son la fuente de verdad y la DB está desactualizada, ejecuta los cambios manualmente o
dropyupgrade headdesde scratch (en dev)
Autogenerate genera muchísimos cambios
Probablemente:
- No agregaste
naming_conventiony los nombres de constraints difieren compare_type=Truedetecta diferencias de tipo sutiles- Hay tablas en la DB que no están en
models.py(o viceversa)
Revisa diff por diff y decide qué hacer.
permission denied to create extension
La extensión pgcrypto debe crearse como superuser. Si Alembic intenta crearla, configura db/setup.sql para hacerlo manualmente antes.
Ejercicios
Ejercicio 1. Empezando desde DB vacía, genera la migration inicial y aplícala. Verifica que las 6 tablas (más alembic_version) están creadas.
Solución
# Reset
docker compose down -v && docker compose up -d
sleep 5
# Bootstrap mínimo (extensión + usuario)
docker exec -i blog-postgres psql -U postgres -d blog_dev < db/setup.sql
# Generar migration
alembic revision --autogenerate -m "initial schema"
# Aplicar
alembic upgrade head
# Verificar
docker exec -it blog-postgres psql -U blog_user -d blog_dev -c "\dt"
Esperado: 7 tablas listadas.
Ejercicio 2. Lee la migration generada. Localiza:
- La tabla
postscon sus 2 FKs (a users y categories) - El CHECK constraint
published_has_date - La junction table
post_tags
Solución
cat alembic/versions/*initial_schema.py
Busca:
op.create_table("posts", ...)consa.ForeignKeyConstraint(["author_id"], ...)ysa.ForeignKeyConstraint(["category_id"], ...)sa.CheckConstraint("(published = ...)", name="...published_has_date")op.create_table("post_tags", ...)con composite PK
Si algo falta, autogenerate no lo detectó — revisa los modelos.
Ejercicio 3. Haz downgrade base y luego upgrade head. Verifica que las tablas se recrean correctamente.
Solución
alembic downgrade base
docker exec -it blog-postgres psql -U blog_user -d blog_dev -c "\dt"
# (vacío excepto alembic_version)
alembic upgrade head
docker exec -it blog-postgres psql -U blog_user -d blog_dev -c "\dt"
# 7 tablas de nuevo
Ejercicio 4. Carga el seed después del upgrade y verifica que los datos están.
Solución
docker exec -i blog-postgres psql -U blog_user -d blog_dev < db/seed.sql
docker exec -it blog-postgres psql -U blog_user -d blog_dev -c \
"SELECT count(*) FROM users; SELECT count(*) FROM posts;"
# 5 usuarios, 10 posts (del seed)
Ejercicio 5. Imprime el SQL que upgrade ejecutaría sin aplicarlo (modo dry run).
Solución
# Reset primero
alembic downgrade base
# Dry run
alembic upgrade head --sql
# Imprime el SQL completo: BEGIN / CREATE TABLE / ... / INSERT INTO alembic_version / COMMIT
Útil para code review de migrations: ves exactamente qué SQL va a correr.
Resumen
- Migration inicial captura todo el schema actual
- Caso 1 (vacío):
alembic revision --autogenerate+alembic upgrade head - Caso 2 (existente): generar +
alembic stamp headpara no recrear - Siempre revisa la migration generada antes de aplicar
alembic_versionmantiene la revisión actual de la DBupgradeydowngradeson simétricos — practica ambosdb/setup.sqlahora solo bootstrap (extensiones, usuario), las tablas las crea Alembic
En la siguiente cápsula, el día a día: autogenerate cuando modificas modelos.
Recursos Adicionales
- Alembic Tutorial — Initial Migration
alembic stampdocumentation- "Adopting Alembic in an Existing Project"
Siguiente: Cápsula 04 — Autogenerate.