Django: Switch to Custom User Model Mid-Project

This is an overview of how you can switch to a custom User model in your Django codebase if you’re already using it in production and don’t want to lose any data.

I had to go through this process myself because I didn’t know that using a custom User model was a must, even if you didn’t need any extra fields. In fact, the Django documentation recommends it for new projects, even though the warning is not prominent in any of the quick start tutorials and guides. Maybe they can emphasize this a bit more?

Anyway, if you’re stuck in the same situation as me, here’s how to dig yourself out of the mess.

Warning

Always make sure you have a backup of your database so you can downgrade back to a stable state!

1. Locate a Django app with an initial migration

If you already have a Django app you can place your custom User model in, congratulations! Skip to step 6.

Important: this existing Django app MUST have an initial migration (something like 0001_initial.py in the migrations/ directory). It doesn’t matter if the Django app no longer has any models associated with it.

If not, let’s continue with creating a Django app.

2. Create a new Django app

Create a new Django app in your codebase with the following command:

python3 manage.py startapp accounts

You can name it whatever you want, like users. I already had an accounts app in my project, so that’s what I’ll use for this tutorial.

3. Create a fake model to create an initial migration

Go into your new app and open up models.py. Create a new fake model:

from django.db import models


class FakeModel(models.Model):
    pass

We’ll remove this model later after everything is done.

4. Create an initial migration

Run:

python3 manage.py makemigrations

You should now have an initial migration in your app. For example, my migration was in accounts/migrations/0001_initial.py with the following content:

# Generated by Django 3.2.5 on 2021-07-12 16:28

from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='FakeModel',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
            ],
        ),
    ]

5. Release this go-between release to admins

Now, make a new release and make sure all server admins update to this version first. All instances must perform a migration on this release at least once.

The reason is because Django keeps track of what migrations it has applied. If we created a new migration that created a new table for the User model, Django would try to apply that migration, but fail because the table already exists.

To solve this problem, if we migrate all previous instances using a go-between release, and then patch the migration with the User model migration information, then existing instances will not apply the migration twice, and new instances will be able to create the User model since it’s an initial release.

6. Create a custom user model

If you came here straight from step 1, then use the app that you chose that already has an initial migration. For context, my app is accounts.

We need to now create a custom user model. Edit models.py and add the following:

from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
    class Meta:
        db_table = 'auth_user'

The db_table field is important because we’re trying to seamlessly transition from the existing User model table. I recommend naming the custom user model as User as well to preserve relations in the database. Don’t worry, you should be able to rename it after this is done.

If you followed steps 1-5, your models.py should look something like this:

from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    class Meta:
        db_table = 'auth_user'


class FakeModel(models.Model):
    pass

Don’t worry if you came straight from step 1 and don’t have the FakeModel model.

7. Manually patch the initial migration

Now, edit your app’s initial migration. You should see a lot of migrations for the other models in your app.

WARNING: this patch is valid as of July 13th, 2021, and with Django version 3.2.5. Newer Django versions may have different fields and migrations. To make sure, create an empty project, make a custom user model, and create an initial migration and compare it to this section. If it has been updated, then use the values from the empty project!

First, import the necessary dependencies:

import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone

Under dependencies, add:

        ('auth', '0012_alter_user_first_name_max_length'),

Your dependencies section should now look like this:

    dependencies = [
        ('auth', '0012_alter_user_first_name_max_length'),
    ]

Inside operations, add:

        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('is_superuser', models.BooleanField(default=False,
                                                     help_text='Designates that this user has all permissions without explicitly assigning them.',
                                                     verbose_name='superuser status')),
                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'},
                                              help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
                                              max_length=150, unique=True,
                                              validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
                                              verbose_name='username')),
                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
                ('is_staff', models.BooleanField(default=False,
                                                 help_text='Designates whether the user can log into this admin site.',
                                                 verbose_name='staff status')),
                ('is_active', models.BooleanField(default=True,
                                                  help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
                                                  verbose_name='active')),
                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
                ('groups', models.ManyToManyField(blank=True,
                                                  help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
                                                  related_name='user_set', related_query_name='user', to='auth.Group',
                                                  verbose_name='groups')),
                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.',
                                                            related_name='user_set', related_query_name='user',
                                                            to='auth.Permission', verbose_name='user permissions')),
            ],
            options={
                'db_table': 'auth_user',
            },
            managers=[
                ('objects', django.contrib.auth.models.UserManager()),
            ],
        ),

If you followed steps 1-5, your migrations file should now look something like this:

# Generated by Django 3.2.5 on 2021-07-12 16:28

import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone

from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
        ('auth', '0012_alter_user_first_name_max_length'),
    ]

    operations = [
        migrations.CreateModel(
            name='FakeModel',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
            ],
        ),
        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('is_superuser', models.BooleanField(default=False,
                                                     help_text='Designates that this user has all permissions without explicitly assigning them.',
                                                     verbose_name='superuser status')),
                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'},
                                              help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
                                              max_length=150, unique=True,
                                              validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
                                              verbose_name='username')),
                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
                ('is_staff', models.BooleanField(default=False,
                                                 help_text='Designates whether the user can log into this admin site.',
                                                 verbose_name='staff status')),
                ('is_active', models.BooleanField(default=True,
                                                  help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
                                                  verbose_name='active')),
                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
                ('groups', models.ManyToManyField(blank=True,
                                                  help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
                                                  related_name='user_set', related_query_name='user', to='auth.Group',
                                                  verbose_name='groups')),
                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.',
                                                            related_name='user_set', related_query_name='user',
                                                            to='auth.Permission', verbose_name='user permissions')),
            ],
            options={
                'db_table': 'auth_user',
            },
            managers=[
                ('objects', django.contrib.auth.models.UserManager()),
            ],
        ),
    ]

8. Enable the custom user model in settings

In your settings.py file, add:

AUTH_USER_MODEL = "accounts.User"

Remember to change accounts to your app name!

9. Create an empty migration

Now, we need to make one last migration in order to finalize the move to the new custom User model. Execute:

python3 manage.py makemigrations --empty accounts --name change_user_type

while substituting accounts with your app name. Django should create a new empty migration for you. For context, my migration was in accounts/migrations/0002_change_user_type.py.

10. Edit the empty migration

Add the following function to the top of your migrations file (below the imports):

def change_user_type(apps, schema_editor):
    ContentType = apps.get_model('contenttypes', 'ContentType')
    ct = ContentType.objects.filter(
        app_label='auth',
        model='user'
    ).first()
    if ct:
        ct.app_label = 'user'
        ct.save()

Then, under operations, add:

      migrations.RunPython(change_user_type),

When you’re done, your migrations file should look something like this (with the dependencies section being the only difference if you used your own app with many more migrations):

# Generated by Django 3.2.5 on 2021-07-12 16:38

from django.db import migrations


def change_user_type(apps, schema_editor):
    ContentType = apps.get_model('contenttypes', 'ContentType')
    ct = ContentType.objects.filter(
        app_label='auth',
        model='user'
    ).first()
    if ct:
        ct.app_label = 'user'
        ct.save()


class Migration(migrations.Migration):

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

    operations = [
        migrations.RunPython(change_user_type),
    ]

11. Migrate to new db_table field

You may now want to generate a migration to migrate to the default table. Edit models.py and remove the Meta class. If your User model is empty, make sure to add pass as well. Your User model should then look something like this:

class User(AbstractUser):
    pass

Generate a new migration.

If you skipped to step 6 from step 1, you’re done at this point. Just push a new release and let admins upgrade. Future migrations can be generated and applied just like before.

12. Remove the temporary fake models

If you followed steps 1-5, we need to remove the fake model we created to generate an initial migration. Edit models.py and remove the FakeModel model.

Generate a new migration, and deploy. Remember to make an announcement so that server admins do not forget to upgrade to the go-between release first and perform a database migration.

Conclusion

I tested the steps above with my own Django project, which uses PostgreSQL for the backend and Docker for deploy orchestration. The migration went without a hitch and I was able to keep my existing database tables without any dirty modifications.

Let me know how the steps above worked out for you in the comments below!

Credits

This blog post is based largely on this blog post from Caktus Group and the bug report on Django’s bugtracker, specifically comment 24.

comments