DEV Community

Cover image for How Django keeps track of your model classes?
Srikanth
Srikanth

Posted on • Edited on

How Django keeps track of your model classes?

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

So when you write:

from myapp.models import MyModel
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Automatic and foolproof - You can't forget to register a model
  2. Happens immediately - No separate registration step needed
  3. 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:

  1. Django calls django.setup()
  2. setup() calls apps.populate(INSTALLED_APPS)
  3. For each app, Django imports models.py
  4. Importing causes Python to execute class definitions
  5. Each class MyModel(models.Model): triggers ModelBase.__new__()
  6. The metaclass registers the model in the app registry
  7. makemigrations accesses registered models via apps.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
                ...
Enter fullscreen mode Exit fullscreen mode

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:

  1. Current state: The models in your Python code (what we just discussed)
  2. 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(),
        ),
    ]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

The autodetector walks through a series of detection methods:

  1. generate_created_models() - Finds models in to_state but not in from_state
  2. generate_deleted_models() - Finds models in from_state but not in to_state
  3. generate_added_fields() - Compares fields on each model
  4. generate_removed_fields() - Looks for missing fields
  5. generate_altered_fields() - Detects changes to field properties (max_length, null, etc.)
  6. 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,
                    ),
                )
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

To this:

class Article(models.Model):
    title = models.CharField(max_length=200)
Enter fullscreen mode Exit fullscreen mode

Here's what happens:

  1. from_state has a field with max_length=100 (from previous migration)
  2. to_state has a field with max_length=200 (from your current code)
  3. Autodetector calls old_field.deconstruct() != new_field.deconstruct()
  4. Returns True because max_length differs
  5. Generates AlterField operation
  6. Creates migration file like:
operations = [
    migrations.AlterField(
        model_name='article',
        name='title',
        field=models.CharField(max_length=200),
    ),
]
Enter fullscreen mode Exit fullscreen mode

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
  • ModelBase metaclass 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)