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"
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
fake/applied_fakecolumn todjango_migrationsin core. Most direct, but highest-risk: that table is hand-managed byMigrationRecorderand its schema has never changed, so it would require an in-placeALTER TABLEon 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.ALTER, but still adds a permanent schema object and a join for one specific use case.migrate. Works today but relies on internals not meant to be overridden; brittle across upgrades.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
record_applied()ignores the newfakeargument, sodjango_migrationskeeps its current schema and "row present = applied" semantics.fakeparameter is additive (keyword), so any existing recorder subclasses keep working.Open questions
Migrationmodel is currently awkward (theclassproperty + _migration_classcaching); the implementation should probably also add a cleaner override point.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:2. Recorder Resolution
Resolve the recorder class using
import_string()(similar toFORM_RENDERERand other configurable Django components) and route all recorder instantiation through a helper function:The following locations should use this helper instead of directly instantiating
MigrationRecorder:MigrationExecutorMigrationLoader(2 call sites)showmigrationsmanagement command3. Pass Migration Context to Recorders
Extend recorder APIs to accept migration context information. The base implementation ignores the additional arguments, preserving existing behavior.
The migration executor already knows the resolved value of
fake(including the--fake-initialsoft-applied case) and should forward it throughrecord_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.
Configure the custom recorder in project settings: