Doc update: Describe steps for manually squashing migrations

Hello there.
Recently I needed to manually squash the migrations, after trying to use squashmigrations and failing hard several times, I’ve used the a approach:

Here’s what I’ve done, hopefully this can help someone in the future.

Step 1

Remove all migrations from all apps and run makemigrations
Since I had quite a few apps, I wrote a small script to:

  • detect all apps with migrations and delete them;
  • recreate migrations

Just remember to take note of all the apps that had migrations recreated, you’ll need it on Step 4.

Delete all migration files inside the apps migrations folder Script
import subprocess
from collections.abc import Generator
from pathlib import Path


def main():
    app_dirs_with_migrations = get_app_dirs_with_migrations()
    for migration_files in app_dirs_with_migrations.values():
        for migration_file in migration_files:
            migration_file.unlink()

    # Recreate all migrations
    subprocess.run(["uv", "run", "python", "manage.py", "makemigrations"], check=False)  # noqa: S607


def get_app_dirs_with_migrations() -> dict[Path, list[Path]]:
    project_root = Path.cwd().joinpath("your-root-project-dir")

    apps_with_migrations: dict[Path, list[Path]] = {}
    for app_dir in iter_path_dirs(project_root):
        migrations = migration_files_from_app(app_dir)
        if not migrations:
            continue
        apps_with_migrations[app_dir] = migrations
    return apps_with_migrations


def iter_path_dirs(path: Path) -> Generator[Path, None, None]:
    for sub_path in path.iterdir():
        if sub_path.is_dir() and sub_path.stem not in ("__pycache__", "static", "templates"):
            yield sub_path


def migration_files_from_app(app_dir: Path) -> list[Path]:
    migrations = []
    for app_sub_folder in iter_path_dirs(app_dir):
        if app_sub_folder.stem != "migrations":
            continue
        for file in app_sub_folder.iterdir():
            if file.stem.startswith("__"):
                continue
            migrations.append(file)
    return sorted(migrations)


if __name__ == "__main__":
    main()

Step 2

Running the new created migrations on a clean database
Since some of my models are using typing.Generic the migrations were using the typing.Generic class in the migrations.CreateModel operation, under the options bases, this was causing an error. So I needed to manually edit some of the files to remove the typing.Generic from the bases option inside some migrations.
To run the migrations from scratch on a clean database I’ve used docker for that.

test.yml

services:
  django:
    build:
      context: .
      dockerfile: ./compose/local/django/Dockerfile
    image: project_local_django
    container_name: project_test
    depends_on:
      - test_postgres
      - redis
    environment:
      POSTGRES_HOST: test_postgres
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_PORT: 5432
      POSTGRES_DB: project
      REDIS_URL: "redis://redis:6379/0"
    command: pytest --cov=bid_capital_loan_service --cov-report=html:cov_report ${PYTEST_ARGS:-""}
    volumes:
      - .cov_report:/app/cov_report

  test_postgres:
    image: docker.io/postgres:16
    container_name: bid_test_postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: project
    ports:
      - 5445:5432

  redis:
    image: docker.io/redis:6
    container_name: your_project_local_redis
    ports:
      - 6380:6379
bash script to run migrations
docker compose -f test.yml down
docker compose -f test.yml up test_postgres -d
docker compose -f test.yml build
docker compose -f test.yml run django python manage.py migrate
docker compose -f test.yml down

Step 3

Running migrate --fake-initial on the target database

Since I had more than one environment, I’ve targeted the UAT environment for safety first (You need to make sure that all target databases are on the same version, all migrations that were deleted are applied on them).

uv run python manage.py migrate --fake-initial
This marked all the new “migrations” as being applied on the target database.

Step 4

Running migrate app_name --prune on the target database
After all the migrations were faked, I ran the migrate --prune command to remove all migrations that no longer existed. You need to run this for each app though, that’s quite painful.
uv run python manage.py migrate app_name --prune

Step 5

Assert that no migrations needs to be applied
Since we did some massive changes, it’s a good double check that you don’t have any migrations to be applied after the changes.
uv run python manage.py migrate --plan

Finale

That’s it, all apps now have one (or just a few) migrations. Notice that any local (other) environment will need to do steps 3-5 after this goes to your main branch (or just drop the database).