I've run python manage.py makemigrations hundreds of times. Recently, curiosity struck: How does Django even know which models exist in my project?
The Problem: Finding Needles in a Haystack
Think about it … your Django project might have dozens of apps, each with multiple model classes scattered across files. When you run makemigrations, Django somehow finds every single model, compares it to the database schema, and generates the right migrations. How?
The answer reveals one of Django's cleverest design decisions.
The Entry Point: django.setup()
Every Django management command (makemigrations, migrate, runserver, etc.) starts by calling django.setup(). This is where Django's model registry gets populated:
# django/django/__init__.py
def setup(set_prefix=True):
"""
Configure the settings (this happens as a side effect of accessing the
first setting), configure logging and populate the app registry.
"""
...
apps.populate(settings.INSTALLED_APPS)
The apps.populate() call kicks off the entire discovery process.
The Discovery Process: Import Every models.py
Django's Apps registry iterates through your INSTALLED_APPS and explicitly imports each app's models.py file:
# django/django/apps/registry.py
class Apps:
"""
A registry that stores the configuration of installed applications.
It also keeps track of models, e.g. to provide reverse relations.
"""
def populate(self, installed_apps=None):
"""
Load application configurations and models.
Import each application module and then each model module.
"""
...
# Phase 2: import models modules.
for app_config in self.app_configs.values():
app_config.import_models()
Each AppConfig then imports its models module:
# django/django/apps/config.py
class AppConfig:
"""Class representing a Django application and its configuration."""
def import_models(self):
self.models = self.apps.all_models[self.label]
if module_has_submodule(self.module, MODELS_MODULE_NAME):
models_module_name = "%s.%s" % (self.name, MODELS_MODULE_NAME)
self.models_module = import_module(models_module_name)
So when you write:
from myapp.models import MyModel
Django isn't actually relying on you to import it. Django imports your models.py file automatically, which triggers the import of every model class defined in it.
The Magic: Metaclass Registration
But here's the clever part—importing a class doesn't automatically register it anywhere. This is where Python metaclasses come in.
Every Django model inherits from models.Model, which uses ModelBase as its metaclass. A metaclass is like a "class factory"—it controls how classes themselves are created.
When Python creates your model class (during import), it calls ModelBase.__new__():
# django/django/db/models/base.py
class ModelBase(type):
"""Metaclass for all models."""
def __new__(cls, name, bases, attrs, **kwargs):
super_new = super().__new__
...
new_class._prepare()
# This is where a model class gets registered
new_class._meta.apps.register_model(new_class._meta.app_label, new_class)
return new_class
The key insight: Registration happens at class definition time, not at runtime. The moment Python evaluates class MyModel(models.Model):, the metaclass automatically registers it in Django's app registry.
Why Metaclasses?
You might wonder: couldn't Django just scan for classes that inherit from models.Model after importing? Technically yes, but metaclasses offer several advantages:
- Automatic and foolproof - You can't forget to register a model
- Happens immediately - No separate registration step needed
- Enables introspection - Django can modify the class during creation (adding reverse relations, etc.)
Putting It All Together
Now we can trace the complete flow when you run makemigrations:
- Django calls
django.setup() -
setup()callsapps.populate(INSTALLED_APPS) - For each app, Django imports
models.py - Importing causes Python to execute class definitions
- Each
class MyModel(models.Model):triggersModelBase.__new__() - The metaclass registers the model in the app registry
-
makemigrationsaccesses registered models viaapps.get_app_config(app_label).get_models()
Here's how makemigrations uses the registry:
# django/django/core/management/commands/makemigrations.py
class Command(BaseCommand):
@no_translations
def handle(self, *app_labels, **options):
...
for app_label in consistency_check_labels:
for model in apps.get_app_config(app_label).get_models():
# Check if model needs migration
...
How Django Detects Model Changes
Now that we understand how Django finds your models, the next question is: how does it know what changed?
When you run makemigrations, Django needs to answer: "What's different between my current model definitions and the database schema?" This requires comparing two things:
- Current state: The models in your Python code (what we just discussed)
- Previous state: What Django thinks the database schema looks like
The State Framework: Django's Memory
Django maintains a representation of your database schema through migration files. Each migration file contains operations that describe changes, but more importantly, it contains a state - a snapshot of what all your models looked like after that migration was applied.
Here's a simplified example:
# myapp/migrations/0002_add_email_field.py
class Migration(migrations.Migration):
dependencies = [
('myapp', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='email',
field=models.EmailField(),
),
]
Django can "replay" all migrations to build a complete picture of what the schema should look like.
Building the Project State
When makemigrations runs, it builds two parallel representations:
# django/django/core/management/commands/makemigrations.py
class Command(BaseCommand):
def handle(self, *app_labels, **options):
# 1. Load the current state from migration files
loader = MigrationLoader(None, ignore_no_migrations=True)
# 2. Build state from current model definitions
from_state = loader.project_state()
to_state = ProjectState.from_apps(apps)
from_state (the "before"): Built by replaying all existing migration files in order. This represents what Django thinks your database schema currently looks like.
to_state (the "after"): Built directly from your current model classes using ProjectState.from_apps(apps). Remember that apps registry we discussed? This is where it gets used!
The Autodetector: Finding the Differences
Django's MigrationAutodetector compares these two states:
# django/django/db/migrations/autodetector.py
class MigrationAutodetector:
def __init__(self, from_state, to_state, questioner=None):
self.from_state = from_state
self.to_state = to_state
self.questioner = questioner or MigrationQuestioner()
def changes(self, graph, trim_to_apps=None, convert_apps=None, migration_name=None):
"""
Main entry point to produce a list of applicable changes.
Returns a dict of {app_label: [Migration, ...]}
"""
# Detect all the changes
self._detect_changes()
# Generate actual migration operations
return self.arrange_for_graph(...)
The autodetector walks through a series of detection methods:
-
generate_created_models()- Finds models into_statebut not infrom_state -
generate_deleted_models()- Finds models infrom_statebut not into_state -
generate_added_fields()- Compares fields on each model -
generate_removed_fields()- Looks for missing fields -
generate_altered_fields()- Detects changes to field properties (max_length, null, etc.) - Many more - for indexes, constraints, model options, etc.
How Field Changes Are Detected
Here's a simplified look at how Django detects a changed field:
# django/django/db/migrations/autodetector.py
def generate_altered_fields(self):
for app_label, model_name in sorted(self.kept_model_keys):
old_model = self.from_state.models[app_label, model_name]
new_model = self.to_state.models[app_label, model_name]
for field_name in old_fields_keys & new_fields_keys:
old_field = old_model.get_field(field_name)
new_field = new_model.get_field(field_name)
# This is the key comparison
if old_field.deconstruct() != new_field.deconstruct():
self.add_operation(
app_label,
operations.AlterField(
model_name=model_name,
name=field_name,
field=new_field,
),
)
The deconstruct() method is crucial here. Every Django field knows how to represent itself as a serializable tuple:
# Example: EmailField().deconstruct() returns:
# ('email', 'django.db.models.EmailField', [], {'max_length': 254})
# If you change max_length=100, the deconstruct output changes:
# ('email', 'django.db.models.EmailField', [], {'max_length': 100})
Django compares these deconstructed representations. If they differ, it knows the field changed.
The Complete makemigrations Flow
Let's trace the entire journey:
1. You run: python manage.py makemigrations
2. Django calls django.setup()
└─> Imports all models (ModelBase metaclass registers them)
3. MigrationLoader reads existing migration files
└─> Builds from_state (the "before" snapshot)
4. ProjectState.from_apps(apps) reads registered models
└─> Builds to_state (the "after" snapshot)
5. MigrationAutodetector compares states
├─> generate_created_models()
├─> generate_added_fields()
├─> generate_altered_fields() ← Uses field.deconstruct()
└─> [many other detection methods]
6. Autodetector generates Operation objects
└─> CreateModel, AddField, AlterField, etc.
7. Operations are packaged into Migration class
└─> Written to migrations/000X_auto_YYYYMMDD_HHMM.py
8. Migration file saved to disk
Why This Design Matters
This architecture has interesting implications:
1. Migrations are the source of truth
Your database schema is defined by the sequence of migrations, not the current model code. This is why you can't just edit old migrations—they're historical records.
2. Autodetection isn't perfect
The autodetector can't detect everything. Field renames look like "delete old field, add new field". That's why Django asks you questions during makemigrations.
3. State is app-local, but dependencies are global
Each app tracks its own migrations, but models can reference each other across apps via ForeignKey. Django's dependency graph ensures migrations run in the right order.
A Practical Example
Let's say you change this:
class Article(models.Model):
title = models.CharField(max_length=100)
To this:
class Article(models.Model):
title = models.CharField(max_length=200)
Here's what happens:
-
from_statehas a field withmax_length=100(from previous migration) -
to_statehas a field withmax_length=200(from your current code) - Autodetector calls
old_field.deconstruct() != new_field.deconstruct() - Returns
Truebecause max_length differs - Generates
AlterFieldoperation - Creates migration file like:
operations = [
migrations.AlterField(
model_name='article',
name='title',
field=models.CharField(max_length=200),
),
]
The Takeaway
Django's model change detection is a two-phase process:
Phase 1 - Model Discovery (what we covered first):
-
django.setup()imports your models -
ModelBasemetaclass registers them automatically - Apps registry holds all model definitions
Phase 2 - Change Detection (what we just covered):
- Build "before" state from migration files
- Build "after" state from registered models
- Compare using
MigrationAutodetector - Generate operations for differences
Understanding this helps you debug:
- "Why isn't Django detecting my change?" (Check if the field's
deconstruct()actually changed) - "Why does Django think I'm deleting and adding a field?" (It can't detect renames automatically)
- "Can I edit old migrations?" (No—they're the historical record used to build
from_state) - "What if I squash migrations?" (You're combining the historical record, but the end state stays the same)
The elegance is in the separation: model discovery uses metaclasses and imports, while change detection uses state comparison. The only database access needed is reading the django_migrations table to see which migrations have already been applied—this tells Django which point in the migration history represents your current schema state.
Cover image by John Fowler
Top comments (0)