Migraciones de base de datos (Alembic)¶
Guía práctica para crear, validar y aplicar migraciones de esquema en el backend de Camarero IA. El backend usa PostgreSQL (driver asyncpg) tanto en local como en producción, SQLAlchemy 2.x async y Alembic para versionar el esquema.
Las migraciones viven en backend/alembic/versions/. La configuración del entorno está en backend/alembic/env.py.
Cuándo necesitas una migración
Cualquier cambio en el esquema de la BD: tabla nueva, columna nueva, índice, constraint o cambio de tipo. Si añades o tocas un modelo ORM y eso cambia la forma de una tabla, necesitas una migración.
Convenciones obligatorias¶
Estas reglas son normativas (ver backend/AGENTS.md). Síguelas siempre.
| Regla | Detalle |
|---|---|
| Nombre de fichero | YYYYMMDD_descripcion_breve.py (p. ej. 20260614_table_lifecycle_fields.py). |
Un solo head |
La cadena de revisiones debe ser lineal: una única cabeza. No crees ramas. |
upgrade() y downgrade() |
Ambos completos e inversos: downgrade() deshace exactamente lo que hace upgrade(). |
| NOT NULL en tabla existente | Una columna nullable=False nueva sobre una tabla con datos requiere un server_default (o bien añadirla nullable=True primero y rellenar después). |
| Registrar el modelo | Importa el modelo nuevo en app/main.py y en alembic/env.py para que autogenerate lo detecte. |
| Inmutabilidad | Nunca edites una migración ya aplicada. Si necesitas corregir algo, crea una migración nueva encima. |
Enums como String, no como tipo Enum
Por convención del repo, los enums se almacenan como sa.String(N) con el .value, nunca como el tipo Enum de SQLAlchemy. Una migración que añade un campo enum añade una columna String.
Registrar el modelo nuevo (clave para autogenerate)¶
Alembic solo detecta los cambios de los modelos cuyas tablas estén registradas en Base.metadata. En este proyecto eso ocurre por efecto secundario del import: cada módulo de modelo, al importarse, registra sus tablas en el Base compartido.
En alembic/env.py el target_metadata es Base.metadata y, justo encima, se importan todos los paquetes de modelos por módulo, por ejemplo:
from app.core.database import Base # noqa: E402
# ...
from app.modules.table.infrastructure.models import table_model # noqa: E402, F401
from app.modules.session.infrastructure.models import ( # noqa: E402, F401
cart_model,
idempotency_model,
manager_call_model,
session_model,
)
Si tu tabla no aparece en el autogenerate
Casi siempre es porque el modelo no está importado. Añade el import del modelo nuevo en alembic/env.py (con # noqa: E402, F401) y en app/main.py, siguiendo el mismo orden que ya usa la app. Sin ese import, alembic revision --autogenerate generará una migración vacía.
Crear una migración¶
- Define o modifica el modelo ORM en su módulo (
app/modules/{m}/infrastructure/models/...). - Asegúrate de que el modelo está importado en
alembic/env.pyy enapp/main.py(ver sección anterior). - Genera la revisión con autogenerate:
- Revisa el fichero generado a mano. Autogenerate es un punto de partida, no la verdad final: comprueba el
down_revision, elserver_defaultde columnas NOT NULL, los índices y quedowngrade()sea el inverso exacto. - Renombra el fichero si hace falta para que cumpla el patrón
YYYYMMDD_descripcion.py.
down_revision y la cadena lineal
El down_revision de tu migración debe apuntar a la revisión que era head antes de la tuya. Si dos personas crean migraciones en paralelo aparecen dos heads: hay que rebasar una de ellas para que apunte a la otra y restaurar la linealidad.
Reglas para columnas¶
Columna NOT NULL nueva en una tabla existente¶
Una tabla en producción ya tiene filas. Si añades una columna nullable=False sin valor por defecto, la migración falla porque las filas existentes no pueden satisfacer la restricción. Dos opciones válidas:
- Opción A (recomendada):
server_default. La columna se creanullable=Falsecon un valor por defecto a nivel de servidor, que rellena las filas existentes. - Opción B: dos pasos. Añadirla
nullable=True, rellenar los datos en la propia migración, y después aplicar elALTERa NOT NULL.
Las columnas que son nullable=True no tienen este problema y se pueden añadir directamente.
Ejemplo real del repo¶
La migración backend/alembic/versions/20260614_table_lifecycle_fields.py añade campos de ciclo de vida de mesa a table_sessions para el dashboard de control de sala del manager. Ilustra todas las convenciones a la vez.
Cabecera con los identificadores de revisión (nótese la cadena lineal vía down_revision):
revision: str = "20260614_table_lifecycle_fields"
down_revision: Union[str, Sequence[str], None] = "20260613_selected_options_lines"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
El upgrade() añade una columna NOT NULL con server_default (para que las filas existentes hagan backfill limpio) y el resto como nullable. El enum se almacena como String:
def upgrade() -> None:
op.add_column(
"table_sessions",
sa.Column(
"lifecycle_state",
sa.String(length=30),
server_default="READING",
nullable=False,
),
)
op.create_index(
op.f("ix_table_sessions_lifecycle_state"),
"table_sessions",
["lifecycle_state"],
unique=False,
)
op.add_column(
"table_sessions", sa.Column("seated_at", sa.DateTime(timezone=True), nullable=True)
)
# ... más columnas nullable timezone-aware ...
op.add_column(
"table_sessions", sa.Column("table_profile", sa.String(length=30), nullable=True)
)
El downgrade() es el inverso exacto: borra las columnas en orden inverso y elimina el índice antes de la columna que indexa:
def downgrade() -> None:
op.drop_column("table_sessions", "table_profile")
# ... drops en orden inverso ...
op.drop_index(
op.f("ix_table_sessions_lifecycle_state"), table_name="table_sessions"
)
op.drop_column("table_sessions", "lifecycle_state")
Qué aprender de este ejemplo
lifecycle_statees NOT NULL → llevaserver_default="READING".- Los timestamps son
DateTime(timezone=True)(timezone-aware, convención del proyecto). - El enum se guarda como
String(30), no como tipoEnum. downgrade()borra en orden inverso y suelta el índice antes que su columna.
Verificar la migración¶
Antes de dar la migración por buena, aplícala contra una base de datos desechable y comprueba que sube y baja sin errores.
# Aplicar todas las migraciones pendientes hasta la cabeza
alembic upgrade head
# (recomendado) Verificar que el downgrade también funciona
alembic downgrade -1
alembic upgrade head
Comprobaciones útiles del estado de las revisiones:
# Revisión(es) actualmente aplicada(s) en la BD
alembic current
# La cabeza de la cadena de revisiones — debe ser UNA sola
alembic heads
# Historial completo de revisiones
alembic history
Más de un head = cadena rota
Si alembic heads devuelve más de una revisión, hay un merge pendiente: dos migraciones comparten el mismo down_revision. Reapunta el down_revision de una de ellas a la otra para volver a una cadena lineal antes de mergear.
Bootstrap de BD vacía
En este repo, sobre una base de datos vacía, env.py no reproduce toda la cadena de revisiones: crea el esquema canónico actual con Base.metadata.create_all() y luego hace stamp a head. Las bases de datos ya existentes sí siguen el camino incremental normal de alembic upgrade. Por eso, para verificar el camino incremental de tu migración, parte de una BD que ya tenga el esquema previo aplicado, no de una vacía.
Errores comunes¶
- La migración sale vacía. El modelo no está importado en
alembic/env.py/app/main.py. Añade el import y vuelve a generar. upgrade headfalla con NOT NULL. Añadiste una columnanullable=Falsesinserver_defaultsobre una tabla con datos. Usaserver_defaulto el patrón de dos pasos.- Dos heads. Dos migraciones en paralelo. Reapunta
down_revisiony restaura la linealidad. - Editaste una migración ya aplicada. No lo hagas: las BD que ya la aplicaron no la re-ejecutan. Crea una migración nueva encima.