Right, let’s talk about migrations. This is where many Django projects go from a neat little prototype to a tangled mess of “why won’t this just work?!” if you’re not careful. I’m here to make sure that doesn’t happen to you.

Think of your models.py file as your ultimate, idealistic blueprint for your database. It’s the perfect world. Migrations are the gritty, reality-TV version of actually building that database, one messy, step-by-step change at a time. They’re Django’s way of taking the changes you make to your models and translating them into SQL commands that alter your database schema to match. This is powerful magic. It means you don’t have to be some kind of SQL wizard manually writing ALTER TABLE statements by hand, which is a fantastic way to introduce subtle, project-killing bugs.

The Two-Step Migration Dance

There are two commands you will run so often they’ll be etched into your muscle memory: makemigrations and migrate. They are a pair. You must never run one without considering the other. It’s a dance, and if you forget your partner, you step on toes. Database toes. Which hurts.

First, makemigrations. This is you telling Django, “Hey, I’ve changed some models—my blueprints—can you figure out what SQL needs to be run to make the database catch up?” Django then takes a look at the current state of your models and compares it to the previous state, which is stored in the migrations/ folder of your app. It then generates a new migration file, which is essentially a very detailed, version-controlled set of instructions.

python manage.py makemigrations myapp

This creates a file in myapp/migrations/ that looks something like 0002_auto_20230915.py. Don’t let the auto name fool you; it’s perfectly precise. Open it up! It’s just Python code. You’ll see an operation like migrations.AddField(...). This is Django’s plan. It’s always a good idea to glance at this plan before you execute it.

Once you’re happy with the plan, you execute it with migrate. This is Django connecting to your database and running the actual SQL.

python manage.py migrate

And just like that, your database schema evolves. This two-step process is the heart of it. makemigrations makes the plan, migrate executes it.

The Migration Squash: A Necessary Evil

As your project grows, you’ll accumulate dozens, even hundreds, of migration files. This is fine, but running all of them from scratch on a new database can get slow. This is where squashmigrations comes in. It’s Django’s way of taking a bunch of initial migrations and consolidating them into a single, more efficient one. It’s like taking a long book and creating a detailed summary of the first few chapters.

You run it like this:

python manage.py squashmigrations myapp 0004

This would squash all migrations for myapp up to and including 0004. The brilliance here is that it doesn’t throw away your old migration files. It keeps them around for any databases that are already part-way through the migration history, while new databases can use the new, squashed migration. It’s a backwards-compatible optimization, and it’s frankly a bit of a engineering marvel.

Data Migrations: When Schema Changes Aren’t Enough

Sometimes, changing the schema isn’t enough. Let’s say you add a new non-nullable field is_awesome to your BlogPost model. You run makemigrations, and Django will stop, scratch its head, and ask you: “Okay, but what value do you want me to put in this new column for all your existing blog posts?” It gives you two options: provide a default value right now, or quit and create a one-off data migration to handle it properly.

The default value is a quick fix, but it’s often a trap. For a boolean field, maybe a default of False is fine. But for something more complex, you need a data migration. This is where you write custom Python code inside a migration to populate the new field properly.

You create an empty migration and then write your operations:

python manage.py makemigrations --empty myapp --name backfill_is_awesome

Then you edit the generated file:

# myapp/migrations/0003_backfill_is_awesome.py

from django.db import migrations

def set_blog_post_awesome(apps, schema_editor):
    BlogPost = apps.get_model('myapp', 'BlogPost')
    # Let's assume all posts by the author "Greg" are inherently awesome
    BlogPost.objects.filter(author__username='Greg').update(is_awesome=True)
    # And all others are not
    BlogPost.objects.exclude(author__username='Greg').update(is_awesome=False)

class Migration(migrations.Migration):
    dependencies = [
        ('myapp', '0002_add_field_is_awesome'),
    ]

    operations = [
        migrations.RunPython(set_blog_post_awesome),
    ]

This is the “right” way to do it. It’s explicit, it’s testable, and it doesn’t rely on a brittle default value. Data migrations are your best friend for complex data changes. Embrace them.

The Golden Rule: Version Control is Your Lifeline

Here’s the most important piece of advice I can give you: Never, ever alter a migration file that has already been committed to your version control system and run on other developers’ machines or, heaven forbid, production servers.

That migration file 0002_auto_20230915.py is now a historical record. If you find a mistake in it, you don’t edit it. You create a new migration, 0003_fix_dumb_mistake.py, that corrects the error. Why? Because the django_migrations table in your database has already logged that 0002 has been applied. If you change 0002 and someone else pulls your code and runs migrate, their database will see the applied 0002 and skip it, completely missing your fix. You’ve created a nightmare scenario where database states diverge. If you need to change a model, you add a new migration. Full stop. Treat migrations as immutable, and you’ll sleep much better at night.