Experimental. fastmig is a drop-in add-on for Django (tested with 4.2, 5.2
and 6.0) that makes migrate on large projects several times faster without
changing the SQL that gets executed.
pip install django-fastmig
INSTALLED_APPS = ["fastmig", *INSTALLED_APPS] # or: import fastmig; fastmig.install()FASTMIG_DISABLE=1 turns it off. Pure Python, no settings, no changes to your
migrations, models or database.
Fresh database; every DDL statement the schema editor executes is logged and compared between the two runs.
| project | models / migrations | backend | stock | fastmig | speedup | DDL |
|---|---|---|---|---|---|---|
| "Project A" — real in-house inventory/ERP app (taggit, modeltranslation, auditlog, pgtrigger, 49 data migrations) | 222 / 493 | PostgreSQL 15 | 154–178 s | 17–22 s | 8–9.5× | identical (22 557 statements) |
Project A, pytest --create-db, one app |
PostgreSQL 15 | 170 s | 23 s | 7× | ||
Project A, reverse: migrate <app> zero for all 22 apps |
222 / 493 | PostgreSQL 15 | 851 s | 39 s | 21× | identical (15 039 statements) |
| generated, 400 models | 400 / 818 | SQLite | 313–364 s | 57 s | 5.5–6.4× | identical (11 262) |
| generated, 120 models | 120 / 223 | SQLite / PostgreSQL | 17–20 s | 4–5 s | 3.5–4.6× | identical |
generated corner cases (MTI, proxies, O2O pks, to_field, explicit through, renames, pk type changes) |
53 / 59 | SQLite / PostgreSQL | 1.5 / 3.0 s | 0.7 / 2.2 s | 2× / 1.4× | identical |
Reverse migrations gain the most: before unapplying, Django recomputes the project state for every migration in the plan by cloning and mutating it, which repeats the whole re-rendering cost on top of the forward pass.
What remains is mostly the database executing DDL and the class renders Django
still needs (including a full re-render before every RunPython, same as
stock). Scripts and generators are in benchmarks/.
Django's own test suite passes with fastmig installed: the migrations and
schema suites (1 033 tests) on both SQLite and PostgreSQL, plus 1 435 tests
from related labels (migrate_signals, contenttypes_tests, auth_tests,
proxy_models, model_inheritance, swappable_models, backends, …).
The schema editor works on rendered model classes. After every operation
Django rebuilds ("fakes") classes with type(...) — expensive pure-Python work
(ModelBase.__new__, field cloning via deconstruct(), contribute_to_class,
M2M through models). For any relational change stock Django rebuilds the
model's entire connected component; on a connected schema that is
O(models) class builds per operation. The 120-model project above rebuilds
37 080 classes over 223 migrations; fastmig rebuilds 1 163. See the forum
thread Very slow migrations with large numbers of tables.
That patch (SHALLOW_RELOAD_MIGRATION) reloads only the mutated model. It gets
the speed, but models that point at the re-rendered one keep pointing at the
old class object — reverse relations and FK targets can land on the wrong
class, which is what Django's maintainers warned about. It usually works
because schema editors mostly read table/column names, which are the same on
the stale class.
fastmig:
- re-renders the mutated model plus what genuinely needs a new class:
subclasses/proxies, targets of added/removed/altered relations, and — when a
referenced field, pk or relation changed — direct predecessors, propagating
through pk-type dependencies (MTI, O2O primary keys,
to_field); - re-links every other model's forward pointers to the new class through
Django's own
do_related_class(), clearing cached properties and following auto-created through tables; - preserves the pre-change class that
RenameModel/AlterFieldon a pk read fromfrom_state(stock only gets it by accident of traversal order); - unregisters stale auto-created through classes;
- re-renders instead of re-linking for third-party relation fields it doesn't
know (e.g. taggit's
TaggableManager); - keeps Django's
from_statecontract (#24225, #24573).
The plan is computed from ModelState objects, never from rendered classes.
fastmig.install() (run by the AppConfig) monkey-patches
django.db.migrations.state.ProjectState: reload_model/reload_models
(planner + re-link), add_field/remove_field/alter_field/add_model/
rename_model/remove_model (hints about what changed), and clone
(cheaper, equivalent). uninstall() restores everything. fastmig.stats
counts reloads, renders and re-linked fields.
- Validated on SQLite and PostgreSQL only; not on MySQL/Oracle.
- Third-party operations that mutate a model's
ModelStateand callstate.reload_model()for it (pgtrigger,django.contrib.postgres) work unchanged. Untested: an operation that mutates model X but only worked because stock's component-wide re-render rebuilt X as a side effect of reloading another model. I know of no package doing this. - Some operations emit several independent DDL statements (e.g. altering the FK column in each table referencing a changed pk). Django orders them by model-registry order, which is hash-seed dependent — stock Django itself emits them in varying order between runs. The benchmark scripts treat a pure reordering of otherwise identical statements as a match.
- If anything looks off:
FASTMIG_DISABLE=1, andbenchmarks/bench_real.pygives you a full stock-vs-fastmig DDL diff for your project.
pip install -e . django
python -m unittest discover -s tests -v # unit + SQL parity tests
python benchmarks/gen_project.py benchmarks/demo --apps 8 --models 15 --rounds 30
python benchmarks/bench.py benchmarks/demo [--pg dbname] # stock vs fastmig, DDL diff
python benchmarks/bench_real.py <project> <python> <settings> <pg_db> [runs]
DJANGO_SRC=/path/to/django python benchmarks/run_django_tests.py --settings test_sqlite migrations schema
BSD 3-Clause license.