Categories
Posts

Changing a Django model’s primary key with migrations

Recently, I took on the task of internationalizing a Django app. The app included the following two simple models for tagging user Profiles with a set of pre-defined Tags:

class Profile(models.Model):
    name = models.CharField()
    tags = models.ManyToManyField('Tag', blank=True)


class Tag(models.Model):
    text = models.CharField(max_length=255)
    slug = models.SlugField(primary_key=True)

“Wait a minute…”, I thought to myself while scrolling through the models.py module for the first time. “How am I supposed to add a translation for slug when it is the primary key?” After all, multilingual URLs were part of the requirement and so all slugs used in URLs had to be translatable as well.

I figured that the cleanest option was adding a translation slug field for each supported language and introducing a surrogate key instead of having a SlugField as the primary key.

As a wise Stack Overflow user once put it, changing the primary key of a model is like “doing open-heart surgery”. If you have very complex and/or intertwined models with lots of instances, it’s probably best to follow the approach presented in the linked Stack Overflow answer.

If, on the other hand, you are dealing with only a few models and instances, feel free to try out the following steps to achieve the task of replacing a Django model’s natural key with a surrogate key. It goes without saying that you should have a database backup ready and test everything multiple times before you migrate your production database.

  1. Create a second, nearly identical model with a surrogate primary key by copy-and-pasting the old model:
class Tag(models.Model):
    text = models.CharField(max_length=255)
    slug = models.SlugField(primary_key=True)


class Tag2(models.Model):
    text = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
  1. Add a new ForeignKey('Tag2', …) or ManyToManyField('Tag2', …) field to each model that already has a foreign key pointing to the original Tag model (e.g. Profile):
class Profile(models.Model):
    name = models.CharField()
    tags = models.ManyToManyField('Tag', blank=True, related_name='profiles')
    tags2 = models.ManyToManyField('Tag2', blank=True, related_name='profiles_2')
  1. Generate a new migration, but don’t run it yet:
[zepp@arch myapp]$ ./manage.py makemigrations myapp
  1. Instead, add a RunPython operation to the newly created migration. This operation should create one instance of the new model for each instance of the old model, thereby basically converting the old data to the new format:
from django.db import migrations, models
import django.db.models.deletion


def convert_tags(apps, schema_editor):
    Tag = apps.get_model('myapp', 'Tag')
    Tag2 = apps.get_model('myapp', 'Tag2')
    for tag in Tag.objects.all():
        tag_2 = Tag2.objects.create(text=tag.text, slug=tag.slug, set=tag.set)
        for p in tag.profiles.all():
            p.tags2.add(tag_2)


class Migration(migrations.Migration):

    dependencies = [
        ('myapp', '0002_auto_20200402_0423'),
    ]

    operations = [
        migrations.CreateModel(
            name='Tag2',
            fields=[
                ('text', models.CharField(max_length=255)),
                ('slug', models.SlugField(unique=True)),
            ],
            options={
                'verbose_name': 'Tag',
                'verbose_name_plural': 'Tags',
                'abstract': False,
            },
        ),
        migrations.AddField(
            model_name='profile',
            name='tags2',
            field=models.ManyToManyField(blank=True, related_name='profiles_2', to='myapp.Tag2'),
        ),
        ),
        migrations.RunPython(convert_tags),
    ]
  1. At this point, you have essentially copied the original model (Tag) as well as its instances into a new model (Tag2) with a surrogate primary key.
  2. It’s now time to get rid of the old model and data by deleting the original model. You’ll probably have to comment out a few lines of code so as to make the makemigrations command run again. It’s probably a good idea to mark those lines with a specific comment so you can find them again quickly. Once you are done, run the makemigrations command:
[zepp@arch myapp]$ ./manage.py makemigrations myapp
Migrations for 'myapp':
  myapp/migrations/0004_auto_20200426_0705.py
    - Remove field tags from profile
    - Delete model Tag
  1. Next, rename the new model to have the same name as the old one:
[zepp@arch myapp]$ ./manage.py makemigrations myapp                                                                                                                                                                    
Did you rename the myapp.Tag2 model to Tag? [y/N] y                                                               
Migrations for 'myapp':                                                                                                                                                                                                                  
  myapp/migrations/0005_auto_20200426_0707.py                                                                                                                                                                                            
    - Rename model Tag2 to Tag
  1. The same step is necessary for the fields referencing Tag2 (e.g. tags2 on Profile):
[zepp@arch myapp]$ ./manage.py makemigrations myapp                                                                                                      
Did you rename profile.tags2 to profile.tags (a ManyToManyField)? [y/N] y                                                                                                                     
Migrations for 'myapp':                                                                                                                                                                                                                  
  myapp/migrations/0006_auto_20200426_0709.py                                                                     
    - Rename field tags2 on profile to tags                                                                                                                                                                                            
  1. Uncomment the lines of code you commented out earlier and, if applicable, adapt any other places in your code that rely on the old (natural) primary key.
  2. Finally, run the migrate Django command to apply the created migrations:
[zepp@arch myapp]$ ./manage.py migrate myapp
Operations to perform:
  Apply all migrations: myapp
Running migrations:
  Applying myapp.0003_auto_20200426_0659... OK
  Applying myapp.0004_auto_20200426_0705... OK
  Applying myapp.0005_auto_20200426_0707... OK
  Applying myapp.0006_auto_20200426_0709... OK