Módulo 2 · Bases de datos

Lección 11 — Migraciones versionadas

Cambios de esquema sin caída: cómo evolucionar TicketFlow con datos reales dentro.

Publicada
En esta lección
  1. Ejercicio 1 — Expand-contract
  2. Ejercicio 2 — NOT NULL
  3. Ejercicio 3 — CONCURRENTLY
  4. Ejercicio 4 — Las tres mejoras
  5. Resumen del profesor

Ejercicio 1 — Expand-contract

1-2. La migración de datos por lotes:

python
def copy_title(apps, schema_editor):
    Event = apps.get_model("events", "Event")
    qs = Event.objects.filter(label__isnull=True).order_by("pk")
    batch = 1000
    while True:
        items = list(qs[:batch])
        if not items:
            break
        for e in items:
            e.label = e.title
        Event.objects.bulk_update(items, ["label"], batch_size=batch)
  1. Dual write: en save() del modelo o en los servicios de creación se setean ambos campos. Los 3 eventos de prueba tienen ambas columnas iguales.
  2. Tras CONTRACT (RemoveField de title), el código viejo que hace event.title lanza FieldError/OperationalError: por eso el paso 4 solo ocurre cuando NINGÚN pod corre código viejo (el orden de despliegue es parte del diseño).

Ejercicio 2 — NOT NULL

  1. Django genera el AlterField con default; PostgreSQL moderno hace el default como metadato y luego valida — pero el default queda "pegado" al schema (y con versiones/acciones variadas puede rewrite): el camino explícito evita sorpresas y documenta la intención.
  2. Camino largo: nullable (instantáneo) → backfill por lotes (no bloquea, memoria controlada) → AlterField NOT NULL (valida sin backfill pendiente). Con \timing ves que el backfill es lo lento — y por lotes no bloquea a nadie.
  3. El linter señala las AddField NOT NULL, RemoveField y AlterField de tipo de tu historial: la lista es tu deuda técnica de migraciones, para planear (no para asustarse).

Ejercicio 3 — CONCURRENTLY

  1. Migración: class Migration: atomic = False; operations = [RunSQL("CREATE INDEX CONCURRENTLY idx_reservation_created ON events_reservation (created_at);", reverse_sql="DROP INDEX IF EXISTS idx_reservation_created;")]
  2. Sin CONCURRENTLY, el CREATE INDEX toma un lock que bloquea INSERT/UPDATE de la tabla mientras dura el build (en tablas grandes: segundos-minutos de API parada). Si un CONCURRENTLY falla a medias, queda un índice INVALID que ocupa y NO se usa — hay que DROP y reintentar.
  3. El check SQL devuelve los índices inválidos; en CI/después de deploys grandes es un buen smoke.

Ejercicio 4 — Las tres mejoras

  1. NOT NULL: nullable → backfill (valores razonables: created_at para updated_at vacíos, etc.) → AlterField.
  2. CHECK expires_at > created_at: primero corrige filas viejas que lo violen (¿hay? SELECT COUNT(*) WHERE NOT (expires_at > created_at)); después AddConstraint. Si hay violadores, la migración falla al aplicar — por eso el orden: corregir, luego restringir.
  3. Slug: nullable → backfill (slugify(title) + id corto, garantizando unicidad con retry por colisión) → UNIQUE. (El AddConstraint UniqueField se aplica tras el backfill para no fallar.)

Resumen del profesor

  • Migración = historia versionada: una intención, reverse siempre, lo desplegado no se toca.
  • Expand → dual write → switch → contract: el patrón que hace el renombrado sin apagar nada.
  • Lo bloqueante (NOT NULL a ciegas, índices grandes, ALTER TYPE) se hace por el camino largo y medido.
  • apps.get_model y lotes en RunPython: determinismo y memoria.

Después de la corrección: Lección 12 — NoSQL, cierre del módulo de datos.