Make the migration recorder pluggable via a MIGRATION_RECORDER setting
- Dominant language
- No language data
- Stars
- 188
- Forks
- 7
- PR merge metrics
- No merged PRs in 30d
Description
### Code of Conduct
- [x] 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`:
```python
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:
```python
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.
```python
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.
```python
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:
```python
MIGRATION_RECORDER = "myapp.recorders.FakeTrackingRecorder"
```
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by reading global_settings.py, django/db/migrations/recorder.py, MigrationExecutor, MigrationLoader, and the showmigrations command to trace every recorder instantiation. Verify how the executor resolves fake and fake-initial operations before deciding the extension seam. Done means the setting routes all listed uses through the configured recorder, the default remains compatible, and the recorder receives the resolved fake context.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- django, python
- Domain
- backend, databases
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100