Después de algunos meses de desarrollo, no es raro acumular 30, 50 o incluso 100 archivos de migración en una aplicación Django. Cada ejecución de tests que parte de una base vacía los reproduce todos. Cada despliegue en un entorno nuevo también. squashmigrations permite fusionar varias migraciones en una sola, sin perder la compatibilidad con los entornos que ya han aplicado migraciones anteriores.

El comando y lo que genera

La sintaxis acepta un rango de migraciones:

python manage.py squashmigrations <app_label> [migración_inicio] <migración_fin>

El parámetro migración_inicio es opcional. Sin él, Django parte de la primera migración de la aplicación. Ejemplos concretos:

# Fusionar de 0001 a 0020
python manage.py squashmigrations myapp 0001 0020

# Fusionar desde el inicio hasta 0020
python manage.py squashmigrations myapp 0020

# Nombrar el archivo fusionado
python manage.py squashmigrations myapp 0001 0020 --squashed-name initial_clean

Django genera un archivo 0001_squashed_0020_*.py. El optimizador integrado elimina las operaciones redundantes: una columna añadida y luego eliminada desaparece completamente del archivo final, una columna renombrada dos veces se reduce a una sola operación.

El atributo replaces: el mecanismo central

El archivo fusionado contiene un atributo replaces que Django añade automáticamente:

class Migration(migrations.Migration):
    replaces = [
        ("myapp", "0001_initial"),
        ("myapp", "0002_add_slug"),
        ("myapp", "0003_add_index"),
        # ...
        ("myapp", "0020_add_status"),
    ]

    operations = [
        migrations.CreateModel(
            name="Article",
            fields=[
                ("id", models.BigAutoField(primary_key=True)),
                ("slug", models.SlugField(unique=True)),
                ("status", models.CharField(max_length=20, default="draft")),
            ],
        ),
        # ... otras operaciones optimizadas
    ]

Este atributo permite a Django gestionar la coexistencia del archivo fusionado y los originales. En cada llamada a migrate, Django consulta replaces y decide qué hacer según el estado de la tabla django_migrations:

  • Entorno nuevo (ninguna migración aplicada): Django ejecuta el archivo fusionado directamente como una migración normal.
  • Entorno actualizado (todas las migraciones 0001 a 0020 presentes en django_migrations): Django marca el fusionado como aplicado sin ejecutarlo.
  • Entorno parcialmente migrado (0001 a 0012 aplicadas, 0013 a 0020 pendientes): Django ejecuta las migraciones originales que faltan, luego marca el fusionado como aplicado.

Este mecanismo garantiza que el archivo fusionado se puede desplegar sin romper los entornos que ya han aplicado parte de las migraciones originales.

El proceso de limpieza en tres pasos

Fusionar genera un nuevo archivo, pero no elimina los originales. El objetivo real, aligerar el proyecto de forma duradera, requiere tres pasos.

Paso 1: fusionar

python manage.py squashmigrations myapp 0001 0020

Se crea el archivo 0001_squashed_0020_*.py. Los archivos originales se conservan, todavía son necesarios para los entornos parcialmente migrados.

Paso 2: desplegar y verificar

Desplegar el código incluyendo el archivo fusionado en todos los entornos (producción, staging, máquinas de desarrollo). Verificar que la entrada del fusionado está presente en django_migrations en cada entorno:

SELECT app, name FROM django_migrations WHERE app = 'myapp' ORDER BY id;

Una vez que todos los entornos tienen la entrada 0001_squashed_0020_*, las migraciones originales ya no se utilizan.

Paso 3: limpiar

Eliminar los archivos originales (0001 a 0020) y vaciar el atributo replaces en el archivo fusionado para que se convierta en una migración ordinaria:

class Migration(migrations.Migration):
    replaces = []  # vaciar la lista, o eliminar el atributo

    operations = [...]

A partir de aquí, el proyecto tiene un único archivo de migración para ese rango, sin dependencia de los originales eliminados.

Lo que el optimizador no puede hacer

El optimizador de Django es eficaz con las operaciones de esquema estándar: CreateModel, AddField, RemoveField, AlterField, RenameField. Fusiona, reordena y elimina redundancias automáticamente.

RunPython y RunSQL se conservan tal cual en el archivo fusionado. Django no puede determinar si dos funciones Python son equivalentes o si su orden puede modificarse. El archivo generado las incluye con un comentario que indica su procedencia:

migrations.RunPython(
    populate_slugs,
    reverse_code=migrations.RunPython.noop,
),

Si varias operaciones RunPython se encadenan en el squash y manipulan los mismos datos, hay que revisarlas manualmente para evitar efectos secundarios. Aquí es donde el squash suele requerir más trabajo.

El flag --no-optimize desactiva completamente el optimizador cuando las operaciones son demasiado interdependientes para reordenarse sin riesgo:

python manage.py squashmigrations myapp 0001 0020 --no-optimize

Cuándo fusionar y cuándo esperar

Fusionar es seguro cuando las migraciones a combinar no se han aplicado aún en producción: se combina código en desarrollo sin restricciones de compatibilidad.

La situación se complica cuando algunas migraciones están en producción y otras no. Ejemplo: producción está en la migración 0015 y se quiere fusionar 0001 a 0020. El archivo fusionado cubre 0001 a 0020, pero producción todavía necesita los originales 0016 a 0020 para llegar al estado final. Django los busca, los encuentra, los aplica y marca el fusionado como hecho. Todo funciona, siempre que no se eliminen los originales antes de que producción esté al día.

Eliminar los archivos originales antes del paso 2 rompe los entornos parcialmente migrados: Django busca las migraciones que faltan, no las encuentra y lanza un error.

Impacto en los tests

Uno de los beneficios más inmediatos es la velocidad de configuración de los tests. Django reproduce todas las migraciones para crear la base de datos de test (--keepdb conserva la base entre ejecuciones, pero la primera creación sigue siendo completa). Pasar de 80 migraciones a 5 o 6 puede reducir el tiempo de setup en varias decenas de segundos según la complejidad del esquema.

Para medir el impacto antes y después:

time python manage.py migrate

squashmigrations es una operación de mantenimiento, no una urgencia. Hacerlo demasiado tarde complica la limpieza; hacerlo demasiado pronto, antes de que todos los entornos estén sincronizados, es arriesgado. El momento adecuado es cuando termina una fase de desarrollo y antes de que las migraciones lleguen a producción.

Para profundizar en el ORM de Django: Django select_for_update(): bloqueo de filas y concurrencia.