Change the primary key of a Django model

 02 October 2019   Sylvain

Category / Key words: Database / Postgresql, Django.


After a wrong choice on a data model, we can be tempted of changing the primary key of this model.

Operation not so easy if this model is referenced by foreign keys in other models.

We propose you to achieve this in 6 steps. For this, we'll start from a simple data application with two models:

from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=32)


class Item(models.Model):
    category = models.ForeignKey(
        Category,
        related_name='items',
        on_delete=models.CASCADE
    )

1. Category: creation of the new key

The first step consist in creating a new field which will become the new primary key in the end. In our example, we add a field with the type uuid.

Unfortunately, we cannot directly create a field uuid with a unique constraint :

+import uuid

 from django.db import models


 class Category(models.Model):
    ...
+   uuid = models.UUIDField(
+       max_length=36,
+       unique=True,
+       default=uuid.uuid4,
+       editable=False
+   )

With the following migration :

class Migration(migrations.Migration):

    dependencies = [
        ('data', '0001_initial'),
    ]

    operations = [
        migrations.AddField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
        ),
    ]

We would have this error during the migration:

django.db.utils.IntegrityError: could not create unique index "data_category_uuid_key"
DETAIL:  Key (uuid)=(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) is duplicated.

As explained in the django documentation:

The application of a « simple » migration which adds a unique not null field to a table which contains already some rows, will produce an error. Because the value used to fill existing rows is generated only once, which is contrary to unique constraint.

We will have to edit the migration, in order to create a field of type uuid with null=True, fill this field, then migrate this field to null=False.

def create_uuid(apps, schema_editor):
    Category = apps.get_model('data', 'Category')
    for category in Category.objects.all():
        category.uuid = uuid.uuid4()
        category.save()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0001_initial'),
    ]

    operations = [
        migrations.AddField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(default=uuid.uuid4, editable=False, null=True),
        ),
        migrations.RunPython(create_uuid, reverse_code=migrations.RunPython.noop),
        migrations.AlterField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
        )
    ]

2. Item: transform the foreign key category into an IntegerField

 class  Item(models.Model):
-    category = models.ForeignKey(
-        Category,
-        related_name='items',
-        on_delete=models.CASCADE
-    )
+    category = models.IntegerField()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0002_...'),
    ]

    operations = [
        migrations.AlterField(
            model_name='item',
            name='category',
            field=models.IntegerField(),
        ),
    ]

3. Category: the uuid field become the primary key

Turn uuid field of Category model into a primary key, without forgotting to add the id=models.IntegerField() field in order to avoid that django delete it automatically.

 class Category(models.Model):
     uuid = models.UUIDField(
         max_length=36,
-        unique=True,
+        primary_key=True,
         default=uuid.uuid4,
         editable=False
     )
+    id = models.IntegerField()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0003_...'),
    ]

    operations = [
        migrations.AlterField(
            model_name='category',
            name='id',
            field=models.IntegerField(),
        ),
        migrations.AlterField(
            model_name='category',
            name='uuid',
            field=models.UUIDField(
                default=uuid.uuid4,
                editable=False,
                primary_key=True,
                serialize=False
            ),
        ),
    ]

4. Item: rename the category field to category_old

 class  Item(models.Model):
-    category = models.IntegerField()
+    category_old = models.IntegerField()
class Migration(migrations.Migration):

    dependencies = [
        ('data', '0004_...'),
    ]

    operations = [
        migrations.RenameField(
            model_name='item',
            old_name='category',
            new_name='category_old',
        ),
    ]

5. Item: add the foreign key category

Add the foreign key category with null=True and fill it with the content of category_old field.

 class  Item(models.Model):
     ...
+    category = models.ForeignKey(
+        Category,
+        related_name='items',
+        on_delete=models.CASCADE,
+        null=True
+    )
def copy_category_uuid(apps, schema_editor):
    Item = apps.get_model('data', 'Item')
    Category = apps.get_model('data', 'Category')
    category_uuid = Category.objects.filter(id=models.OuterRef('category_old')) \
                                    .values_list('uuid')[:1]
    Item.objects.update(category=models.Subquery(category_uuid))


class Migration(migrations.Migration):

    dependencies = [
        ('data', '0005_auto_20191002_0909'),
    ]

    operations = [
        migrations.AddField(
            model_name='item',
            name='category',
            field=models.ForeignKey(
                null=True,
                on_delete=django.db.models.deletion.CASCADE,
                related_name='items',
                to='data.Category'
            ),
        ),
        migrations.RunPython(
            copy_category_uuid,
            reverse_code=migrations.RunPython.noop
        ),
    ]

6. The end !

6th and last step, we only need to delete useless fields from models and transform the foreign key category of Item model to null=False.

 class Category(models.Model):
     name = models.CharField(max_length=32)
     uuid = models.UUIDField(
         max_length=36,
         primary_key=True,
         default=uuid.uuid4,
         editable=False
     )
-    id = models.IntegerField()


 class  Item(models.Model):
-    category_old = models.IntegerField()
     category = models.ForeignKey(
         Category,
         related_name='items',
         on_delete=models.CASCADE,
-        null=True
     )