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:

  1. Schema vacío (DB recién creada) — generas migration con autogenerate y la aplicas. Crea las tablas.
  2. 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

  1. Orden de creación correcto: tablas con FK deben crearse después de la tabla referida. Alembic generalmente lo hace bien, pero verifica.
  2. downgrade invierte correctamente: drops en orden inverso a creates.
  3. Server defaults se reflejan: gen_random_uuid(), now(), 'true', 'false', etc.
  4. Constraints están todas: PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK.
  5. Tipos correctos: postgresql.UUID(as_uuid=True), DateTime(timezone=True), Text, etc.
  6. 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

EscenarioCaso
Proyecto nuevo, sin DB existenteCaso 1
DB existente que quieres adoptarCaso 2
Forzar reset desde AlembicCaso 1 + reset previo
Producción ya con schema, agregar AlembicCaso 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:

  1. docker compose up -d (crea DB y usuario default)
  2. db/setup.sql solo para crear blog_user con permisos
  3. alembic upgrade head para 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:

  1. Asegurar que Base.metadata y la DB son idénticos
  2. Si la DB es la fuente de verdad, ajusta los modelos
  3. Si los modelos son la fuente de verdad y la DB está desactualizada, ejecuta los cambios manualmente o drop y upgrade head desde scratch (en dev)

Autogenerate genera muchísimos cambios

Probablemente:

  • No agregaste naming_convention y los nombres de constraints difieren
  • compare_type=True detecta 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 posts con 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", ...) con sa.ForeignKeyConstraint(["author_id"], ...) y sa.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 head para no recrear
  • Siempre revisa la migration generada antes de aplicar
  • alembic_version mantiene la revisión actual de la DB
  • upgrade y downgrade son simétricos — practica ambos
  • db/setup.sql ahora 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

  1. Alembic Tutorial — Initial Migration
  2. alembic stamp documentation
  3. "Adopting Alembic in an Existing Project"

Siguiente: Cápsula 04 — Autogenerate.