Skip to main content

Database Migration Management

The database migration system in this codebase provides a robust framework for evolving database schemas over time. It relies on a set of management commands that interface with core migration components like the MigrationLoader, MigrationAutodetector, and MigrationExecutor to synchronize model definitions with the database state.

Generating Migrations

The primary tool for creating new migrations is the makemigrations command, implemented in django.core.management.commands.makemigrations.Command. It uses the MigrationAutodetector to compare the current state of your models with the state stored in existing migration files.

Standard Generation

When you run makemigrations, the command scans all apps in INSTALLED_APPS (or specific apps if provided as arguments) and generates new migration files for any detected changes.

# Example of calling makemigrations via management utility
from django.core.management import call_command

# Create migrations for all apps
call_command("makemigrations")

# Create migrations for a specific app with a custom name
call_command("makemigrations", "myapp", name="add_user_profile")

Updating the Last Migration

The --update flag allows you to merge new model changes into the most recent migration instead of creating a new file. This is useful for cleaning up a development branch before committing.

The Command.write_to_last_migration_files method handles this logic. It performs several safety checks:

  • The migration cannot be a squashed migration.
  • The migration must not have been applied to the database yet.
  • No other migrations can depend on the migration being updated.

Handling Conflicts

If multiple developers create migrations simultaneously, the MigrationLoader may detect multiple "leaf nodes" in the migration graph. The makemigrations --merge flag triggers handle_merge, which identifies a common ancestor and creates a new "merge migration" that depends on all conflicting branches.

Applying and Reverting Changes

The migrate command (django.core.management.commands.migrate.Command) is responsible for executing the operations defined in migration files.

Applying Migrations

By default, migrate applies all unapplied migrations. It uses the MigrationExecutor to determine the necessary plan and the MigrationRecorder to track which migrations have been applied in the django_migrations table.

# Apply all migrations
call_command("migrate")

# Apply migrations for a specific app up to a certain point
call_command("migrate", "myapp", "0002")

Reverting to a Previous State

You can revert migrations by providing a target migration name. To unapply all migrations for an app, use the special name "zero".

# Unapply all migrations for the 'auth' app
call_command("migrate", "auth", "zero")

Faking Migrations

The --fake flag marks migrations as applied without actually running the SQL. This is often used when manually fixing a database state that has drifted from the migration history. The --fake-initial flag is specifically designed for the first migration of an app; it checks if the tables already exist and fakes the application if they do.

Inspecting the Migration State

The showmigrations command provides visibility into the current state of the migration graph across all databases.

List Format

The --list (or -l) format shows a checkbox-style list of migrations for each app. An [X] indicates the migration is applied.

# List migrations for the 'migrations' app
call_command("showmigrations", "migrations", format="list")
# Output:
# migrations
# [X] 0001_initial
# [ ] 0002_second

Plan Format

The --plan (or -p) format shows the order in which migrations will be applied. At higher verbosity levels, it also displays dependencies.

# Show the application plan with dependencies
call_command("showmigrations", format="plan", verbosity=2)

Maintenance and Optimization

As a project grows, the number of migration files can become unwieldy. This codebase provides tools to consolidate and optimize these files.

Squashing Migrations

The squashmigrations command combines a range of migrations into a single file. It uses the MigrationOptimizer to reduce the total number of operations (e.g., combining a CreateModel and a subsequent AddField into a single CreateModel operation).

# Squash migrations for 'myapp' from the start up to 0010
call_command("squashmigrations", "myapp", "0010")

When a migration is squashed, the new file includes a replaces attribute containing the list of migrations it supersedes. The MigrationLoader uses this to ensure that the squashed migration is only applied if the replaced migrations haven't been applied yet.

Optimizing Individual Migrations

The optimizemigration command allows you to run the optimizer on a single existing migration file. This is useful if a migration was manually edited or generated with redundant operations.

# Optimize the operations in migration 0005 of 'myapp'
call_command("optimizemigration", "myapp", "0005")

If the optimizer encounters custom Python code (like RunPython) that it cannot safely optimize through, it may flag that "manual porting" is required.

CI/CD and Consistency Checks

To maintain database integrity in automated environments, both makemigrations and migrate support a --check flag.

  • makemigrations --check: Exits with a non-zero status if there are model changes that do not have corresponding migrations. This prevents developers from forgetting to include migration files in their commits.
  • migrate --check: Exits with a non-zero status if there are unapplied migrations. This is useful in deployment pipelines to ensure the database schema is up to date before starting the application.
# Check for missing migrations in CI
python manage.py makemigrations --check --dry-run

The makemigrations command also performs a consistency check at the start of its execution by calling loader.check_consistent_history(connection), ensuring that no applied migration is missing its dependencies in the current graph.