Skip to content

Make the migration recorder pluggable via a MIGRATION_RECORDER setting #180

Description

@Muazzam741

Code of Conduct

  • I agree to follow Django's Code of Conduct

Feature Description

Allow projects to configure which MigrationRecorder class Django uses to record applied/unapplied migrations, via a new MIGRATION_RECORDER setting (a dotted path, defaulting to today's recorder). As a small companion change, pass the resolved fake flag into record_applied() so custom recorders can record how a migration was applied without Django committing to any schema change.

Problem

The django_migrations table records that a migration was applied, but not how. In particular, a migration applied with migrate --fake / --fake-initial is indistinguishable from one that actually ran its database operations.

For teams that use faking to reconcile state, this regularly causes hard-to-debug incidents: the migration history claims a migration is applied, but the corresponding schema changes were never made. The information already exists at runtime (migrate even prints FAKED), it is just never persisted, and there is no supported way for a project to capture it.

Today the only options are monkeypatching MigrationRecorder or subclassing the migrate command — both reach into internals not designed to be overridden, and break easily across upgrades. More generally there's recurring demand to customize how migration state is recorded (audit trails, recording the applying user/host, writing to an external store, multi-tenant tagging), all of which currently require fragile patching.

Request or proposal

proposal

Additional Details

Alternatives considered

  • Add a fake/applied_fake column to django_migrations in core. Most direct, but highest-risk: that table is hand-managed by MigrationRecorder and its schema has never changed, so it would require an in-place ALTER TABLE on a core table in every existing project. It also forces one opinion about semantics (boolean vs. state column, behavior for replaces/squashed migrations) onto everyone while solving only one narrow problem.
  • A separate core-managed metadata table. Avoids the in-place ALTER, but still adds a permanent schema object and a join for one specific use case.
  • Userland monkeypatching / subclassing migrate. Works today but relies on internals not meant to be overridden; brittle across upgrades.
  • Schema-drift detection tooling. Valuable and broader, but orthogonal and much larger in scope.

The pluggable recorder is the smallest change that unblocks all of the above as third-party or project-local code, aligning with Django's preference for extension points over baking in specific features.

Backwards compatibility

  • Default behavior is unchanged: the setting points at the existing class, and the base record_applied() ignores the new fake argument, so django_migrations keeps its current schema and "row present = applied" semantics.
  • The new fake parameter is additive (keyword), so any existing recorder subclasses keep working.

Open questions

  • Subclassing the floating Migration model is currently awkward (the classproperty + _migration_class caching); the implementation should probably also add a cleaner override point.
  • Is record_applied(..., fake=...) the right seam, or should the executor expose richer context (forward/backward, fake-initial vs. explicit fake)?

Disclosure: this proposal was drafted with help from an AI coding tool (Cursor), used to research the migration recorder internals and structure the write-up; reviewed and verified by me.

Implementation Suggestions

1. New Setting

Add a new setting in global_settings.py:

MIGRATION_RECORDER = "django.db.migrations.recorder.MigrationRecorder"

2. Recorder Resolution

Resolve the recorder class using import_string() (similar to FORM_RENDERER and other configurable Django components) and route all recorder instantiation through a helper function:

from django.conf import settings
from django.utils.module_loading import import_string

def get_migration_recorder(connection):
    return import_string(settings.MIGRATION_RECORDER)(connection)

The following locations should use this helper instead of directly instantiating MigrationRecorder:

  • MigrationExecutor
  • MigrationLoader (2 call sites)
  • showmigrations management command

3. Pass Migration Context to Recorders

Extend recorder APIs to accept migration context information. The base implementation ignores the additional arguments, preserving existing behavior.

def record_applied(self, app, name, fake=False):
    self.ensure_schema()
    self.migration_qs.create(app=app, name=name)

The migration executor already knows the resolved value of fake (including the --fake-initial soft-applied case) and should forward it through record_migration().

4. Example: Tracking Fake Migrations

Projects can provide a custom migration recorder implementation to capture whether a migration was applied normally or via a fake operation.

Note: This recorder lives in the project's codebase. Any schema changes to the migration table are owned and managed by the project, not Django.

from django.db import models
from django.db.migrations.recorder import MigrationRecorder
from django.utils.functional import classproperty


class FakeTrackingRecorder(MigrationRecorder):
    _migration_class = None

    @classproperty
    def Migration(cls):
        if cls._migration_class is None:
            base = super().Migration

            class Migration(base):
                applied_fake = models.BooleanField(default=False)

                class Meta:
                    apps = base._meta.apps
                    app_label = "migrations"
                    db_table = "django_migrations"

            cls._migration_class = Migration

        return cls._migration_class

    def record_applied(self, app, name, fake=False):
        self.ensure_schema()
        self.migration_qs.create(
            app=app,
            name=name,
            applied_fake=fake,
        )

Configure the custom recorder in project settings:

MIGRATION_RECORDER = "myapp.recorders.FakeTrackingRecorder"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Idea

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions